@adobe-commerce/elsie 1.6.0-alpha999 → 1.6.0-beta2
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/config/jest.js +3 -3
- package/package.json +3 -3
- package/src/components/Button/Button.tsx +2 -0
- package/src/components/Field/Field.tsx +19 -14
- package/src/components/Icon/Icon.tsx +29 -24
- package/src/components/Incrementer/Incrementer.css +6 -0
- package/src/components/Incrementer/Incrementer.stories.tsx +18 -0
- package/src/components/Incrementer/Incrementer.tsx +66 -59
- package/src/components/MultiSelect/MultiSelect.css +273 -0
- package/src/components/MultiSelect/MultiSelect.stories.tsx +459 -0
- package/src/components/MultiSelect/MultiSelect.tsx +763 -0
- package/src/components/MultiSelect/index.ts +11 -0
- package/src/components/Pagination/Pagination.css +14 -5
- package/src/components/Pagination/Pagination.stories.tsx +32 -3
- package/src/components/Pagination/Pagination.tsx +28 -22
- package/src/components/Pagination/PaginationButton.tsx +46 -0
- package/src/components/Price/Price.tsx +8 -41
- package/src/components/Table/Table.css +183 -0
- package/src/components/Table/Table.stories.tsx +1024 -0
- package/src/components/Table/Table.tsx +253 -0
- package/src/components/Table/index.ts +11 -0
- package/src/components/ToggleButton/ToggleButton.css +13 -1
- package/src/components/ToggleButton/ToggleButton.stories.tsx +13 -6
- package/src/components/ToggleButton/ToggleButton.tsx +4 -0
- package/src/components/index.ts +5 -3
- package/src/docs/slots.mdx +2 -0
- package/src/i18n/en_US.json +38 -0
- package/src/icons/Business.svg +3 -0
- package/src/icons/List.svg +3 -0
- package/src/icons/Quote.svg +3 -0
- package/src/icons/Structure.svg +8 -0
- package/src/icons/Team.svg +5 -0
- package/src/icons/index.ts +29 -24
- package/src/lib/aem/configs.ts +10 -4
- package/src/lib/get-price-formatter.ts +69 -0
- package/src/lib/index.ts +4 -3
- package/src/lib/slot.tsx +61 -5
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/********************************************************************
|
|
2
|
+
* Copyright 2025 Adobe
|
|
3
|
+
* All Rights Reserved.
|
|
4
|
+
*
|
|
5
|
+
* NOTICE: Adobe permits you to use, modify, and distribute this
|
|
6
|
+
* file in accordance with the terms of the Adobe license agreement
|
|
7
|
+
* accompanying it.
|
|
8
|
+
*******************************************************************/
|
|
9
|
+
|
|
10
|
+
import { FunctionComponent } from 'preact';
|
|
11
|
+
import { Button, Tag } from '@adobe-commerce/elsie/components';
|
|
12
|
+
import { classes } from '@adobe-commerce/elsie/lib';
|
|
13
|
+
import { ChevronDown, Close, Check } from '@adobe-commerce/elsie/icons';
|
|
14
|
+
import { useText } from 'preact-i18n';
|
|
15
|
+
import {
|
|
16
|
+
useState,
|
|
17
|
+
useCallback,
|
|
18
|
+
useEffect,
|
|
19
|
+
useRef,
|
|
20
|
+
useMemo,
|
|
21
|
+
} from 'preact/hooks';
|
|
22
|
+
import { JSX } from 'preact/compat';
|
|
23
|
+
import '@adobe-commerce/elsie/components/MultiSelect/MultiSelect.css';
|
|
24
|
+
|
|
25
|
+
type OptionValue = string | number;
|
|
26
|
+
|
|
27
|
+
interface Option {
|
|
28
|
+
label: string;
|
|
29
|
+
value: OptionValue;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SelectedValues = OptionValue[];
|
|
34
|
+
|
|
35
|
+
// Utility functions
|
|
36
|
+
const filterOptions = (options: Option[], searchTerm: string): Option[] => {
|
|
37
|
+
return options.filter((option) =>
|
|
38
|
+
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const getSelectedLabels = (
|
|
43
|
+
value: SelectedValues,
|
|
44
|
+
options: Option[]
|
|
45
|
+
): (string | OptionValue)[] => {
|
|
46
|
+
return value.map((v) => {
|
|
47
|
+
const option = options.find((opt) => opt.value === v);
|
|
48
|
+
return option ? option.label : v;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const generateIds = (id: string, name: string, floatingLabel?: string) => {
|
|
53
|
+
const baseId = id || name;
|
|
54
|
+
return {
|
|
55
|
+
listboxId: `${baseId}-listbox`,
|
|
56
|
+
searchInputId: `${baseId}-search`,
|
|
57
|
+
labelId: floatingLabel ? `${baseId}-label` : undefined,
|
|
58
|
+
selectedDescriptionId: `${name}-selected-description`,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const useAccessibilityAnnouncements = () => {
|
|
63
|
+
const [announcement, setAnnouncement] = useState('');
|
|
64
|
+
|
|
65
|
+
const announce = useCallback((message: string) => {
|
|
66
|
+
setAnnouncement(message);
|
|
67
|
+
setTimeout(() => setAnnouncement(''), 1000);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
return { announcement, announce };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const useKeyboardNavigation = (
|
|
74
|
+
filteredOptions: Option[],
|
|
75
|
+
focusedIndex: number,
|
|
76
|
+
setFocusedIndex: (value: number | ((prev: number) => number)) => void,
|
|
77
|
+
dropdownRef: { current: HTMLDivElement | null }
|
|
78
|
+
) => {
|
|
79
|
+
// Scroll focused item into view
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (focusedIndex >= 0 && dropdownRef.current) {
|
|
82
|
+
const items = dropdownRef.current.querySelectorAll('[data-option-index]');
|
|
83
|
+
if (items[focusedIndex]) {
|
|
84
|
+
items[focusedIndex].scrollIntoView({
|
|
85
|
+
block: 'nearest',
|
|
86
|
+
behavior: 'smooth',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}, [focusedIndex, dropdownRef]);
|
|
91
|
+
|
|
92
|
+
const navigate = useCallback(
|
|
93
|
+
(direction: 'up' | 'down') => {
|
|
94
|
+
setFocusedIndex((prev) => {
|
|
95
|
+
const step = direction === 'up' ? -1 : 1;
|
|
96
|
+
let nextIndex = prev + step;
|
|
97
|
+
|
|
98
|
+
// Skip disabled options in the current direction
|
|
99
|
+
while (
|
|
100
|
+
nextIndex >= 0 &&
|
|
101
|
+
nextIndex < filteredOptions.length &&
|
|
102
|
+
filteredOptions[nextIndex]?.disabled
|
|
103
|
+
) {
|
|
104
|
+
nextIndex += step;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// If we found a valid option, return it
|
|
108
|
+
if (nextIndex >= 0 && nextIndex < filteredOptions.length) {
|
|
109
|
+
return nextIndex;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Wrap around: find the first enabled option from the opposite end
|
|
113
|
+
if (direction === 'up') {
|
|
114
|
+
for (let i = filteredOptions.length - 1; i >= 0; i--) {
|
|
115
|
+
if (!filteredOptions[i]?.disabled) return i;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
for (let i = 0; i < filteredOptions.length; i++) {
|
|
119
|
+
if (!filteredOptions[i]?.disabled) return i;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return -1;
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
[filteredOptions, setFocusedIndex]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return { navigate };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export interface MultiSelectProps {
|
|
133
|
+
options: Option[];
|
|
134
|
+
value: SelectedValues;
|
|
135
|
+
onChange: (value: SelectedValues) => void;
|
|
136
|
+
placeholder?: string;
|
|
137
|
+
selectAllText?: string;
|
|
138
|
+
deselectAllText?: string;
|
|
139
|
+
noResultsText?: string;
|
|
140
|
+
name?: string;
|
|
141
|
+
disabled?: boolean;
|
|
142
|
+
className?: string;
|
|
143
|
+
maxHeight?: number;
|
|
144
|
+
floatingLabel?: string;
|
|
145
|
+
error?: boolean;
|
|
146
|
+
success?: boolean;
|
|
147
|
+
id?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const MultiSelect: FunctionComponent<MultiSelectProps> = ({
|
|
151
|
+
options = [],
|
|
152
|
+
value = [],
|
|
153
|
+
onChange = () => {},
|
|
154
|
+
id = '',
|
|
155
|
+
className = '',
|
|
156
|
+
selectAllText = '',
|
|
157
|
+
deselectAllText = '',
|
|
158
|
+
placeholder = '',
|
|
159
|
+
noResultsText = '',
|
|
160
|
+
floatingLabel = '',
|
|
161
|
+
name = 'multi-select-sdk',
|
|
162
|
+
error = false,
|
|
163
|
+
success = false,
|
|
164
|
+
disabled = false,
|
|
165
|
+
maxHeight = 300,
|
|
166
|
+
}) => {
|
|
167
|
+
const translations = useText({
|
|
168
|
+
selectAll: 'Dropin.MultiSelect.selectAll',
|
|
169
|
+
deselectAll: 'Dropin.MultiSelect.deselectAll',
|
|
170
|
+
placeholder: 'Dropin.MultiSelect.placeholder',
|
|
171
|
+
noResultsText: 'Dropin.MultiSelect.noResultsText',
|
|
172
|
+
removed: 'Dropin.MultiSelect.ariaLabel.removed',
|
|
173
|
+
added: 'Dropin.MultiSelect.ariaLabel.added',
|
|
174
|
+
itemsSelected: 'Dropin.MultiSelect.ariaLabel.itemsSelected',
|
|
175
|
+
itemsAdded: 'Dropin.MultiSelect.ariaLabel.itemsAdded',
|
|
176
|
+
itemsRemoved: 'Dropin.MultiSelect.ariaLabel.itemsRemoved',
|
|
177
|
+
selectedTotal: 'Dropin.MultiSelect.ariaLabel.selectedTotal',
|
|
178
|
+
noResultsFor: 'Dropin.MultiSelect.ariaLabel.noResultsFor',
|
|
179
|
+
optionsAvailable: 'Dropin.MultiSelect.ariaLabel.optionsAvailable',
|
|
180
|
+
dropdownExpanded: 'Dropin.MultiSelect.ariaLabel.dropdownExpanded',
|
|
181
|
+
useArrowKeys: 'Dropin.MultiSelect.ariaLabel.useArrowKeys',
|
|
182
|
+
removeFromSelection: 'Dropin.MultiSelect.ariaLabel.removeFromSelection',
|
|
183
|
+
fromSelection: 'Dropin.MultiSelect.ariaLabel.fromSelection',
|
|
184
|
+
selectedItem: 'Dropin.MultiSelect.ariaLabel.selectedItem',
|
|
185
|
+
inField: 'Dropin.MultiSelect.ariaLabel.inField',
|
|
186
|
+
selectedItems: 'Dropin.MultiSelect.ariaLabel.selectedItems',
|
|
187
|
+
scrollableOptionsList: 'Dropin.MultiSelect.ariaLabel.scrollableOptionsList',
|
|
188
|
+
selectOptions: 'Dropin.MultiSelect.ariaLabel.selectOptions',
|
|
189
|
+
itemAction: 'Dropin.MultiSelect.ariaLabel.itemAction',
|
|
190
|
+
bulkAdded: 'Dropin.MultiSelect.ariaLabel.bulkAdded',
|
|
191
|
+
bulkRemoved: 'Dropin.MultiSelect.ariaLabel.bulkRemoved',
|
|
192
|
+
dropdownExpandedWithOptions:
|
|
193
|
+
'Dropin.MultiSelect.ariaLabel.dropdownExpandedWithOptions',
|
|
194
|
+
selectedItemInField: 'Dropin.MultiSelect.ariaLabel.selectedItemInField',
|
|
195
|
+
removeFromSelectionWithText:
|
|
196
|
+
'Dropin.MultiSelect.ariaLabel.removeFromSelectionWithText',
|
|
197
|
+
itemsSelectedDescription:
|
|
198
|
+
'Dropin.MultiSelect.ariaLabel.itemsSelectedDescription',
|
|
199
|
+
noItemsSelected: 'Dropin.MultiSelect.ariaLabel.noItemsSelected',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
203
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
204
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
205
|
+
|
|
206
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
207
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
208
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
209
|
+
|
|
210
|
+
const { announcement, announce } = useAccessibilityAnnouncements();
|
|
211
|
+
|
|
212
|
+
// Helper function to close dropdown and optionally reset search state
|
|
213
|
+
const closeDropdown = useCallback((resetSearch = false) => {
|
|
214
|
+
setIsOpen(false);
|
|
215
|
+
if (resetSearch) {
|
|
216
|
+
setSearchTerm('');
|
|
217
|
+
}
|
|
218
|
+
setFocusedIndex(-1);
|
|
219
|
+
}, []);
|
|
220
|
+
|
|
221
|
+
// Filter options based on the search term
|
|
222
|
+
const filteredOptions = useMemo(
|
|
223
|
+
() => filterOptions(options, searchTerm),
|
|
224
|
+
[options, searchTerm]
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Keyboard navigation
|
|
228
|
+
const { navigate } = useKeyboardNavigation(
|
|
229
|
+
filteredOptions,
|
|
230
|
+
focusedIndex,
|
|
231
|
+
setFocusedIndex,
|
|
232
|
+
dropdownRef
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Close dropdown on the outside click
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
238
|
+
if (
|
|
239
|
+
containerRef.current &&
|
|
240
|
+
event.target &&
|
|
241
|
+
!containerRef.current.contains(event.target as Node)
|
|
242
|
+
) {
|
|
243
|
+
closeDropdown(true); // Reset search when clicking outside
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
248
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
249
|
+
}, [closeDropdown]);
|
|
250
|
+
|
|
251
|
+
// Toggle dropdown on container click (excluding tags)
|
|
252
|
+
const handleContainerClick = (event: MouseEvent) => {
|
|
253
|
+
if (
|
|
254
|
+
!disabled &&
|
|
255
|
+
searchInputRef.current &&
|
|
256
|
+
event.target &&
|
|
257
|
+
!(event.target as Element).closest('[data-tag]')
|
|
258
|
+
) {
|
|
259
|
+
if (isOpen) {
|
|
260
|
+
closeDropdown(false); // Don't reset search when clicking inside
|
|
261
|
+
} else {
|
|
262
|
+
searchInputRef.current.focus();
|
|
263
|
+
setIsOpen(true);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Handle option selection/deselection
|
|
269
|
+
const handleSelect = useCallback(
|
|
270
|
+
(optionValue: OptionValue) => {
|
|
271
|
+
const option = options.find((opt) => opt.value === optionValue);
|
|
272
|
+
|
|
273
|
+
const isCurrentlySelected = value.includes(optionValue);
|
|
274
|
+
const newValue = isCurrentlySelected
|
|
275
|
+
? value.filter((v) => v !== optionValue)
|
|
276
|
+
: [...value, optionValue];
|
|
277
|
+
|
|
278
|
+
onChange(newValue);
|
|
279
|
+
searchInputRef.current?.focus();
|
|
280
|
+
|
|
281
|
+
// A11y - Announce the selection change
|
|
282
|
+
if (option) {
|
|
283
|
+
const action = isCurrentlySelected
|
|
284
|
+
? translations.removed
|
|
285
|
+
: translations.added;
|
|
286
|
+
announce(
|
|
287
|
+
translations.itemAction
|
|
288
|
+
.replace('{label}', option.label)
|
|
289
|
+
.replace('{action}', action)
|
|
290
|
+
.replace('{count}', newValue.length.toString())
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
[value, onChange, options, announce, translations]
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Handle option deselection via the Tag button
|
|
298
|
+
const handleRemoveTag = (event: MouseEvent, optionValue: OptionValue) => {
|
|
299
|
+
event.stopPropagation();
|
|
300
|
+
const option = options.find((opt) => opt.value === optionValue);
|
|
301
|
+
const newValue = value.filter((v) => v !== optionValue);
|
|
302
|
+
onChange(newValue);
|
|
303
|
+
searchInputRef.current?.focus();
|
|
304
|
+
|
|
305
|
+
// A11y - Announce the selection change
|
|
306
|
+
if (option) {
|
|
307
|
+
announce(
|
|
308
|
+
translations.itemAction
|
|
309
|
+
.replace('{label}', option.label)
|
|
310
|
+
.replace('{action}', translations.removed)
|
|
311
|
+
.replace('{count}', newValue.length.toString())
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const handleSelectAll = (event: MouseEvent) => {
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
const allFilteredValues = filteredOptions.map((opt) => opt.value);
|
|
319
|
+
const currentValueSet = new Set(value);
|
|
320
|
+
const newSelections = allFilteredValues.filter(
|
|
321
|
+
(val) => !currentValueSet.has(val)
|
|
322
|
+
);
|
|
323
|
+
const newValue = [...value, ...newSelections];
|
|
324
|
+
onChange(newValue);
|
|
325
|
+
|
|
326
|
+
// Announce the bulk selection
|
|
327
|
+
if (newSelections.length > 0) {
|
|
328
|
+
announce(
|
|
329
|
+
translations.bulkAdded
|
|
330
|
+
.replace('{count}', newSelections.length.toString())
|
|
331
|
+
.replace('{total}', newValue.length.toString())
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const handleDeselectAll = (event: MouseEvent) => {
|
|
337
|
+
event.preventDefault();
|
|
338
|
+
const filteredValues = new Set(filteredOptions.map((opt) => opt.value));
|
|
339
|
+
const removedCount = value.filter((v) => filteredValues.has(v)).length;
|
|
340
|
+
const newValue = value.filter((v) => !filteredValues.has(v));
|
|
341
|
+
onChange(newValue);
|
|
342
|
+
|
|
343
|
+
// A11y - Announce the bulk deselection
|
|
344
|
+
if (removedCount > 0) {
|
|
345
|
+
announce(
|
|
346
|
+
translations.bulkRemoved
|
|
347
|
+
.replace('{count}', removedCount.toString())
|
|
348
|
+
.replace('{total}', newValue.length.toString())
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// A11y - Keyboard navigation
|
|
354
|
+
const handleInputKeyDown = (
|
|
355
|
+
event: JSX.TargetedKeyboardEvent<HTMLInputElement>
|
|
356
|
+
) => {
|
|
357
|
+
// Handle backspace to remove the last tag
|
|
358
|
+
if (event.key === 'Backspace' && searchTerm === '' && value.length > 0) {
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
const newValue = value.slice(0, -1);
|
|
361
|
+
onChange(newValue);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!isOpen && (event.key === 'ArrowDown' || event.key === 'Enter')) {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
setIsOpen(true);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (isOpen) {
|
|
372
|
+
switch (event.key) {
|
|
373
|
+
case 'ArrowDown':
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
navigate('down');
|
|
376
|
+
break;
|
|
377
|
+
case 'ArrowUp':
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
navigate('up');
|
|
380
|
+
break;
|
|
381
|
+
case 'Enter':
|
|
382
|
+
event.preventDefault();
|
|
383
|
+
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
|
384
|
+
const focusedOption = filteredOptions[focusedIndex];
|
|
385
|
+
if (!focusedOption?.disabled) {
|
|
386
|
+
handleSelect(focusedOption.value);
|
|
387
|
+
}
|
|
388
|
+
} else if (
|
|
389
|
+
filteredOptions.length === 1 &&
|
|
390
|
+
!filteredOptions[0]?.disabled
|
|
391
|
+
) {
|
|
392
|
+
handleSelect(filteredOptions[0].value);
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
case 'Escape':
|
|
396
|
+
event.preventDefault();
|
|
397
|
+
closeDropdown(true); // Reset search on Escape
|
|
398
|
+
break;
|
|
399
|
+
case 'Tab':
|
|
400
|
+
closeDropdown(true); // Reset search on Tab (leaving component)
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Array with labels of all selected options
|
|
407
|
+
const selectedLabels = useMemo(
|
|
408
|
+
() => getSelectedLabels(value, options),
|
|
409
|
+
[value, options]
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// A11y - Generate unique IDs
|
|
413
|
+
const { listboxId, searchInputId, labelId, selectedDescriptionId } =
|
|
414
|
+
generateIds(id, name, floatingLabel);
|
|
415
|
+
const currentFocusedId =
|
|
416
|
+
focusedIndex >= 0 ? `${listboxId}-option-${focusedIndex}` : '';
|
|
417
|
+
|
|
418
|
+
// Check if any items are selected (regardless of filtering)
|
|
419
|
+
const hasSelection = useMemo(() => value.length > 0, [value]);
|
|
420
|
+
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
if (searchTerm && !isOpen) {
|
|
423
|
+
setIsOpen(true);
|
|
424
|
+
}
|
|
425
|
+
}, [isOpen, searchTerm]);
|
|
426
|
+
|
|
427
|
+
// Announce filtered results
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (isOpen && searchTerm) {
|
|
430
|
+
const count = filteredOptions.length;
|
|
431
|
+
if (count === 0) {
|
|
432
|
+
announce(`${translations.noResultsFor} "${searchTerm}"`);
|
|
433
|
+
} else {
|
|
434
|
+
announce(`${count} ${translations.optionsAvailable}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}, [filteredOptions.length, searchTerm, isOpen, announce, translations]);
|
|
438
|
+
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
if (isOpen && searchInputRef.current) {
|
|
441
|
+
searchInputRef.current.focus();
|
|
442
|
+
// Announce when dropdown opens with available options
|
|
443
|
+
if (filteredOptions.length > 0) {
|
|
444
|
+
announce(
|
|
445
|
+
translations.dropdownExpandedWithOptions
|
|
446
|
+
.replace('{count}', filteredOptions.length.toString())
|
|
447
|
+
.replace('{s}', filteredOptions.length === 1 ? '' : 's')
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}, [
|
|
452
|
+
isOpen,
|
|
453
|
+
selectedLabels.length,
|
|
454
|
+
filteredOptions.length,
|
|
455
|
+
announce,
|
|
456
|
+
translations,
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
const renderSelectedTags = () => (
|
|
460
|
+
<>
|
|
461
|
+
{selectedLabels.map((label, index) => {
|
|
462
|
+
const totalSelected = value.length;
|
|
463
|
+
const fieldContext = floatingLabel ? `${floatingLabel}: ` : '';
|
|
464
|
+
// Use i18n translations for selected items text
|
|
465
|
+
const selectedItemsText =
|
|
466
|
+
totalSelected === 1
|
|
467
|
+
? `${fieldContext}${translations.itemsSelected
|
|
468
|
+
.replace('{count}', '1')
|
|
469
|
+
.replace('{labels}', String(label))
|
|
470
|
+
.replace('{s}', '')}`
|
|
471
|
+
: `${fieldContext}${translations.itemsSelected
|
|
472
|
+
.replace('{count}', totalSelected.toString())
|
|
473
|
+
.replace('{labels}', selectedLabels.join(', '))
|
|
474
|
+
.replace('{s}', totalSelected === 1 ? '' : 's')}`;
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<Tag
|
|
478
|
+
key={value[index]}
|
|
479
|
+
data-tag="true"
|
|
480
|
+
className="dropin-multi-select__tag"
|
|
481
|
+
role="group"
|
|
482
|
+
aria-label={
|
|
483
|
+
floatingLabel
|
|
484
|
+
? translations.selectedItemInField
|
|
485
|
+
.replace('{label}', String(label))
|
|
486
|
+
.replace('{field}', floatingLabel)
|
|
487
|
+
: `${translations.selectedItem} ${String(label)}`
|
|
488
|
+
}
|
|
489
|
+
>
|
|
490
|
+
<span aria-hidden="true">{label}</span>
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
onClick={(e) => handleRemoveTag(e, value[index])}
|
|
494
|
+
className="dropin-multi-select__tag-remove"
|
|
495
|
+
disabled={disabled}
|
|
496
|
+
aria-label={translations.removeFromSelectionWithText
|
|
497
|
+
.replace('{label}', String(label))
|
|
498
|
+
.replace('{text}', selectedItemsText)}
|
|
499
|
+
>
|
|
500
|
+
<Close size={12} aria-hidden="true" />
|
|
501
|
+
</button>
|
|
502
|
+
</Tag>
|
|
503
|
+
);
|
|
504
|
+
})}
|
|
505
|
+
</>
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const renderSearchInput = () => (
|
|
509
|
+
<input
|
|
510
|
+
id={searchInputId}
|
|
511
|
+
ref={searchInputRef}
|
|
512
|
+
type="text"
|
|
513
|
+
role="combobox"
|
|
514
|
+
aria-haspopup="listbox"
|
|
515
|
+
aria-expanded={isOpen}
|
|
516
|
+
aria-controls={listboxId}
|
|
517
|
+
className={classes([
|
|
518
|
+
'dropin-multi-select__search',
|
|
519
|
+
['dropin-multi-select__search--with-floating-label', !!floatingLabel],
|
|
520
|
+
])}
|
|
521
|
+
placeholder={
|
|
522
|
+
value.length === 0 ? placeholder || translations.placeholder : ''
|
|
523
|
+
}
|
|
524
|
+
value={searchTerm}
|
|
525
|
+
onChange={(e: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
|
526
|
+
const target = e.target as HTMLInputElement;
|
|
527
|
+
setSearchTerm(target.value);
|
|
528
|
+
setFocusedIndex(-1);
|
|
529
|
+
}}
|
|
530
|
+
onKeyDown={handleInputKeyDown}
|
|
531
|
+
onFocus={() => setIsOpen(true)}
|
|
532
|
+
disabled={disabled}
|
|
533
|
+
style={{
|
|
534
|
+
minWidth: searchTerm ? `${searchTerm.length * 8 + 20}px` : '40px',
|
|
535
|
+
}}
|
|
536
|
+
aria-autocomplete="list"
|
|
537
|
+
aria-activedescendant={
|
|
538
|
+
isOpen && currentFocusedId ? currentFocusedId : undefined
|
|
539
|
+
}
|
|
540
|
+
{...(labelId
|
|
541
|
+
? { 'aria-labelledby': labelId }
|
|
542
|
+
: {
|
|
543
|
+
'aria-label':
|
|
544
|
+
floatingLabel ||
|
|
545
|
+
placeholder ||
|
|
546
|
+
translations.placeholder ||
|
|
547
|
+
translations.selectOptions,
|
|
548
|
+
})}
|
|
549
|
+
aria-describedby={selectedDescriptionId}
|
|
550
|
+
/>
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const renderDropdownControls = () => (
|
|
554
|
+
<div className="dropin-multi-select__controls">
|
|
555
|
+
<Button
|
|
556
|
+
variant="tertiary"
|
|
557
|
+
type="button"
|
|
558
|
+
className="dropin-multi-select__button dropin-multi-select__button--select-all"
|
|
559
|
+
onMouseDown={handleSelectAll}
|
|
560
|
+
data-testid="multi-select-select-all"
|
|
561
|
+
>
|
|
562
|
+
{selectAllText || translations.selectAll}
|
|
563
|
+
</Button>
|
|
564
|
+
{' | '}
|
|
565
|
+
<Button
|
|
566
|
+
variant="tertiary"
|
|
567
|
+
type="button"
|
|
568
|
+
className="dropin-multi-select__button dropin-multi-select__button--deselect-all"
|
|
569
|
+
onMouseDown={handleDeselectAll}
|
|
570
|
+
disabled={!hasSelection}
|
|
571
|
+
data-testid="multi-select-deselect-all"
|
|
572
|
+
>
|
|
573
|
+
{deselectAllText || translations.deselectAll}
|
|
574
|
+
</Button>
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
const renderOptionsList = () => (
|
|
579
|
+
<ul
|
|
580
|
+
className="dropin-multi-select__list"
|
|
581
|
+
id={listboxId}
|
|
582
|
+
role="listbox"
|
|
583
|
+
aria-multiselectable="true"
|
|
584
|
+
aria-label={floatingLabel || placeholder || translations.placeholder}
|
|
585
|
+
>
|
|
586
|
+
{filteredOptions.map((option, index) => {
|
|
587
|
+
const isSelected = value.includes(option.value);
|
|
588
|
+
const isFocused = index === focusedIndex;
|
|
589
|
+
const optionId = `${listboxId}-option-${index}`;
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<li
|
|
593
|
+
key={option.value}
|
|
594
|
+
id={optionId}
|
|
595
|
+
data-option-index={index}
|
|
596
|
+
data-testid={`multi-select-option-${index}`}
|
|
597
|
+
className={classes([
|
|
598
|
+
'dropin-multi-select__option',
|
|
599
|
+
['dropin-multi-select__option--focused', isFocused],
|
|
600
|
+
['dropin-multi-select__option--selected', isSelected],
|
|
601
|
+
['dropin-multi-select__option--disabled', option.disabled],
|
|
602
|
+
])}
|
|
603
|
+
onClick={() => {
|
|
604
|
+
if (!option.disabled) {
|
|
605
|
+
handleSelect(option.value);
|
|
606
|
+
}
|
|
607
|
+
}}
|
|
608
|
+
onMouseEnter={() => !option.disabled && setFocusedIndex(index)}
|
|
609
|
+
role="option"
|
|
610
|
+
aria-selected={isSelected}
|
|
611
|
+
aria-disabled={option.disabled}
|
|
612
|
+
>
|
|
613
|
+
<span
|
|
614
|
+
className={classes([
|
|
615
|
+
'dropin-multi-select__option-label',
|
|
616
|
+
[
|
|
617
|
+
'dropin-multi-select__option-label--disabled',
|
|
618
|
+
option.disabled,
|
|
619
|
+
],
|
|
620
|
+
])}
|
|
621
|
+
>
|
|
622
|
+
{option.label}
|
|
623
|
+
</span>
|
|
624
|
+
{isSelected && (
|
|
625
|
+
<Check
|
|
626
|
+
width={16}
|
|
627
|
+
height={16}
|
|
628
|
+
className="dropin-multi-select__check-icon"
|
|
629
|
+
aria-hidden="true"
|
|
630
|
+
/>
|
|
631
|
+
)}
|
|
632
|
+
</li>
|
|
633
|
+
);
|
|
634
|
+
})}
|
|
635
|
+
</ul>
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<div
|
|
640
|
+
ref={containerRef}
|
|
641
|
+
data-testid="multi-select"
|
|
642
|
+
className={classes(['dropin-multi-select', className])}
|
|
643
|
+
>
|
|
644
|
+
{/* Single hidden input with comma-separated values for form submission */}
|
|
645
|
+
<input
|
|
646
|
+
id={id || name}
|
|
647
|
+
type="hidden"
|
|
648
|
+
name={name}
|
|
649
|
+
data-testid="multi-select-hidden-input"
|
|
650
|
+
value={value.join(',')}
|
|
651
|
+
disabled={disabled}
|
|
652
|
+
aria-hidden="true"
|
|
653
|
+
/>
|
|
654
|
+
<div
|
|
655
|
+
className={classes([
|
|
656
|
+
'dropin-multi-select__container',
|
|
657
|
+
['dropin-multi-select__container--open', isOpen],
|
|
658
|
+
['dropin-multi-select__container--disabled', disabled],
|
|
659
|
+
['dropin-multi-select__container--error', error],
|
|
660
|
+
['dropin-multi-select__container--success', success],
|
|
661
|
+
[
|
|
662
|
+
'dropin-multi-select__container--with-floating-label',
|
|
663
|
+
!!floatingLabel,
|
|
664
|
+
],
|
|
665
|
+
[
|
|
666
|
+
'dropin-multi-select__container--has-value',
|
|
667
|
+
!!(floatingLabel && (value.length > 0 || searchTerm.length > 0)),
|
|
668
|
+
],
|
|
669
|
+
])}
|
|
670
|
+
onMouseDown={handleContainerClick}
|
|
671
|
+
data-testid="multi-select-container"
|
|
672
|
+
>
|
|
673
|
+
<div
|
|
674
|
+
className={classes([
|
|
675
|
+
'dropin-multi-select__tags-area',
|
|
676
|
+
[
|
|
677
|
+
'dropin-multi-select__tags-area--has-values',
|
|
678
|
+
selectedLabels.length > 0,
|
|
679
|
+
],
|
|
680
|
+
])}
|
|
681
|
+
data-testid="multi-select-tags-area"
|
|
682
|
+
role="group"
|
|
683
|
+
aria-label={translations.selectedItems}
|
|
684
|
+
>
|
|
685
|
+
{/* Screen-reader-only description of selected items */}
|
|
686
|
+
<div
|
|
687
|
+
id={selectedDescriptionId}
|
|
688
|
+
className="dropin-multi-select__sr-only"
|
|
689
|
+
aria-live="polite"
|
|
690
|
+
aria-atomic="true"
|
|
691
|
+
>
|
|
692
|
+
{value.length > 0
|
|
693
|
+
? translations.itemsSelectedDescription
|
|
694
|
+
.replace('{count}', value.length.toString())
|
|
695
|
+
.replace('{s}', value.length === 1 ? '' : 's')
|
|
696
|
+
.replace('{labels}', selectedLabels.join(', '))
|
|
697
|
+
: translations.noItemsSelected}
|
|
698
|
+
</div>
|
|
699
|
+
|
|
700
|
+
{renderSelectedTags()}
|
|
701
|
+
{renderSearchInput()}
|
|
702
|
+
{floatingLabel ? (
|
|
703
|
+
<label
|
|
704
|
+
className="dropin-multi-select__floating-label"
|
|
705
|
+
htmlFor={searchInputId}
|
|
706
|
+
id={labelId}
|
|
707
|
+
>
|
|
708
|
+
{floatingLabel}
|
|
709
|
+
</label>
|
|
710
|
+
) : null}
|
|
711
|
+
</div>
|
|
712
|
+
<ChevronDown
|
|
713
|
+
className={classes([
|
|
714
|
+
'dropin-multi-select__chevron',
|
|
715
|
+
['dropin-multi-select__chevron--open', isOpen],
|
|
716
|
+
])}
|
|
717
|
+
/>
|
|
718
|
+
</div>
|
|
719
|
+
|
|
720
|
+
{isOpen && (
|
|
721
|
+
<div
|
|
722
|
+
className="dropin-multi-select__dropdown"
|
|
723
|
+
data-testid="multi-select-dropdown"
|
|
724
|
+
>
|
|
725
|
+
{filteredOptions.length > 0 && renderDropdownControls()}
|
|
726
|
+
|
|
727
|
+
<div
|
|
728
|
+
ref={dropdownRef}
|
|
729
|
+
className="dropin-multi-select__options"
|
|
730
|
+
data-testid="multi-select-options"
|
|
731
|
+
style={{ maxHeight: `${maxHeight}px` }}
|
|
732
|
+
tabIndex={0}
|
|
733
|
+
role="region"
|
|
734
|
+
aria-label={translations.scrollableOptionsList}
|
|
735
|
+
>
|
|
736
|
+
{filteredOptions.length === 0 ? (
|
|
737
|
+
<div
|
|
738
|
+
className="dropin-multi-select__no-results"
|
|
739
|
+
data-testid="multi-select-no-results"
|
|
740
|
+
role="status"
|
|
741
|
+
aria-live="polite"
|
|
742
|
+
>
|
|
743
|
+
{noResultsText || translations.noResultsText}{' '}
|
|
744
|
+
{searchTerm && `"${searchTerm}"`}
|
|
745
|
+
</div>
|
|
746
|
+
) : (
|
|
747
|
+
renderOptionsList()
|
|
748
|
+
)}
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
)}
|
|
752
|
+
|
|
753
|
+
{/* Live region for screen reader announcements */}
|
|
754
|
+
<div
|
|
755
|
+
className="dropin-multi-select__announcements dropin-multi-select__sr-only"
|
|
756
|
+
aria-live="assertive"
|
|
757
|
+
aria-atomic="true"
|
|
758
|
+
>
|
|
759
|
+
{announcement}
|
|
760
|
+
</div>
|
|
761
|
+
</div>
|
|
762
|
+
);
|
|
763
|
+
};
|