@popsure/dirty-swan 0.65.1 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js +361 -209
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/lib/components/searchableDropdown/index.d.ts +22 -0
- package/dist/cjs/lib/components/searchableDropdown/index.stories.d.ts +108 -0
- package/dist/cjs/lib/components/searchableDropdown/index.test.d.ts +1 -0
- package/dist/cjs/lib/hooks/useDropdownAlignment.d.ts +5 -0
- package/dist/cjs/lib/index.d.ts +3 -2
- package/dist/esm/{Calendar-C7I0F5Gv.js → Calendar-8rhyapMz.js} +3 -19
- package/dist/esm/Calendar-8rhyapMz.js.map +1 -0
- package/dist/esm/components/dateSelector/components/Calendar.js +2 -1
- package/dist/esm/components/dateSelector/components/Calendar.js.map +1 -1
- package/dist/esm/components/dateSelector/index.js +2 -1
- package/dist/esm/components/dateSelector/index.js.map +1 -1
- package/dist/esm/components/dateSelector/index.stories.js +2 -1
- package/dist/esm/components/dateSelector/index.stories.js.map +1 -1
- package/dist/esm/components/dateSelector/index.test.js +2 -1
- package/dist/esm/components/dateSelector/index.test.js.map +1 -1
- package/dist/esm/components/searchableDropdown/index.js +13 -0
- package/dist/esm/components/searchableDropdown/index.js.map +1 -0
- package/dist/esm/components/searchableDropdown/index.stories.js +201 -0
- package/dist/esm/components/searchableDropdown/index.stories.js.map +1 -0
- package/dist/esm/components/searchableDropdown/index.test.js +607 -0
- package/dist/esm/components/searchableDropdown/index.test.js.map +1 -0
- package/dist/esm/components/toast/index.js +1 -1
- package/dist/esm/components/toast/index.stories.js +1 -1
- package/dist/esm/components/toast/index.test.js +1 -1
- package/dist/esm/{index-C4IAMlRE.js → index-CT0_LjIR.js} +2 -2
- package/dist/esm/{index-C4IAMlRE.js.map → index-CT0_LjIR.js.map} +1 -1
- package/dist/esm/index-QeP_xz9v.js +175 -0
- package/dist/esm/index-QeP_xz9v.js.map +1 -0
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +7 -17
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/lib/components/searchableDropdown/index.d.ts +22 -0
- package/dist/esm/lib/components/searchableDropdown/index.stories.d.ts +108 -0
- package/dist/esm/lib/components/searchableDropdown/index.test.d.ts +1 -0
- package/dist/esm/lib/hooks/useDropdownAlignment.d.ts +5 -0
- package/dist/esm/lib/index.d.ts +3 -2
- package/dist/esm/useOnClickOutside-B5hujnpp.js +21 -0
- package/dist/esm/useOnClickOutside-B5hujnpp.js.map +1 -0
- package/dist/esm/util/images/index.stories.js +2 -1
- package/dist/esm/util/images/index.stories.js.map +1 -1
- package/package.json +1 -1
- package/src/index.tsx +3 -0
- package/src/lib/components/searchableDropdown/index.stories.tsx +286 -0
- package/src/lib/components/searchableDropdown/index.test.tsx +355 -0
- package/src/lib/components/searchableDropdown/index.tsx +267 -0
- package/src/lib/components/searchableDropdown/style.module.scss +137 -0
- package/src/lib/hooks/useDropdownAlignment.test.ts +210 -0
- package/src/lib/hooks/useDropdownAlignment.ts +34 -0
- package/src/lib/index.tsx +8 -0
- package/dist/esm/Calendar-C7I0F5Gv.js.map +0 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { render, screen } from '../../util/testUtils';
|
|
2
|
+
|
|
3
|
+
import { SearchableDropdown, SearchableDropdownProps } from '.';
|
|
4
|
+
|
|
5
|
+
const options = [
|
|
6
|
+
{ id: 'de', label: 'Germany' },
|
|
7
|
+
{ id: 'fr', label: 'France' },
|
|
8
|
+
{ id: 'es', label: 'Spain' },
|
|
9
|
+
{ id: 'it', label: 'Italy' },
|
|
10
|
+
{ id: 'pt', label: 'Portugal' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const defaultProps: SearchableDropdownProps = {
|
|
14
|
+
options,
|
|
15
|
+
value: 'de',
|
|
16
|
+
onChange: jest.fn(),
|
|
17
|
+
groupName: 'country',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const setup = (props: Partial<SearchableDropdownProps> = {}) => {
|
|
21
|
+
const onChange = jest.fn();
|
|
22
|
+
const utils = render(
|
|
23
|
+
<SearchableDropdown {...defaultProps} onChange={onChange} {...props} />
|
|
24
|
+
);
|
|
25
|
+
return { ...utils, onChange };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('SearchableDropdown', () => {
|
|
29
|
+
describe('rendering', () => {
|
|
30
|
+
it('should render the selected option label in the trigger', () => {
|
|
31
|
+
setup();
|
|
32
|
+
expect(screen.getByRole('button')).toHaveTextContent('Germany');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should render triggerPlaceholder when value is null', () => {
|
|
36
|
+
setup({ value: null, triggerPlaceholder: 'Select a country' });
|
|
37
|
+
expect(screen.getByRole('button')).toHaveTextContent('Select a country');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should not render the dropdown when closed', () => {
|
|
41
|
+
setup();
|
|
42
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should render all options when opened', async () => {
|
|
46
|
+
const { user } = setup();
|
|
47
|
+
await user.click(screen.getByRole('button'));
|
|
48
|
+
|
|
49
|
+
options.forEach((option) => {
|
|
50
|
+
expect(screen.getByLabelText(option.label)).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should render icons in options', async () => {
|
|
55
|
+
const { user } = setup({
|
|
56
|
+
options: [
|
|
57
|
+
{ id: 'de', label: 'Germany', icon: <span data-testid="icon-de">flag</span> },
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
await user.click(screen.getByRole('button'));
|
|
61
|
+
|
|
62
|
+
expect(screen.getAllByTestId('icon-de')).toHaveLength(2); // trigger + option
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should render the selected option icon in the trigger', () => {
|
|
66
|
+
setup({
|
|
67
|
+
options: [
|
|
68
|
+
{ id: 'de', label: 'Germany', icon: <span data-testid="trigger-icon">flag</span> },
|
|
69
|
+
],
|
|
70
|
+
value: 'de',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(screen.getByTestId('trigger-icon')).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should show no results text when search has no matches', async () => {
|
|
77
|
+
const { user } = setup({ searchable: true, noResultsText: 'Nothing here' });
|
|
78
|
+
await user.click(screen.getByRole('button'));
|
|
79
|
+
await user.type(screen.getByPlaceholderText('Search'), 'zzzzz');
|
|
80
|
+
|
|
81
|
+
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('open / close', () => {
|
|
86
|
+
it('should open the dropdown on trigger click', async () => {
|
|
87
|
+
const { user } = setup();
|
|
88
|
+
await user.click(screen.getByRole('button'));
|
|
89
|
+
|
|
90
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should close the dropdown on second trigger click', async () => {
|
|
94
|
+
const { user } = setup();
|
|
95
|
+
const trigger = screen.getByRole('button');
|
|
96
|
+
await user.click(trigger);
|
|
97
|
+
await user.click(trigger);
|
|
98
|
+
|
|
99
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should close the dropdown on Escape', async () => {
|
|
103
|
+
const { user } = setup();
|
|
104
|
+
await user.click(screen.getByRole('button'));
|
|
105
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
106
|
+
|
|
107
|
+
await user.keyboard('{Escape}');
|
|
108
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should close the dropdown when clicking outside', async () => {
|
|
112
|
+
const { user } = setup();
|
|
113
|
+
await user.click(screen.getByRole('button'));
|
|
114
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
115
|
+
|
|
116
|
+
await user.click(document.body);
|
|
117
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('selection', () => {
|
|
122
|
+
it('should call onChange when an option is clicked', async () => {
|
|
123
|
+
const { user, onChange } = setup();
|
|
124
|
+
await user.click(screen.getByRole('button'));
|
|
125
|
+
await user.click(screen.getByLabelText('France'));
|
|
126
|
+
|
|
127
|
+
expect(onChange).toHaveBeenCalledWith('fr');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should close the dropdown after selecting an option', async () => {
|
|
131
|
+
const { user } = setup();
|
|
132
|
+
await user.click(screen.getByRole('button'));
|
|
133
|
+
await user.click(screen.getByLabelText('France'));
|
|
134
|
+
|
|
135
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should mark the current value as checked', async () => {
|
|
139
|
+
const { user } = setup({ value: 'fr' });
|
|
140
|
+
await user.click(screen.getByRole('button'));
|
|
141
|
+
|
|
142
|
+
expect(screen.getByRole('radio', { name: 'France' })).toBeChecked();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('search', () => {
|
|
147
|
+
it('should filter options based on search term', async () => {
|
|
148
|
+
const { user } = setup({ searchable: true });
|
|
149
|
+
await user.click(screen.getByRole('button'));
|
|
150
|
+
await user.type(screen.getByPlaceholderText('Search'), 'ger');
|
|
151
|
+
|
|
152
|
+
expect(screen.getByLabelText('Germany')).toBeInTheDocument();
|
|
153
|
+
expect(screen.queryByLabelText('France')).not.toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should prioritize options that start with the search term', async () => {
|
|
157
|
+
const { user } = setup({
|
|
158
|
+
searchable: true,
|
|
159
|
+
options: [
|
|
160
|
+
{ id: 'al', label: 'Algeria' },
|
|
161
|
+
{ id: 'po', label: 'Portugal' },
|
|
162
|
+
{ id: 'pl', label: 'Poland' },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
await user.click(screen.getByRole('button'));
|
|
166
|
+
await user.type(screen.getByPlaceholderText('Search'), 'po');
|
|
167
|
+
|
|
168
|
+
const radios = screen.getAllByRole('radio');
|
|
169
|
+
expect(radios[0]).toHaveAttribute('value', 'po');
|
|
170
|
+
expect(radios[1]).toHaveAttribute('value', 'pl');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should clear the search term when dropdown closes', async () => {
|
|
174
|
+
const { user } = setup({ searchable: true });
|
|
175
|
+
await user.click(screen.getByRole('button'));
|
|
176
|
+
await user.type(screen.getByPlaceholderText('Search'), 'ger');
|
|
177
|
+
await user.keyboard('{Escape}');
|
|
178
|
+
|
|
179
|
+
await user.click(screen.getByRole('button'));
|
|
180
|
+
expect(screen.getByPlaceholderText('Search')).toHaveValue('');
|
|
181
|
+
expect(screen.getAllByRole('radio')).toHaveLength(options.length);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should select the first filtered option on Enter in search input', async () => {
|
|
185
|
+
const { user, onChange } = setup({ searchable: true });
|
|
186
|
+
await user.click(screen.getByRole('button'));
|
|
187
|
+
await user.type(screen.getByPlaceholderText('Search'), 'fra');
|
|
188
|
+
await user.keyboard('{Enter}');
|
|
189
|
+
|
|
190
|
+
expect(onChange).toHaveBeenCalledWith('fr');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should use custom placeholder text', async () => {
|
|
194
|
+
const { user } = setup({ searchable: true, placeholder: 'Find a country' });
|
|
195
|
+
await user.click(screen.getByRole('button'));
|
|
196
|
+
|
|
197
|
+
expect(screen.getByPlaceholderText('Find a country')).toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('keyboard navigation', () => {
|
|
202
|
+
it('should move focus to the first option on ArrowDown from search input', async () => {
|
|
203
|
+
const { user } = setup({ searchable: true });
|
|
204
|
+
await user.click(screen.getByRole('button'));
|
|
205
|
+
await user.keyboard('{ArrowDown}');
|
|
206
|
+
|
|
207
|
+
expect(screen.getByRole('radio', { name: 'Germany' })).toHaveFocus();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should move focus to the last option on ArrowUp from search input', async () => {
|
|
211
|
+
const { user } = setup({ searchable: true });
|
|
212
|
+
await user.click(screen.getByRole('button'));
|
|
213
|
+
await user.keyboard('{ArrowUp}');
|
|
214
|
+
|
|
215
|
+
expect(screen.getByRole('radio', { name: 'Portugal' })).toHaveFocus();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should navigate between options with ArrowDown and ArrowUp', async () => {
|
|
219
|
+
const { user } = setup({ searchable: true });
|
|
220
|
+
await user.click(screen.getByRole('button'));
|
|
221
|
+
await user.keyboard('{ArrowDown}');
|
|
222
|
+
|
|
223
|
+
expect(screen.getByRole('radio', { name: 'Germany' })).toHaveFocus();
|
|
224
|
+
|
|
225
|
+
await user.keyboard('{ArrowDown}');
|
|
226
|
+
expect(screen.getByRole('radio', { name: 'France' })).toHaveFocus();
|
|
227
|
+
|
|
228
|
+
await user.keyboard('{ArrowUp}');
|
|
229
|
+
expect(screen.getByRole('radio', { name: 'Germany' })).toHaveFocus();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should not go past the last option on ArrowDown', async () => {
|
|
233
|
+
const { user } = setup({
|
|
234
|
+
searchable: true,
|
|
235
|
+
options: [
|
|
236
|
+
{ id: 'a', label: 'Alpha' },
|
|
237
|
+
{ id: 'b', label: 'Beta' },
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
await user.click(screen.getByRole('button'));
|
|
241
|
+
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}');
|
|
242
|
+
|
|
243
|
+
expect(screen.getByRole('radio', { name: 'Beta' })).toHaveFocus();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should not go past the first option on ArrowUp', async () => {
|
|
247
|
+
const { user } = setup({
|
|
248
|
+
searchable: true,
|
|
249
|
+
options: [
|
|
250
|
+
{ id: 'a', label: 'Alpha' },
|
|
251
|
+
{ id: 'b', label: 'Beta' },
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
await user.click(screen.getByRole('button'));
|
|
255
|
+
await user.keyboard('{ArrowDown}{ArrowUp}{ArrowUp}');
|
|
256
|
+
|
|
257
|
+
expect(screen.getByRole('radio', { name: 'Alpha' })).toHaveFocus();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should select option and close on Enter key', async () => {
|
|
261
|
+
const { user, onChange } = setup({ searchable: true });
|
|
262
|
+
await user.click(screen.getByRole('button'));
|
|
263
|
+
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
|
|
264
|
+
|
|
265
|
+
expect(onChange).toHaveBeenCalledWith('fr');
|
|
266
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should select option and close on Space key', async () => {
|
|
270
|
+
const { user, onChange } = setup({ searchable: true });
|
|
271
|
+
await user.click(screen.getByRole('button'));
|
|
272
|
+
await user.keyboard('{ArrowDown}{ArrowDown} ');
|
|
273
|
+
|
|
274
|
+
expect(onChange).toHaveBeenCalledWith('fr');
|
|
275
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('disabled', () => {
|
|
280
|
+
it('should not open the dropdown when disabled', async () => {
|
|
281
|
+
const { user } = setup({ disabled: true });
|
|
282
|
+
await user.click(screen.getByRole('button'));
|
|
283
|
+
|
|
284
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should have the disabled attribute on the trigger', () => {
|
|
288
|
+
setup({ disabled: true });
|
|
289
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('accessibility', () => {
|
|
294
|
+
it('should have aria-expanded false when closed', () => {
|
|
295
|
+
setup();
|
|
296
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should have aria-expanded true when open', async () => {
|
|
300
|
+
const { user } = setup();
|
|
301
|
+
await user.click(screen.getByRole('button'));
|
|
302
|
+
|
|
303
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should have aria-haspopup on the trigger', () => {
|
|
307
|
+
setup();
|
|
308
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'listbox');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should have aria-controls linking to the dropdown when open', async () => {
|
|
312
|
+
const { user } = setup();
|
|
313
|
+
await user.click(screen.getByRole('button'));
|
|
314
|
+
|
|
315
|
+
const trigger = screen.getByRole('button');
|
|
316
|
+
const controlsId = trigger.getAttribute('aria-controls');
|
|
317
|
+
expect(controlsId).toBeTruthy();
|
|
318
|
+
expect(document.getElementById(controlsId!)).toBeInTheDocument();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should have a radiogroup with aria-label', async () => {
|
|
322
|
+
const { user } = setup({ groupName: 'test-group' });
|
|
323
|
+
await user.click(screen.getByRole('button'));
|
|
324
|
+
|
|
325
|
+
expect(screen.getByRole('radiogroup')).toHaveAttribute('aria-label', 'test-group');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should return focus to the trigger after selection', async () => {
|
|
329
|
+
const { user } = setup();
|
|
330
|
+
await user.click(screen.getByRole('button'));
|
|
331
|
+
await user.click(screen.getByLabelText('France'));
|
|
332
|
+
|
|
333
|
+
expect(screen.getByRole('button')).toHaveFocus();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should return focus to the trigger after pressing Escape', async () => {
|
|
337
|
+
const { user } = setup();
|
|
338
|
+
await user.click(screen.getByRole('button'));
|
|
339
|
+
await user.keyboard('{Escape}');
|
|
340
|
+
|
|
341
|
+
expect(screen.getByRole('button')).toHaveFocus();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('groupName', () => {
|
|
346
|
+
it('should auto-generate a groupName when not provided', async () => {
|
|
347
|
+
const { user } = setup({ groupName: undefined });
|
|
348
|
+
await user.click(screen.getByRole('button'));
|
|
349
|
+
|
|
350
|
+
expect(screen.getByRole('radiogroup')).toHaveAttribute('aria-label');
|
|
351
|
+
const radios = screen.getAllByRole('radio');
|
|
352
|
+
expect(radios[0]).toHaveAttribute('name');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import classnames from 'classnames';
|
|
3
|
+
|
|
4
|
+
import { Input } from '../input';
|
|
5
|
+
import { ChevronDownIcon } from '../icon';
|
|
6
|
+
import { useDropdownAlignment } from '../../hooks/useDropdownAlignment';
|
|
7
|
+
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
|
8
|
+
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
|
9
|
+
import generateId from '../../util/generateId';
|
|
10
|
+
|
|
11
|
+
import styles from './style.module.scss';
|
|
12
|
+
|
|
13
|
+
export interface SearchableDropdownOption {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SearchableDropdownProps {
|
|
20
|
+
options: SearchableDropdownOption[];
|
|
21
|
+
value: string | null;
|
|
22
|
+
onChange: (value: string) => void;
|
|
23
|
+
searchable?: boolean;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
triggerPlaceholder?: string;
|
|
26
|
+
noResultsText?: string;
|
|
27
|
+
groupName?: string;
|
|
28
|
+
dropUp?: boolean;
|
|
29
|
+
condensed?: boolean;
|
|
30
|
+
bordered?: boolean;
|
|
31
|
+
showChevron?: boolean;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const SearchableDropdown = ({
|
|
36
|
+
options,
|
|
37
|
+
value,
|
|
38
|
+
onChange,
|
|
39
|
+
searchable = false,
|
|
40
|
+
placeholder = 'Search',
|
|
41
|
+
triggerPlaceholder,
|
|
42
|
+
noResultsText = 'No results found',
|
|
43
|
+
groupName: groupNameProp,
|
|
44
|
+
dropUp = false,
|
|
45
|
+
condensed = false,
|
|
46
|
+
bordered = false,
|
|
47
|
+
showChevron = false,
|
|
48
|
+
disabled = false,
|
|
49
|
+
}: SearchableDropdownProps) => {
|
|
50
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
51
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
52
|
+
const [localValue, setLocalValue] = useState(value);
|
|
53
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
54
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
56
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
const optionRefs = useRef<Map<string, HTMLInputElement>>(new Map());
|
|
58
|
+
const [groupName] = useState(() => groupNameProp ?? `sd-${generateId()}`);
|
|
59
|
+
const dropdownId = `${groupName}-dropdown`;
|
|
60
|
+
|
|
61
|
+
const closeAndRestoreFocus = useCallback(() => {
|
|
62
|
+
setIsOpen(false);
|
|
63
|
+
triggerRef.current?.focus();
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
useOnClickOutside(containerRef, () => isOpen && closeAndRestoreFocus());
|
|
67
|
+
useEscapeKey(
|
|
68
|
+
useCallback(() => {
|
|
69
|
+
if (isOpen) closeAndRestoreFocus();
|
|
70
|
+
}, [isOpen, closeAndRestoreFocus])
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const { alignRight, alignUp } = useDropdownAlignment(containerRef, dropdownRef, isOpen);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (isOpen && searchable && searchInputRef.current) {
|
|
77
|
+
searchInputRef.current.focus();
|
|
78
|
+
}
|
|
79
|
+
}, [isOpen, searchable]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!isOpen) {
|
|
83
|
+
setSearchTerm('');
|
|
84
|
+
setLocalValue(value);
|
|
85
|
+
}
|
|
86
|
+
}, [isOpen, value]);
|
|
87
|
+
|
|
88
|
+
const filteredOptions = useMemo(() => {
|
|
89
|
+
if (!searchTerm) return options;
|
|
90
|
+
return [...options]
|
|
91
|
+
.filter((option) =>
|
|
92
|
+
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
|
93
|
+
)
|
|
94
|
+
.sort((a, b) => {
|
|
95
|
+
const term = searchTerm.toLowerCase();
|
|
96
|
+
const aStartsWith = a.label.toLowerCase().startsWith(term);
|
|
97
|
+
const bStartsWith = b.label.toLowerCase().startsWith(term);
|
|
98
|
+
if (aStartsWith && !bStartsWith) return -1;
|
|
99
|
+
if (!aStartsWith && bStartsWith) return 1;
|
|
100
|
+
return 0;
|
|
101
|
+
});
|
|
102
|
+
}, [options, searchTerm]);
|
|
103
|
+
|
|
104
|
+
const selectedOption = options.find((option) => option.id === value);
|
|
105
|
+
|
|
106
|
+
const handleTriggerClick = () => {
|
|
107
|
+
if (!disabled) setIsOpen(!isOpen);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
111
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
const target =
|
|
114
|
+
e.key === 'ArrowDown'
|
|
115
|
+
? filteredOptions[0]
|
|
116
|
+
: filteredOptions[filteredOptions.length - 1];
|
|
117
|
+
if (target) {
|
|
118
|
+
setLocalValue(target.id);
|
|
119
|
+
optionRefs.current.get(target.id)?.focus();
|
|
120
|
+
}
|
|
121
|
+
} else if (e.key === 'Enter') {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
const target = filteredOptions[0];
|
|
124
|
+
if (target) {
|
|
125
|
+
onChange(target.id);
|
|
126
|
+
closeAndRestoreFocus();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleOptionClick = (optionId: string) => {
|
|
132
|
+
onChange(optionId);
|
|
133
|
+
closeAndRestoreFocus();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleOptionRef = (optionId: string, el: HTMLInputElement | null) => {
|
|
137
|
+
if (el) optionRefs.current.set(optionId, el);
|
|
138
|
+
else optionRefs.current.delete(optionId);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleOptionKeyDown = (e: React.KeyboardEvent, optionId: string) => {
|
|
142
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
const currentIndex = filteredOptions.findIndex(
|
|
145
|
+
(o) => o.id === optionId
|
|
146
|
+
);
|
|
147
|
+
const nextIndex =
|
|
148
|
+
e.key === 'ArrowDown'
|
|
149
|
+
? Math.min(currentIndex + 1, filteredOptions.length - 1)
|
|
150
|
+
: Math.max(currentIndex - 1, 0);
|
|
151
|
+
const next = filteredOptions[nextIndex];
|
|
152
|
+
if (next) {
|
|
153
|
+
setLocalValue(next.id);
|
|
154
|
+
optionRefs.current.get(next.id)?.focus();
|
|
155
|
+
}
|
|
156
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
onChange(optionId);
|
|
159
|
+
closeAndRestoreFocus();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className={classnames(styles.container, { 'd-inline-block': condensed })} ref={containerRef}>
|
|
165
|
+
<button
|
|
166
|
+
ref={triggerRef}
|
|
167
|
+
type="button"
|
|
168
|
+
className={classnames(
|
|
169
|
+
'd-flex ai-center jc-between w100 br8 bg-white c-pointer ta-left tc-neutral-900',
|
|
170
|
+
styles.selectTrigger, {
|
|
171
|
+
[styles.selectTriggerOpen]: isOpen,
|
|
172
|
+
[styles.condensed]: condensed,
|
|
173
|
+
[styles.bordered]: bordered,
|
|
174
|
+
[styles.disabled]: disabled,
|
|
175
|
+
}
|
|
176
|
+
)}
|
|
177
|
+
onClick={handleTriggerClick}
|
|
178
|
+
disabled={disabled}
|
|
179
|
+
aria-expanded={isOpen}
|
|
180
|
+
aria-haspopup="listbox"
|
|
181
|
+
aria-controls={isOpen ? dropdownId : undefined}
|
|
182
|
+
>
|
|
183
|
+
<span className={'d-flex ai-center gap8'}>
|
|
184
|
+
{selectedOption?.icon && (
|
|
185
|
+
<span className={styles.optionIcon}>{selectedOption.icon}</span>
|
|
186
|
+
)}
|
|
187
|
+
{!condensed && (
|
|
188
|
+
<span className="p-p">
|
|
189
|
+
{selectedOption?.label ?? triggerPlaceholder}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
</span>
|
|
193
|
+
{showChevron && (
|
|
194
|
+
<ChevronDownIcon
|
|
195
|
+
className={classnames('ml8', { [styles.chevronOpen]: isOpen })}
|
|
196
|
+
size={20}
|
|
197
|
+
noMargin
|
|
198
|
+
color="neutral-600"
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
</button>
|
|
202
|
+
{isOpen && (
|
|
203
|
+
<div
|
|
204
|
+
id={dropdownId}
|
|
205
|
+
ref={dropdownRef}
|
|
206
|
+
className={classnames(
|
|
207
|
+
styles.dropdown,
|
|
208
|
+
'bg-white br8 p8 d-flex fd-column',
|
|
209
|
+
{ [styles.dropdownUp]: dropUp || alignUp, [styles.dropdownRight]: alignRight }
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
{searchable && (
|
|
213
|
+
<div className={classnames('pb8 bg-white', styles.searchContainer)}>
|
|
214
|
+
<Input
|
|
215
|
+
ref={searchInputRef}
|
|
216
|
+
type="text"
|
|
217
|
+
placeholder={placeholder}
|
|
218
|
+
value={searchTerm}
|
|
219
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
220
|
+
label={placeholder}
|
|
221
|
+
hideLabel
|
|
222
|
+
onKeyDown={handleSearchKeyDown}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
<div className={styles.optionList} role="radiogroup" aria-label={groupName}>
|
|
227
|
+
{filteredOptions.map((option) => (
|
|
228
|
+
<div key={option.id} className={styles.optionWrapper}>
|
|
229
|
+
<input
|
|
230
|
+
type="radio"
|
|
231
|
+
id={`${groupName}-${option.id}`}
|
|
232
|
+
name={groupName}
|
|
233
|
+
value={option.id}
|
|
234
|
+
checked={option.id === localValue}
|
|
235
|
+
onChange={() => setLocalValue(option.id)}
|
|
236
|
+
onClick={() => handleOptionClick(option.id)}
|
|
237
|
+
tabIndex={option.id === localValue ? 0 : -1}
|
|
238
|
+
className={styles.optionRadio}
|
|
239
|
+
ref={(el) => handleOptionRef(option.id, el)}
|
|
240
|
+
onKeyDown={(e) => handleOptionKeyDown(e, option.id)}
|
|
241
|
+
/>
|
|
242
|
+
<label
|
|
243
|
+
htmlFor={`${groupName}-${option.id}`}
|
|
244
|
+
className={classnames(
|
|
245
|
+
'd-flex ai-center gap8 w100 br8 c-pointer ta-left tc-neutral-900',
|
|
246
|
+
styles.option, {
|
|
247
|
+
[styles.optionSelected]: option.id === localValue,
|
|
248
|
+
})}
|
|
249
|
+
>
|
|
250
|
+
{option.icon && (
|
|
251
|
+
<span className={styles.optionIcon}>{option.icon}</span>
|
|
252
|
+
)}
|
|
253
|
+
<span className="p-p">{option.label}</span>
|
|
254
|
+
</label>
|
|
255
|
+
</div>
|
|
256
|
+
))}
|
|
257
|
+
{filteredOptions.length === 0 && (
|
|
258
|
+
<p className={`p-p tc-neutral-500 ${styles.noResults}`}>
|
|
259
|
+
{noResultsText}
|
|
260
|
+
</p>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
};
|