Skip to content

Commit cbea003

Browse files
sarahdayanclaude
andauthored
feat(autocomplete): add noResults template support (#90)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9d758d7 commit cbea003

File tree

12 files changed

+645
-18
lines changed

12 files changed

+645
-18
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './autocomplete-item';
22
export * from './carousel-item';
33
export * from './list-item';
4+
export * from './no-results';
45
export * from './panel-layout';
56
export * from './section-header';
67
export * from './utils';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { TemplateParams } from 'instantsearch.js/es/types';
2+
3+
export function renderNoResults(message: string) {
4+
return function noResults(
5+
_data: Record<string, never>,
6+
{ html }: TemplateParams
7+
) {
8+
return html`<span>${message}</span>`;
9+
};
10+
}

packages/runtime/src/experiences/templates/panel-layout.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,94 @@ type PanelElements = Partial<
44
Record<'recent' | 'suggestions' | (string & {}), preact.JSX.Element>
55
>;
66

7+
type PanelIndex = {
8+
indexName: string;
9+
hits: Array<{ objectID: string }>;
10+
results: { query: string };
11+
};
12+
713
type PanelData = {
814
elements: PanelElements;
9-
indices: Array<{ indexName: string }>;
15+
indices: Array<PanelIndex>;
16+
};
17+
18+
type PanelTemplate = (data: PanelData, params: TemplateParams) => unknown;
19+
20+
type GlobalNoResultsConfig = {
21+
title?: string;
22+
description?: string;
23+
clearLabel?: string;
1024
};
1125

26+
function defaultPanel(data: PanelData, { html }: TemplateParams) {
27+
return html`${Object.keys(data.elements).map((key) => {
28+
return data.elements[key];
29+
})}`;
30+
}
31+
32+
export function withGlobalNoResults(
33+
config: GlobalNoResultsConfig,
34+
inner?: PanelTemplate
35+
): PanelTemplate {
36+
const innerPanel = inner ?? defaultPanel;
37+
38+
return function panel(data: PanelData, { html }: TemplateParams) {
39+
const allEmpty =
40+
data.indices.length > 0 &&
41+
data.indices.every((index) => {
42+
return index.hits.length === 0;
43+
});
44+
45+
if (!allEmpty) {
46+
return innerPanel(data, { html } as TemplateParams);
47+
}
48+
49+
const query = data.indices[0]?.results?.query ?? '';
50+
const title = config.title
51+
? config.title.replaceAll('{{query}}', query)
52+
: undefined;
53+
const { description, clearLabel } = config;
54+
55+
return html`<div class="ais-AutocompleteNoResults">
56+
<svg
57+
class="ais-AutocompleteNoResults-icon"
58+
viewBox="0 0 24 24"
59+
fill="currentColor"
60+
>
61+
<path
62+
d="M16.041 15.856c-0.034 0.026-0.067 0.055-0.099 0.087s-0.060 0.064-0.087 0.099c-1.258 1.213-2.969 1.958-4.855 1.958-1.933 0-3.682-0.782-4.95-2.050s-2.050-3.017-2.050-4.95 0.782-3.682 2.050-4.95 3.017-2.050 4.95-2.050 3.682 0.782 4.95 2.050 2.050 3.017 2.050 4.95c0 1.886-0.745 3.597-1.959 4.856zM21.707 20.293l-3.675-3.675c1.231-1.54 1.968-3.493 1.968-5.618 0-2.485-1.008-4.736-2.636-6.364s-3.879-2.636-6.364-2.636-4.736 1.008-6.364 2.636-2.636 3.879-2.636 6.364 1.008 4.736 2.636 6.364 3.879 2.636 6.364 2.636c2.125 0 4.078-0.737 5.618-1.968l3.675 3.675c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414z"
63+
></path>
64+
</svg>
65+
66+
${title && html`<p class="ais-AutocompleteNoResults-title">${title}</p>`}
67+
${description &&
68+
html`<p class="ais-AutocompleteNoResults-description">${description}</p>`}
69+
${clearLabel &&
70+
html`<button
71+
type="button"
72+
class="ais-AutocompleteNoResults-clear"
73+
onClick=${() => {
74+
const root = document.querySelector('.ais-Autocomplete');
75+
const input = root?.querySelector<HTMLInputElement>(
76+
'input[type="search"]'
77+
);
78+
if (input) {
79+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
80+
HTMLInputElement.prototype,
81+
'value'
82+
)?.set;
83+
nativeInputValueSetter?.call(input, '');
84+
input.dispatchEvent(new Event('input', { bubbles: true }));
85+
input.focus();
86+
}
87+
}}
88+
>
89+
${clearLabel}
90+
</button>`}
91+
</div>`;
92+
};
93+
}
94+
1295
export function renderTwoColumnsPanel() {
1396
return function panel(data: PanelData, { html }: TemplateParams) {
1497
const leftKeys = ['recent', 'suggestions'];

packages/runtime/src/experiences/widget.tsx

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import {
3232
renderAutocompleteItem,
3333
renderCarouselItem,
3434
renderListItem,
35+
renderNoResults,
3536
renderSectionHeader,
3637
renderTwoColumnsPanel,
38+
withGlobalNoResults,
3739
SKELETON_CSS,
3840
} from './templates';
3941
import type { ExperienceWidget } from './types';
@@ -144,6 +146,7 @@ export default (function experience(
144146
indices,
145147
detachedMediaQuery,
146148
panelLayout,
149+
noResults: globalNoResults,
147150
...rest
148151
} = params as typeof params & {
149152
showRecent?: false | { templates: { header: string } };
@@ -153,27 +156,40 @@ export default (function experience(
153156
// oxlint-disable-next-line id-length -- backward compat for old configs
154157
q?: string;
155158
indexName?: string;
156-
templates?: { header?: string };
159+
templates?: { header?: string; noResults?: string };
157160
};
158161
indices?: Array<{
159162
indexName: string;
160163
hitsPerPage?: number;
161164
templates?: {
162165
header?: string;
163166
item?: Record<string, string>;
167+
noResults?: string;
164168
};
165169
searchParameters?: Record<string, unknown>;
166170
}>;
167171
detachedMediaQuery?: string;
168172
panelLayout?: 'one-column' | 'two-columns';
173+
noResults?:
174+
| false
175+
| {
176+
title?: string;
177+
description?: string;
178+
clearLabel?: string;
179+
};
169180
};
170181

182+
const twoColumns =
183+
panelLayout === 'two-columns' ? renderTwoColumnsPanel() : undefined;
184+
const panelTemplate =
185+
globalNoResults && typeof globalNoResults === 'object'
186+
? withGlobalNoResults(globalNoResults, twoColumns)
187+
: twoColumns;
188+
171189
return Promise.resolve({
172190
...rest,
173191
...(detachedMediaQuery ? { detachedMediaQuery } : {}),
174-
...(panelLayout === 'two-columns'
175-
? { templates: { panel: renderTwoColumnsPanel() } }
176-
: {}),
192+
...(panelTemplate ? { templates: { panel: panelTemplate } } : {}),
177193
...(showRecent
178194
? {
179195
showRecent: showRecent.templates.header
@@ -191,15 +207,25 @@ export default (function experience(
191207
? {
192208
showQuerySuggestions: {
193209
indexName: showQuerySuggestions.indexName,
194-
...(showQuerySuggestions.templates?.header
195-
? {
196-
templates: {
197-
header: renderSectionHeader(
198-
showQuerySuggestions.templates.header
199-
),
200-
},
201-
}
202-
: {}),
210+
...((showQuerySuggestions.templates?.header ||
211+
showQuerySuggestions.templates?.noResults) && {
212+
templates: {
213+
...(showQuerySuggestions.templates?.header
214+
? {
215+
header: renderSectionHeader(
216+
showQuerySuggestions.templates.header
217+
),
218+
}
219+
: {}),
220+
...(showQuerySuggestions.templates?.noResults
221+
? {
222+
noResults: renderNoResults(
223+
showQuerySuggestions.templates.noResults
224+
),
225+
}
226+
: {}),
227+
},
228+
}),
203229
...(showQuerySuggestions.searchPageUrl
204230
? {
205231
getURL: (item: { query: string }) => {
@@ -250,6 +276,13 @@ export default (function experience(
250276
),
251277
}
252278
: {}),
279+
...(entryTemplates?.noResults
280+
? {
281+
noResults: renderNoResults(
282+
entryTemplates.noResults
283+
),
284+
}
285+
: {}),
253286
},
254287
};
255288
}

packages/theme/src/widgets/autocomplete/autocomplete.css

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@
258258
transform: scale(1) translateY(0);
259259
}
260260

261-
.ais-AutocompletePanel button {
261+
.ais-AutocompletePanel button:not(.ais-AutocompleteNoResults-clear) {
262262
-webkit-appearance: none;
263263
-moz-appearance: none;
264264
appearance: none;
@@ -484,6 +484,82 @@
484484
width: var(--ais-autocomplete-icon-size);
485485
}
486486

487+
/* --- Global no results --- */
488+
.ais-AutocompleteNoResults {
489+
display: flex;
490+
flex-direction: column;
491+
align-items: center;
492+
justify-content: center;
493+
padding: var(--ais-autocomplete-no-results-padding-y)
494+
var(--ais-autocomplete-no-results-padding-x);
495+
text-align: center;
496+
gap: var(--ais-autocomplete-no-results-gap);
497+
}
498+
499+
.ais-AutocompleteNoResults-icon {
500+
width: var(--ais-autocomplete-no-results-icon-size);
501+
height: var(--ais-autocomplete-no-results-icon-size);
502+
padding: var(--ais-autocomplete-no-results-icon-padding);
503+
border-radius: 50%;
504+
color: rgba(
505+
var(--ais-autocomplete-no-results-icon-color),
506+
var(--ais-autocomplete-no-results-icon-opacity)
507+
);
508+
box-sizing: content-box;
509+
}
510+
511+
.ais-AutocompleteNoResults-title {
512+
font-size: var(--ais-autocomplete-no-results-title-font-size);
513+
font-weight: var(--ais-autocomplete-no-results-title-font-weight);
514+
margin: 0;
515+
color: rgb(var(--ais-autocomplete-no-results-title-color));
516+
}
517+
518+
.ais-AutocompleteNoResults-description {
519+
font-size: var(--ais-autocomplete-no-results-description-font-size);
520+
color: rgba(
521+
var(--ais-autocomplete-no-results-description-color),
522+
var(--ais-autocomplete-no-results-description-opacity)
523+
);
524+
margin: 0;
525+
}
526+
527+
.ais-AutocompleteNoResults-clear {
528+
font-size: var(--ais-autocomplete-no-results-clear-font-size);
529+
font-family: inherit;
530+
font-weight: var(--ais-autocomplete-no-results-clear-font-weight);
531+
padding: var(--ais-autocomplete-no-results-clear-padding-y)
532+
var(--ais-autocomplete-no-results-clear-padding-x);
533+
border-radius: var(--ais-autocomplete-no-results-clear-border-radius);
534+
border: var(--ais-autocomplete-no-results-clear-border-width) solid
535+
rgba(
536+
var(--ais-autocomplete-no-results-clear-border-color),
537+
var(--ais-autocomplete-no-results-clear-border-opacity)
538+
);
539+
background: rgba(
540+
var(--ais-autocomplete-no-results-clear-background-color),
541+
var(--ais-autocomplete-no-results-clear-background-opacity)
542+
);
543+
color: rgb(var(--ais-autocomplete-no-results-clear-color));
544+
cursor: pointer;
545+
transition:
546+
background-color var(--ais-autocomplete-transition-duration)
547+
var(--ais-autocomplete-transition-timing-function),
548+
border-color var(--ais-autocomplete-transition-duration)
549+
var(--ais-autocomplete-transition-timing-function);
550+
}
551+
552+
.ais-AutocompleteNoResults-clear:hover {
553+
border-color: rgba(
554+
var(--ais-autocomplete-no-results-clear-border-color),
555+
var(--ais-autocomplete-no-results-clear-hover-border-opacity)
556+
);
557+
background: rgba(
558+
var(--ais-autocomplete-no-results-clear-background-color),
559+
var(--ais-autocomplete-no-results-clear-hover-background-opacity)
560+
);
561+
}
562+
487563
/* --- Two-column panel layout --- */
488564
.ais-AutocompletePanelTwoColumns {
489565
display: grid;

0 commit comments

Comments
 (0)