@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.
Files changed (37) hide show
  1. package/config/jest.js +3 -3
  2. package/package.json +3 -3
  3. package/src/components/Button/Button.tsx +2 -0
  4. package/src/components/Field/Field.tsx +19 -14
  5. package/src/components/Icon/Icon.tsx +29 -24
  6. package/src/components/Incrementer/Incrementer.css +6 -0
  7. package/src/components/Incrementer/Incrementer.stories.tsx +18 -0
  8. package/src/components/Incrementer/Incrementer.tsx +66 -59
  9. package/src/components/MultiSelect/MultiSelect.css +273 -0
  10. package/src/components/MultiSelect/MultiSelect.stories.tsx +459 -0
  11. package/src/components/MultiSelect/MultiSelect.tsx +763 -0
  12. package/src/components/MultiSelect/index.ts +11 -0
  13. package/src/components/Pagination/Pagination.css +14 -5
  14. package/src/components/Pagination/Pagination.stories.tsx +32 -3
  15. package/src/components/Pagination/Pagination.tsx +28 -22
  16. package/src/components/Pagination/PaginationButton.tsx +46 -0
  17. package/src/components/Price/Price.tsx +8 -41
  18. package/src/components/Table/Table.css +183 -0
  19. package/src/components/Table/Table.stories.tsx +1024 -0
  20. package/src/components/Table/Table.tsx +253 -0
  21. package/src/components/Table/index.ts +11 -0
  22. package/src/components/ToggleButton/ToggleButton.css +13 -1
  23. package/src/components/ToggleButton/ToggleButton.stories.tsx +13 -6
  24. package/src/components/ToggleButton/ToggleButton.tsx +4 -0
  25. package/src/components/index.ts +5 -3
  26. package/src/docs/slots.mdx +2 -0
  27. package/src/i18n/en_US.json +38 -0
  28. package/src/icons/Business.svg +3 -0
  29. package/src/icons/List.svg +3 -0
  30. package/src/icons/Quote.svg +3 -0
  31. package/src/icons/Structure.svg +8 -0
  32. package/src/icons/Team.svg +5 -0
  33. package/src/icons/index.ts +29 -24
  34. package/src/lib/aem/configs.ts +10 -4
  35. package/src/lib/get-price-formatter.ts +69 -0
  36. package/src/lib/index.ts +4 -3
  37. 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
+ };