Skip to content

Commit 3ff224f

Browse files
feat: add FilterSuggestions widget (#6861)
* feat: add `RefinementSuggestions` widget * feat: add customisable items, headers and empty component props * implement instantsearch.js widget, use streaming=false and tests * revert example change * fix test * bundlesize changed after merge * feat: allow disabling header * feat: add customizable skeleton class names * rename to filter suggestions * fix lint * transport option * fix tests * address comments * export in umd * add Promise.prototype.finally --------- Co-authored-by: Aymeric Giraudet <aymeric.giraudet@algolia.com>
1 parent 8181fac commit 3ff224f

File tree

37 files changed

+2334
-6
lines changed

37 files changed

+2334
-6
lines changed

babel.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,12 @@ module.exports = (api) => {
8181
// we require polyfills for this already
8282
'Array.prototype.includes',
8383

84-
// Used only in Chat, which expects modern browsers
84+
// Used only in newer widgets, which expect modern browsers
8585
'Object.fromEntries',
8686
'Object.entries',
8787
'Array.prototype.find',
8888
'String.prototype.startsWith',
89+
'Promise.prototype.finally',
8990

9091
// false positive (babel doesn't know types)
9192
// this is actually only called on arrays

bundlesize.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
},
4343
{
4444
"path": "./packages/instantsearch.css/themes/algolia.css",
45-
"maxSize": "9 kB"
45+
"maxSize": "9.25 kB"
4646
},
4747
{
4848
"path": "./packages/instantsearch.css/themes/algolia-min.css",
@@ -58,7 +58,7 @@
5858
},
5959
{
6060
"path": "./packages/instantsearch.css/themes/satellite.css",
61-
"maxSize": "10 kB"
61+
"maxSize": "10.25 kB"
6262
},
6363
{
6464
"path": "./packages/instantsearch.css/themes/satellite-min.css",
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/** @jsx createElement */
2+
import { cx } from '../lib';
3+
4+
import { createButtonComponent } from './Button';
5+
import { SparklesIcon } from './chat/icons';
6+
7+
import type { ComponentProps, Renderer } from '../types';
8+
9+
export type Suggestion = {
10+
/**
11+
* The facet attribute name.
12+
*/
13+
attribute: string;
14+
/**
15+
* The facet value to filter by.
16+
*/
17+
value: string;
18+
/**
19+
* Human-readable display label.
20+
*/
21+
label: string;
22+
/**
23+
* Number of records matching this filter.
24+
*/
25+
count: number;
26+
};
27+
28+
export type FilterSuggestionsItemComponentProps = {
29+
suggestion: Suggestion;
30+
classNames: Partial<
31+
Pick<
32+
FilterSuggestionsClassNames,
33+
'item' | 'itemRefined' | 'button' | 'label' | 'count'
34+
>
35+
>;
36+
refine: () => void;
37+
};
38+
39+
export type FilterSuggestionsHeaderComponentProps = {
40+
classNames: Partial<
41+
Pick<FilterSuggestionsClassNames, 'header' | 'headerIcon' | 'headerTitle'>
42+
>;
43+
};
44+
45+
export type FilterSuggestionsEmptyComponentProps = {
46+
classNames: Partial<Pick<FilterSuggestionsClassNames, 'emptyRoot'>>;
47+
};
48+
49+
export type FilterSuggestionsProps = ComponentProps<'div'> & {
50+
suggestions: Suggestion[];
51+
isLoading: boolean;
52+
refine: (attribute: string, value: string) => void;
53+
classNames?: Partial<FilterSuggestionsClassNames>;
54+
/**
55+
* Number of skeleton items to show when loading.
56+
* @default 3
57+
*/
58+
skeletonCount?: number;
59+
/**
60+
* Component to render each suggestion item.
61+
*/
62+
itemComponent?: (props: FilterSuggestionsItemComponentProps) => JSX.Element;
63+
/**
64+
* Component to render the header. Set to `false` to disable the header.
65+
*/
66+
headerComponent?:
67+
| ((props: FilterSuggestionsHeaderComponentProps) => JSX.Element)
68+
| false;
69+
/**
70+
* Component to render when there are no suggestions.
71+
*/
72+
emptyComponent?: (
73+
props: FilterSuggestionsEmptyComponentProps
74+
) => JSX.Element | null;
75+
};
76+
77+
export type FilterSuggestionsClassNames = {
78+
/**
79+
* Class names to apply to the root element
80+
*/
81+
root: string | string[];
82+
/**
83+
* Class names to apply to the root element when loading
84+
*/
85+
loadingRoot: string | string[];
86+
/**
87+
* Class names to apply to the root element when empty
88+
*/
89+
emptyRoot: string | string[];
90+
/**
91+
* Class names to apply to the header element
92+
*/
93+
header: string | string[];
94+
/**
95+
* Class names to apply to the header icon element
96+
*/
97+
headerIcon: string | string[];
98+
/**
99+
* Class names to apply to the header title element
100+
*/
101+
headerTitle: string | string[];
102+
/**
103+
* Class names to apply to the skeleton container element
104+
*/
105+
skeleton: string | string[];
106+
/**
107+
* Class names to apply to each skeleton item element
108+
*/
109+
skeletonItem: string | string[];
110+
/**
111+
* Class names to apply to the list element
112+
*/
113+
list: string | string[];
114+
/**
115+
* Class names to apply to each item element
116+
*/
117+
item: string | string[];
118+
/**
119+
* Class names to apply to the item element when refined
120+
*/
121+
itemRefined: string | string[];
122+
/**
123+
* Class names to apply to the button element
124+
*/
125+
button: string | string[];
126+
/**
127+
* Class names to apply to the label element
128+
*/
129+
label: string | string[];
130+
/**
131+
* Class names to apply to the count element
132+
*/
133+
count: string | string[];
134+
};
135+
136+
export function createFilterSuggestionsComponent({ createElement }: Renderer) {
137+
const Button = createButtonComponent({ createElement });
138+
139+
function DefaultHeader({
140+
classNames,
141+
}: FilterSuggestionsHeaderComponentProps) {
142+
return (
143+
<div className={cx('ais-FilterSuggestions-header', classNames.header)}>
144+
<span
145+
className={cx(
146+
'ais-FilterSuggestions-headerIcon',
147+
classNames.headerIcon
148+
)}
149+
>
150+
<SparklesIcon createElement={createElement} />
151+
</span>
152+
<span
153+
className={cx(
154+
'ais-FilterSuggestions-headerTitle',
155+
classNames.headerTitle
156+
)}
157+
>
158+
Filter suggestions
159+
</span>
160+
</div>
161+
);
162+
}
163+
164+
function DefaultItem({
165+
suggestion,
166+
classNames,
167+
refine,
168+
}: FilterSuggestionsItemComponentProps) {
169+
return (
170+
<Button
171+
variant="outline"
172+
size="sm"
173+
className={cx(classNames.button)}
174+
onClick={refine}
175+
>
176+
<span className={cx('ais-FilterSuggestions-label', classNames.label)}>
177+
{suggestion.label}: {suggestion.value}
178+
</span>
179+
<span className={cx('ais-FilterSuggestions-count', classNames.count)}>
180+
{suggestion.count}
181+
</span>
182+
</Button>
183+
);
184+
}
185+
186+
return function FilterSuggestions(
187+
userProps: FilterSuggestionsProps
188+
): JSX.Element | null {
189+
const {
190+
classNames = {},
191+
suggestions,
192+
isLoading,
193+
refine,
194+
skeletonCount = 3,
195+
itemComponent: ItemComponent = DefaultItem,
196+
headerComponent,
197+
emptyComponent: EmptyComponent,
198+
...props
199+
} = userProps;
200+
201+
const HeaderComponent =
202+
headerComponent === false ? null : headerComponent ?? DefaultHeader;
203+
204+
const isEmpty = suggestions.length === 0;
205+
206+
if (isEmpty && !isLoading) {
207+
return (
208+
<div
209+
{...props}
210+
className={cx(
211+
'ais-FilterSuggestions',
212+
classNames.root,
213+
'ais-FilterSuggestions--empty',
214+
classNames.emptyRoot,
215+
props.className
216+
)}
217+
>
218+
{EmptyComponent && (
219+
<EmptyComponent classNames={{ emptyRoot: classNames.emptyRoot }} />
220+
)}
221+
</div>
222+
);
223+
}
224+
225+
const headerClassNames: FilterSuggestionsHeaderComponentProps['classNames'] =
226+
{
227+
header: classNames.header,
228+
headerIcon: classNames.headerIcon,
229+
headerTitle: classNames.headerTitle,
230+
};
231+
232+
const itemClassNames: FilterSuggestionsItemComponentProps['classNames'] = {
233+
item: classNames.item,
234+
itemRefined: classNames.itemRefined,
235+
button: classNames.button,
236+
label: classNames.label,
237+
count: classNames.count,
238+
};
239+
240+
return (
241+
<div
242+
{...props}
243+
className={cx(
244+
'ais-FilterSuggestions',
245+
classNames.root,
246+
isLoading &&
247+
cx('ais-FilterSuggestions--loading', classNames.loadingRoot),
248+
props.className
249+
)}
250+
>
251+
{HeaderComponent && <HeaderComponent classNames={headerClassNames} />}
252+
{isLoading ? (
253+
<div
254+
className={cx(
255+
'ais-FilterSuggestions-skeleton',
256+
classNames.skeleton
257+
)}
258+
>
259+
{[...new Array(skeletonCount)].map((_, i) => (
260+
<div
261+
key={i}
262+
className={cx(
263+
'ais-FilterSuggestions-skeletonItem',
264+
classNames.skeletonItem
265+
)}
266+
/>
267+
))}
268+
</div>
269+
) : (
270+
<ul className={cx('ais-FilterSuggestions-list', classNames.list)}>
271+
{suggestions.map((suggestion) => (
272+
<li
273+
key={`${suggestion.attribute}-${suggestion.value}`}
274+
className={cx('ais-FilterSuggestions-item', classNames.item)}
275+
>
276+
<ItemComponent
277+
suggestion={suggestion}
278+
classNames={itemClassNames}
279+
refine={() => refine(suggestion.attribute, suggestion.value)}
280+
/>
281+
</li>
282+
))}
283+
</ul>
284+
)}
285+
</div>
286+
);
287+
};
288+
}

packages/instantsearch-ui-components/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ export * from './Hits';
1717
export * from './LookingSimilar';
1818
export * from './RelatedProducts';
1919
export * from './TrendingItems';
20+
export * from './FilterSuggestions';

packages/instantsearch.css/src/components/button.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
var(--ais-button-text-color-rgb),
6565
var(--ais-button-text-color-alpha)
6666
);
67+
border: 1px solid transparent;
6768

6869
&:disabled {
6970
background-color: rgba(

0 commit comments

Comments
 (0)