Skip to content

Commit 630a9e6

Browse files
feat(autocomplete): implement recent searches (#6747)
* feat(autocomplete): implement recent searches * do not save if showRecent is not true * remove line return * allow users to set a custom key for local storage
1 parent a99b6bd commit 630a9e6

13 files changed

Lines changed: 740 additions & 34 deletions

File tree

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",

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,5 +147,6 @@
147147
"webpack": "4.47.0",
148148
"babel-loader": "8.2.2",
149149
"zod": "3.25.76"
150-
}
150+
},
151+
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
151152
}
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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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 | { storageKey?: string };
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 storageKey =
28+
showRecent && typeof showRecent === 'object'
29+
? showRecent.storageKey
30+
: undefined;
31+
const storage = useMemo(
32+
() => createStorage({ limit: 5, storageKey }),
33+
[storageKey]
34+
);
35+
const [snapshot, setSnapshot] = useState(storage.getSnapshot());
36+
useEffect(() => {
37+
storage.registerUpdateListener(() => {
38+
setSnapshot(storage.getSnapshot());
39+
});
40+
return () => {
41+
storage.unregisterUpdateListener();
42+
};
43+
}, [storage]);
44+
45+
if (!showRecent) {
46+
return {
47+
storage: { onAdd: () => {}, onRemove: () => {} },
48+
storageHits: [],
49+
indicesForPropGetters: indices,
50+
indicesConfigForPropGetters: indicesConfig,
51+
};
52+
}
53+
54+
const storageHits = snapshot.getAll(query).map((value) => ({
55+
objectID: value,
56+
query: value,
57+
__indexName: 'recent-searches',
58+
}));
59+
60+
const indicesForPropGetters = [...indices];
61+
const indicesConfigForPropGetters = [...indicesConfig];
62+
indicesForPropGetters.unshift({
63+
indexName: 'recent-searches',
64+
indexId: 'recent-searches',
65+
hits: storageHits,
66+
});
67+
indicesConfigForPropGetters.unshift({
68+
indexName: 'recent-searches',
69+
// @ts-expect-error - we know it has query as it's generated from storageHits
70+
getQuery: (item) => item.query,
71+
});
72+
73+
return {
74+
storage,
75+
storageHits,
76+
indicesForPropGetters,
77+
indicesConfigForPropGetters,
78+
};
79+
};
80+
}
81+
82+
const LOCAL_STORAGE_KEY_TEST = 'test-localstorage-support';
83+
const LOCAL_STORAGE_KEY = 'autocomplete-recent-searches';
84+
85+
function isLocalStorageSupported() {
86+
try {
87+
localStorage.setItem(LOCAL_STORAGE_KEY_TEST, '');
88+
localStorage.removeItem(LOCAL_STORAGE_KEY_TEST);
89+
90+
return true;
91+
} catch (error) {
92+
return false;
93+
}
94+
}
95+
96+
function getLocalStorage(key: string = LOCAL_STORAGE_KEY) {
97+
if (!isLocalStorageSupported()) {
98+
return {
99+
setItems() {},
100+
getItems() {
101+
return [];
102+
},
103+
};
104+
}
105+
106+
return {
107+
setItems(items: string[]) {
108+
try {
109+
window.localStorage.setItem(key, JSON.stringify(items));
110+
} catch {
111+
// do nothing, this likely means the storage is full
112+
}
113+
},
114+
getItems(): string[] {
115+
const items = window.localStorage.getItem(key);
116+
117+
return items ? (JSON.parse(items) as string[]) : [];
118+
},
119+
};
120+
}
121+
122+
export function createStorage({
123+
limit = 5,
124+
storageKey,
125+
}: {
126+
limit: number;
127+
storageKey?: string;
128+
}) {
129+
const storage = getLocalStorage(storageKey);
130+
let updateListener: (() => void) | null = null;
131+
132+
return {
133+
onAdd(query: string) {
134+
this.onRemove(query);
135+
storage.setItems([query, ...storage.getItems()]);
136+
},
137+
onRemove(query: string) {
138+
storage.setItems(storage.getItems().filter((q) => q !== query));
139+
140+
updateListener?.();
141+
},
142+
registerUpdateListener(callback: () => void) {
143+
updateListener = callback;
144+
},
145+
unregisterUpdateListener() {
146+
updateListener = null;
147+
},
148+
getSnapshot() {
149+
return {
150+
getAll(query = ''): string[] {
151+
return storage
152+
.getItems()
153+
.filter((q) => q.includes(query))
154+
.slice(0, limit);
155+
},
156+
};
157+
},
158+
};
159+
}

0 commit comments

Comments
 (0)