Skip to content

Commit 39115af

Browse files
committed
Add filtering capabilities to SelectNext
1 parent 8aadd6e commit 39115af

File tree

3 files changed

+168
-51
lines changed

3 files changed

+168
-51
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createContext } from 'preact';
2+
3+
export type SelectFilterableContextType<T = unknown> = {
4+
shouldRender: (value: T) => boolean;
5+
};
6+
7+
const SelectFilterableContext =
8+
createContext<SelectFilterableContextType | null>(null);
9+
10+
export default SelectFilterableContext;

src/components/input/SelectNext.tsx

+54-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import { useSyncedRef } from '../../hooks/use-synced-ref';
1717
import type { PresentationalProps } from '../../types';
1818
import { MenuCollapseIcon, MenuExpandIcon } from '../icons';
1919
import Button from './Button';
20+
import Input from './Input';
2021
import SelectContext from './SelectContext';
22+
import SelectFilterableContext from './SelectFilterableContext';
2123

2224
export type SelectOptionStatus = {
2325
selected: boolean;
@@ -37,11 +39,16 @@ function SelectOption<T>({
3739
disabled = false,
3840
classes,
3941
}: SelectOptionProps<T>) {
42+
const filterContext = useContext(SelectFilterableContext);
4043
const selectContext = useContext(SelectContext);
4144
if (!selectContext) {
4245
throw new Error('Select.Option can only be used as Select child');
4346
}
4447

48+
if (filterContext && !filterContext.shouldRender(value)) {
49+
return null;
50+
}
51+
4552
const { selectValue, value: currentValue } = selectContext;
4653
const selected = !disabled && currentValue === value;
4754

@@ -78,6 +85,48 @@ function SelectOption<T>({
7885
);
7986
}
8087

88+
export type SelectFilterableProps<T> = {
89+
children: ComponentChildren;
90+
91+
/** Placeholder to display in select box. Defaults to 'Search…' */
92+
placeholder?: string;
93+
94+
/**
95+
* Invoked when the filter query changes, for every value of the option this
96+
* Filterable wraps
97+
*/
98+
onFilter: (query: string, value: T) => boolean;
99+
};
100+
101+
function SelectFilterable<T>({
102+
placeholder = 'Search…',
103+
onFilter,
104+
children,
105+
}: SelectFilterableProps<T>) {
106+
const [query, setQuery] = useState('');
107+
const shouldRender = useCallback(
108+
(value: unknown) => onFilter(query, value as T),
109+
[onFilter, query],
110+
);
111+
112+
return (
113+
<SelectFilterableContext.Provider value={{ shouldRender }}>
114+
<div className="p-2 bg-grey-2 sticky top-0">
115+
<Input
116+
type="search"
117+
placeholder={placeholder}
118+
aria-label={placeholder}
119+
value={query}
120+
onInput={e => setQuery((e.target as HTMLInputElement).value)}
121+
/>
122+
</div>
123+
{children}
124+
</SelectFilterableContext.Provider>
125+
);
126+
}
127+
128+
SelectFilterable.displayName = 'SelectNext.Filterable';
129+
81130
SelectOption.displayName = 'SelectNext.Option';
82131

83132
function useShouldDropUp(
@@ -173,7 +222,7 @@ function SelectMain<T>({
173222
loop: false,
174223
autofocus: true,
175224
containerVisible: listboxOpen,
176-
selector: '[role="option"]',
225+
selector: '[role="option"],input[type="search"]',
177226
});
178227

179228
useLayoutEffect(() => {
@@ -245,6 +294,9 @@ function SelectMain<T>({
245294

246295
SelectMain.displayName = 'SelectNext';
247296

248-
const SelectNext = Object.assign(SelectMain, { Option: SelectOption });
297+
const SelectNext = Object.assign(SelectMain, {
298+
Option: SelectOption,
299+
Filterable: SelectFilterable,
300+
});
249301

250302
export default SelectNext;

src/pattern-library/components/patterns/prototype/SelectNextPage.tsx

+104-49
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,74 @@ const defaultItems = [
1313
{ id: '4', name: 'Cecelia Davenport' },
1414
{ id: '5', name: 'Doris Evanescence' },
1515
];
16+
const longListOfItems = [
17+
...defaultItems.map(({ id, name }) => ({
18+
id: `1${id}`,
19+
name: `1 ${name}`,
20+
})),
21+
...defaultItems.map(({ id, name }) => ({
22+
id: `2${id}`,
23+
name: `2 ${name}`,
24+
})),
25+
...defaultItems.map(({ id, name }) => ({
26+
id: `3${id}`,
27+
name: `3 ${name}`,
28+
})),
29+
...defaultItems.map(({ id, name }) => ({
30+
id: `4${id}`,
31+
name: `4 ${name}`,
32+
})),
33+
...defaultItems.map(({ id, name }) => ({
34+
id: `5${id}`,
35+
name: `5 ${name}`,
36+
})),
37+
...defaultItems.map(({ id, name }) => ({
38+
id: `6${id}`,
39+
name: `6 ${name}`,
40+
})),
41+
];
42+
43+
function SelectOptionExample({
44+
item,
45+
textOnly,
46+
}: {
47+
item: (typeof defaultItems)[number];
48+
textOnly: boolean;
49+
}) {
50+
return (
51+
<SelectNext.Option value={item}>
52+
{() =>
53+
textOnly ? (
54+
<>{item.name}</>
55+
) : (
56+
<>
57+
{item.name}
58+
<div className="grow" />
59+
<div className="rounded px-2 bg-grey-7 text-white">{item.id}</div>
60+
</>
61+
)
62+
}
63+
</SelectNext.Option>
64+
);
65+
}
1666

1767
function SelectExample({
1868
disabled,
19-
textOnly,
69+
textOnly = false,
70+
filterable,
2071
items = defaultItems,
2172
}: {
2273
disabled?: boolean;
2374
textOnly?: boolean;
75+
filterable?: boolean;
2476
items?: typeof defaultItems;
2577
}) {
2678
const [value, setValue] = useState<(typeof items)[number]>();
79+
const onFilter = useCallback(
80+
(query: string, value: (typeof items)[number]) =>
81+
value.name.toLowerCase().includes(query.toLowerCase()),
82+
[],
83+
);
2784

2885
return (
2986
<SelectNext
@@ -47,23 +104,43 @@ function SelectExample({
47104
}
48105
disabled={disabled}
49106
>
50-
{items.map(item => (
51-
<SelectNext.Option value={item} key={item.id}>
52-
{() =>
53-
textOnly ? (
54-
<>{item.name}</>
55-
) : (
56-
<>
57-
{item.name}
58-
<div className="grow" />
59-
<div className="rounded px-2 bg-grey-7 text-white">
60-
{item.id}
61-
</div>
62-
</>
63-
)
64-
}
65-
</SelectNext.Option>
66-
))}
107+
{!filterable &&
108+
items.map(item => (
109+
<SelectOptionExample item={item} textOnly={textOnly} key={item.id} />
110+
))}
111+
{filterable && (
112+
<>
113+
{/* Render a couple options before filterable to demonstrate stickiness */}
114+
<SelectOptionExample item={items[0]} textOnly={textOnly} />
115+
<SelectOptionExample item={items[1]} textOnly={textOnly} />
116+
117+
{/* Render two filterable blocks to demonstrate sections filtering */}
118+
<SelectNext.Filterable onFilter={onFilter}>
119+
{items
120+
.slice(2)
121+
.splice(0, items.length / 2 - 2)
122+
.map(item => (
123+
<SelectOptionExample
124+
item={item}
125+
textOnly={textOnly}
126+
key={item.id}
127+
/>
128+
))}
129+
</SelectNext.Filterable>
130+
<SelectNext.Filterable onFilter={onFilter}>
131+
{items
132+
.slice(2)
133+
.splice(items.length / 2 - 2 + 1)
134+
.map(item => (
135+
<SelectOptionExample
136+
item={item}
137+
textOnly={textOnly}
138+
key={item.id}
139+
/>
140+
))}
141+
</SelectNext.Filterable>
142+
</>
143+
)}
67144
</SelectNext>
68145
);
69146
}
@@ -154,7 +231,7 @@ export default function SelectNextPage() {
154231

155232
<Library.Example>
156233
<Library.Demo title="Basic Select">
157-
<div className="w-[350px] mx-auto">
234+
<div className="w-96 mx-auto">
158235
<SelectExample textOnly />
159236
</div>
160237
</Library.Demo>
@@ -183,42 +260,20 @@ export default function SelectNextPage() {
183260

184261
<Library.Example title="Select with many options">
185262
<Library.Demo title="Select with many options">
186-
<div className="w-[350px] mx-auto">
187-
<SelectExample
188-
items={[
189-
...defaultItems.map(({ id, name }) => ({
190-
id: `1${id}`,
191-
name: `1 ${name}`,
192-
})),
193-
...defaultItems.map(({ id, name }) => ({
194-
id: `2${id}`,
195-
name: `2 ${name}`,
196-
})),
197-
...defaultItems.map(({ id, name }) => ({
198-
id: `3${id}`,
199-
name: `3 ${name}`,
200-
})),
201-
...defaultItems.map(({ id, name }) => ({
202-
id: `4${id}`,
203-
name: `4 ${name}`,
204-
})),
205-
...defaultItems.map(({ id, name }) => ({
206-
id: `5${id}`,
207-
name: `5 ${name}`,
208-
})),
209-
...defaultItems.map(({ id, name }) => ({
210-
id: `6${id}`,
211-
name: `6 ${name}`,
212-
})),
213-
]}
214-
/>
263+
<div className="w-96 mx-auto">
264+
<SelectExample items={longListOfItems} />
265+
</div>
266+
</Library.Demo>
267+
<Library.Demo title="Select with filtering">
268+
<div className="w-96 mx-auto">
269+
<SelectExample items={longListOfItems} filterable />
215270
</div>
216271
</Library.Demo>
217272
</Library.Example>
218273

219274
<Library.Example title="Disabled Select">
220275
<Library.Demo title="Disabled Select">
221-
<div className="w-[350px] mx-auto">
276+
<div className="w-96 mx-auto">
222277
<SelectExample disabled />
223278
</div>
224279
</Library.Demo>

0 commit comments

Comments
 (0)