Skip to content

Commit 8f7e4a4

Browse files
feat(autocomplete): implement recent searches
1 parent 510c2bb commit 8f7e4a4

File tree

13 files changed

+707
-33
lines changed

13 files changed

+707
-33
lines changed

bundlesize.config.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
},
1111
{
1212
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
13-
"maxSize": "88.75 kB"
13+
"maxSize": "90.25 kB"
1414
},
1515
{
1616
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
17-
"maxSize": "190.25 kB"
17+
"maxSize": "192.50 kB"
1818
},
1919
{
2020
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
@@ -42,11 +42,11 @@
4242
},
4343
{
4444
"path": "./packages/instantsearch.css/themes/algolia.css",
45-
"maxSize": "8.75 kB"
45+
"maxSize": "9 kB"
4646
},
4747
{
4848
"path": "./packages/instantsearch.css/themes/algolia-min.css",
49-
"maxSize": "8.25 kB"
49+
"maxSize": "8.50 kB"
5050
},
5151
{
5252
"path": "./packages/instantsearch.css/themes/reset.css",
@@ -58,11 +58,11 @@
5858
},
5959
{
6060
"path": "./packages/instantsearch.css/themes/satellite.css",
61-
"maxSize": "9.75 kB"
61+
"maxSize": "10 kB"
6262
},
6363
{
6464
"path": "./packages/instantsearch.css/themes/satellite-min.css",
65-
"maxSize": "9.25 kB"
65+
"maxSize": "9.50 kB"
6666
},
6767
{
6868
"path": "./packages/instantsearch.css/components/chat.css",

examples/react/getting-started/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function App() {
5656

5757
<div className="search-panel__results">
5858
<SearchBox placeholder="" className="searchbox" />
59+
5960
<Hits hitComponent={HitComponent} />
6061

6162
<div className="pagination">
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/** @jsx createElement */
2+
3+
import { cx } from '../../lib';
4+
5+
import { AutocompleteClockIcon, AutocompleteTrashIcon } from './icons';
6+
7+
import type { Renderer } from '../../types';
8+
9+
export type AutocompleteRecentSearchProps<
10+
T = { query: string } & Record<string, unknown>
11+
> = {
12+
item: T;
13+
onSelect: () => void;
14+
onRemoveRecentSearch: () => void;
15+
classNames?: Partial<AutocompleteRecentSearchClassNames>;
16+
};
17+
18+
export type AutocompleteRecentSearchClassNames = {
19+
/**
20+
* Class names to apply to the root element
21+
**/
22+
root: string | string[];
23+
/**
24+
* Class names to apply to the content element
25+
**/
26+
content: string | string[];
27+
/**
28+
* Class names to apply to the actions element
29+
**/
30+
actions: string | string[];
31+
/**
32+
* Class names to apply to the icon element
33+
**/
34+
icon: string | string[];
35+
/**
36+
* Class names to apply to the body element
37+
**/
38+
body: string | string[];
39+
/**
40+
* Class names to apply to the delete button element
41+
**/
42+
deleteButton: string | string[];
43+
};
44+
45+
export function createAutocompleteRecentSearchComponent({
46+
createElement,
47+
}: Renderer) {
48+
return function AutocompleteRecentSearch({
49+
item,
50+
onSelect,
51+
onRemoveRecentSearch,
52+
classNames = {},
53+
}: AutocompleteRecentSearchProps) {
54+
return (
55+
<div
56+
onClick={onSelect}
57+
className={cx(
58+
'ais-AutocompleteItemWrapper ais-AutocompleteRecentSearchWrapper',
59+
classNames.root
60+
)}
61+
>
62+
<div
63+
className={cx(
64+
'ais-AutocompleteItemContent',
65+
'ais-AutocompleteRecentSearchItemContent',
66+
classNames.content
67+
)}
68+
>
69+
<div
70+
className={cx(
71+
'ais-AutocompleteItemIcon',
72+
'ais-AutocompleteRecentSearchItemIcon',
73+
classNames.content
74+
)}
75+
>
76+
<AutocompleteClockIcon createElement={createElement} />
77+
</div>
78+
<div
79+
className={cx(
80+
'ais-AutocompleteItemContentBody',
81+
'ais-AutocompleteRecentSearchItemContentBody',
82+
classNames.content
83+
)}
84+
>
85+
{item.query}
86+
</div>
87+
</div>
88+
<div
89+
className={cx(
90+
'ais-AutocompleteItemActions',
91+
'ais-AutocompleteRecentSearchItemActions',
92+
classNames.actions
93+
)}
94+
>
95+
<button
96+
className={cx(
97+
'ais-AutocompleteItemActionButton',
98+
'ais-AutocompleteRecentSearchItemDeleteButton',
99+
classNames.deleteButton
100+
)}
101+
title={`Remove ${item.query} from recent searches`}
102+
onClick={(evt) => {
103+
evt.stopPropagation();
104+
onRemoveRecentSearch();
105+
}}
106+
>
107+
<AutocompleteTrashIcon createElement={createElement} />
108+
</button>
109+
</div>
110+
</div>
111+
);
112+
};
113+
}

packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteSuggestion.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { cx } from '../../lib';
44

5+
import { AutocompleteSubmitIcon } from './icons';
6+
57
import type { Renderer } from '../../types';
68

79
export type AutocompleteSuggestionProps<
@@ -17,6 +19,14 @@ export type AutocompleteSuggestionClassNames = {
1719
* Class names to apply to the root element
1820
**/
1921
root: string | string[];
22+
/** Class names to apply to the content element **/
23+
content: string | string[];
24+
/** Class names to apply to the actions element **/
25+
actions: string | string[];
26+
/** Class names to apply to the icon element **/
27+
icon: string | string[];
28+
/** Class names to apply to the body element **/
29+
body: string | string[];
2030
};
2131

2232
export function createAutocompleteSuggestionComponent({
@@ -30,9 +40,38 @@ export function createAutocompleteSuggestionComponent({
3040
return (
3141
<div
3242
onClick={onSelect}
33-
className={cx('ais-AutocompleteSuggestion', classNames.root)}
43+
className={cx(
44+
'ais-AutocompleteItemWrapper',
45+
'ais-AutocompleteSuggestionWrapper',
46+
classNames.root
47+
)}
3448
>
35-
{item.query}
49+
<div
50+
className={cx(
51+
'ais-AutocompleteItemContent',
52+
'ais-AutocompleteSuggestionItemContent',
53+
classNames.content
54+
)}
55+
>
56+
<div
57+
className={cx(
58+
'ais-AutocompleteItemIcon',
59+
'ais-AutocompleteSuggestionItemIcon',
60+
classNames.content
61+
)}
62+
>
63+
<AutocompleteSubmitIcon createElement={createElement} />
64+
</div>
65+
<div
66+
className={cx(
67+
'ais-AutocompleteItemContentBody',
68+
'ais-AutocompleteSuggestionItemContentBody',
69+
classNames.content
70+
)}
71+
>
72+
{item.query}
73+
</div>
74+
</div>
3675
</div>
3776
);
3877
};

packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ type CreateAutocompletePropGettersParams = {
4040
) => [TType, (newState: TType) => unknown];
4141
};
4242

43-
type UsePropGetters<TItem extends BaseHit> = (params: {
43+
export type UsePropGetters<TItem extends BaseHit> = (params: {
4444
indices: Array<{
4545
indexName: string;
4646
indexId: string;
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { UsePropGetters } from './createAutocompletePropGetters';
2+
3+
type CreateAutocompleteStorageParams = {
4+
useEffect: (effect: () => void, inputs?: readonly unknown[]) => void;
5+
useMemo: <TType>(factory: () => TType, inputs: readonly unknown[]) => TType;
6+
useState: <TType>(
7+
initialState: TType
8+
) => [TType, (newState: TType) => unknown];
9+
};
10+
11+
type UseStorageParams<TItem extends Record<string, unknown>> = {
12+
showRecent?: boolean | object;
13+
query?: string;
14+
} & Pick<Parameters<UsePropGetters<TItem>>[0], 'indices' | 'indicesConfig'>;
15+
16+
export function createAutocompleteStorage({
17+
useEffect,
18+
useMemo,
19+
useState,
20+
}: CreateAutocompleteStorageParams) {
21+
return function useStorage<TItem extends Record<string, unknown>>({
22+
showRecent,
23+
query,
24+
indices,
25+
indicesConfig,
26+
}: UseStorageParams<TItem>) {
27+
const storage = useMemo(() => createStorage({ limit: 5 }), []);
28+
const [snapshot, setSnapshot] = useState(storage.getSnapshot());
29+
useEffect(() => {
30+
storage.registerUpdateListener(() => {
31+
setSnapshot(storage.getSnapshot());
32+
});
33+
return () => {
34+
storage.unregisterUpdateListener();
35+
};
36+
}, [storage]);
37+
38+
const storageHits = snapshot.getAll(query).map((value) => ({
39+
objectID: value,
40+
query: value,
41+
__indexName: 'recent-searches',
42+
}));
43+
44+
const indicesForPropGetters = [...indices];
45+
const indicesConfigForPropGetters = [...indicesConfig];
46+
if (showRecent) {
47+
indicesForPropGetters.unshift({
48+
indexName: 'recent-searches',
49+
indexId: 'recent-searches',
50+
hits: storageHits,
51+
});
52+
indicesConfigForPropGetters.unshift({
53+
indexName: 'recent-searches',
54+
// @ts-expect-error - we know it has query as it's generated from storageHits
55+
getQuery: (item) => item.query,
56+
});
57+
}
58+
59+
return {
60+
storage,
61+
storageHits,
62+
indicesForPropGetters,
63+
indicesConfigForPropGetters,
64+
};
65+
};
66+
}
67+
68+
const LOCAL_STORAGE_KEY_TEST = 'test-localstorage-support';
69+
const LOCAL_STORAGE_KEY = 'autocomplete-recent-searches';
70+
71+
function isLocalStorageSupported() {
72+
try {
73+
localStorage.setItem(LOCAL_STORAGE_KEY_TEST, '');
74+
localStorage.removeItem(LOCAL_STORAGE_KEY_TEST);
75+
76+
return true;
77+
} catch (error) {
78+
return false;
79+
}
80+
}
81+
82+
function getLocalStorage() {
83+
if (!isLocalStorageSupported()) {
84+
return {
85+
setItems() {},
86+
getItems() {
87+
return [];
88+
},
89+
};
90+
}
91+
92+
return {
93+
setItems(items: string[]) {
94+
try {
95+
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(items));
96+
} catch {
97+
// do nothing, this likely means the storage is full
98+
}
99+
},
100+
getItems(): string[] {
101+
const items = window.localStorage.getItem(LOCAL_STORAGE_KEY);
102+
103+
return items ? (JSON.parse(items) as string[]) : [];
104+
},
105+
};
106+
}
107+
108+
export function createStorage({ limit = 5 }: { limit: number }) {
109+
const storage = getLocalStorage();
110+
let updateListener: (() => void) | null = null;
111+
112+
return {
113+
onAdd(query: string) {
114+
this.onRemove(query);
115+
storage.setItems([query, ...storage.getItems()]);
116+
},
117+
onRemove(query: string) {
118+
storage.setItems(storage.getItems().filter((q) => q !== query));
119+
120+
updateListener?.();
121+
},
122+
registerUpdateListener(callback: () => void) {
123+
updateListener = callback;
124+
},
125+
unregisterUpdateListener() {
126+
updateListener = null;
127+
},
128+
getSnapshot() {
129+
return {
130+
getAll(query = ''): string[] {
131+
return storage
132+
.getItems()
133+
.filter((q) => q.includes(query))
134+
.slice(0, limit);
135+
},
136+
};
137+
},
138+
};
139+
}

packages/instantsearch-ui-components/src/components/autocomplete/icons.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,19 @@ export function AutocompleteClearIcon({ createElement }: IconProps) {
6060
</svg>
6161
);
6262
}
63+
64+
export function AutocompleteClockIcon({ createElement }: IconProps) {
65+
return (
66+
<svg viewBox="0 0 24 24" fill="currentColor">
67+
<path d="M12.516 6.984v5.25l4.5 2.672-0.75 1.266-5.25-3.188v-6h1.5zM12 20.016q3.281 0 5.648-2.367t2.367-5.648-2.367-5.648-5.648-2.367-5.648 2.367-2.367 5.648 2.367 5.648 5.648 2.367zM12 2.016q4.125 0 7.055 2.93t2.93 7.055-2.93 7.055-7.055 2.93-7.055-2.93-2.93-7.055 2.93-7.055 7.055-2.93z"></path>
68+
</svg>
69+
);
70+
}
71+
72+
export function AutocompleteTrashIcon({ createElement }: IconProps) {
73+
return (
74+
<svg viewBox="0 0 24 24" fill="currentColor">
75+
<path d="M18 7v13c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-10c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-13zM17 5v-1c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879h-4c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v1h-4c-0.552 0-1 0.448-1 1s0.448 1 1 1h1v13c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h10c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-13h1c0.552 0 1-0.448 1-1s-0.448-1-1-1zM9 5v-1c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h4c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v1zM9 11v6c0 0.552 0.448 1 1 1s1-0.448 1-1v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1zM13 11v6c0 0.552 0.448 1 1 1s1-0.448 1-1v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1z"></path>
76+
</svg>
77+
);
78+
}

0 commit comments

Comments
 (0)