@juspay/svelte-ui-components 4.16.0 → 4.18.0

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.
@@ -12,8 +12,13 @@
12
12
  value = $bindable([]),
13
13
  multiple = false,
14
14
  searchable = false,
15
+ searchPosition = 'trigger',
15
16
  placeholder = '',
16
17
  disabled = false,
18
+ error = false,
19
+ errorMessage,
20
+ clearable = false,
21
+ onclear,
17
22
  bottomContent,
18
23
  optionIndicator,
19
24
  showSelectAll = false,
@@ -40,6 +45,7 @@
40
45
 
41
46
  let items: SelectItem[] = $derived(normalizeItems(rawItems));
42
47
  let query = $state('');
48
+ let restoringFocus = false;
43
49
  let highlightedIndex = $state(-1);
44
50
  let containerEl: HTMLDivElement | null = $state(null);
45
51
  let searchInputEl: HTMLInputElement | null = $state(null);
@@ -55,7 +61,44 @@
55
61
  // default. The in-flow panel still honours the CSS var via its margin-top.
56
62
  const PORTAL_DROPDOWN_GAP = 4;
57
63
 
58
- const listboxId = `select-listbox-${Math.random().toString(36).slice(2, 9)}`;
64
+ const instanceId = $props.id();
65
+ const listboxId = `select-listbox-${instanceId}`;
66
+ const errorMessageId = `${listboxId}-error`;
67
+
68
+ /* `searchable` alone no longer says where the input goes, so every place
69
+ that used to branch on it now asks which of the two it meant. Filtering
70
+ still keys off `searchable`, since that is true in both placements. */
71
+ const searchInTrigger = $derived(searchable && searchPosition !== 'menu');
72
+ const searchInMenu = $derived(searchable && searchPosition === 'menu');
73
+
74
+ const hasErrorMessage = $derived(
75
+ error && typeof errorMessage === 'string' && errorMessage.trim().length > 0
76
+ );
77
+
78
+ /* Nothing selected means nothing to clear, so the control is not rendered at
79
+ all rather than rendered inert -- a disabled × that never enables reads as
80
+ broken. Same for `disabled`, where the whole field is inert already. */
81
+ const showClearButton = $derived(clearable && !disabled && value.length > 0);
82
+
83
+ function handleClear(event: MouseEvent): void {
84
+ /* The point of a trigger-level clear is that it does NOT open the menu.
85
+ The trigger's own click handler sits on the ancestor, so without this
86
+ the click would clear and then immediately open. */
87
+ event.stopPropagation();
88
+ if (value.length === 0) {
89
+ return;
90
+ }
91
+ value = [];
92
+ onchange?.(value);
93
+ onclear?.();
94
+ if (searchable && searchInputEl !== null) {
95
+ restoringFocus = true;
96
+ searchInputEl.focus();
97
+ restoringFocus = false;
98
+ } else {
99
+ triggerEl?.focus();
100
+ }
101
+ }
59
102
 
60
103
  function getLabel(id: string): string {
61
104
  const found = items.find((item) => item.id === id);
@@ -186,7 +229,7 @@
186
229
  onopen?.();
187
230
  highlightedIndex = -1;
188
231
  query = '';
189
- if (searchable) {
232
+ if (searchInTrigger) {
190
233
  await tick();
191
234
  if (searchInputEl !== null) {
192
235
  searchInputEl.focus();
@@ -210,6 +253,11 @@
210
253
  } else {
211
254
  value = [id];
212
255
  close();
256
+ // Focus was in the menu's own search box, which is now gone; without
257
+ // this it falls back to <body> and the keyboard user loses their place.
258
+ if (searchInMenu) {
259
+ triggerEl?.focus();
260
+ }
213
261
  }
214
262
  onchange?.(value);
215
263
  }
@@ -283,6 +331,28 @@
283
331
  }
284
332
  }
285
333
 
334
+ const FOCUSABLE =
335
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
336
+
337
+ /* Options carry tabindex="-1", so this is the search box plus whatever the
338
+ consumer pinned via bottomContent -- the panel's real tab stops. */
339
+ function menuFocusables(): HTMLElement[] {
340
+ return dropdownEl === null ? [] : [...dropdownEl.querySelectorAll<HTMLElement>(FOCUSABLE)];
341
+ }
342
+
343
+ /* Leaving the menu forwards should continue from the trigger, as if the
344
+ panel had never been in the tab order -- which is literally true for a
345
+ portaled one, whose DOM position is the end of <body>. */
346
+ function focusAfterTrigger(): void {
347
+ if (triggerEl === null || typeof document === 'undefined') {
348
+ return;
349
+ }
350
+ const tabbable = [...document.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
351
+ (element) => element.closest('.select-dropdown') === null
352
+ );
353
+ tabbable.at(tabbable.indexOf(triggerEl) + 1)?.focus();
354
+ }
355
+
286
356
  function handleKeydown(event: KeyboardEvent): void {
287
357
  if (disabled) {
288
358
  return;
@@ -321,7 +391,7 @@
321
391
  case 'Escape':
322
392
  if (open) {
323
393
  close();
324
- if (!searchable && triggerEl !== null) {
394
+ if (!searchInTrigger && triggerEl !== null) {
325
395
  triggerEl.focus();
326
396
  }
327
397
  }
@@ -334,14 +404,69 @@
334
404
  }
335
405
  }
336
406
  break;
337
- case 'Tab':
338
- if (open) {
407
+ case 'Tab': {
408
+ if (!open) {
409
+ break;
410
+ }
411
+ /* Outside the in-menu variant, Tab always means focus is leaving. */
412
+ if (!searchInMenu) {
339
413
  close();
414
+ break;
415
+ }
416
+ const active = document.activeElement;
417
+ if (event.shiftKey) {
418
+ /* Backwards out of the trigger genuinely leaves the widget; every
419
+ other Shift+Tab walks back through the menu and must not close
420
+ it, or the search box could never be left and re-entered. */
421
+ if (active === triggerEl) {
422
+ close();
423
+ } else if (active === searchInputEl) {
424
+ /* The trigger is not the portaled panel's DOM predecessor, so
425
+ native Shift+Tab would land outside the widget entirely. */
426
+ event.preventDefault();
427
+ triggerEl?.focus();
428
+ }
429
+ break;
430
+ }
431
+ if (active === triggerEl) {
432
+ /* A portaled panel is appended to <body>, so it is not the
433
+ trigger's DOM-order successor and native Tab would skip the whole
434
+ menu. Moving focus explicitly makes the in-flow and portaled
435
+ variants answer Tab identically. */
436
+ event.preventDefault();
437
+ searchInputEl?.focus();
438
+ break;
439
+ }
440
+ /* Forward Tab only leaves once there is nothing left to reach: the
441
+ search box is followed by any pinned bottom actions, which were
442
+ unreachable while Tab from the search closed the menu outright. */
443
+ if (active === menuFocusables().at(-1)) {
444
+ event.preventDefault();
445
+ close();
446
+ focusAfterTrigger();
340
447
  }
341
448
  break;
449
+ }
342
450
  }
343
451
  }
344
452
 
453
+ /* The panel handler exists for the pinned bottom actions, which have no
454
+ handler of their own. The search box already carries one, and letting its
455
+ bubbled event run a second time here moved the highlight twice per key. */
456
+ function handleMenuKeydown(event: KeyboardEvent): void {
457
+ if (event.target !== searchInputEl) {
458
+ handleKeydown(event);
459
+ }
460
+ }
461
+
462
+ /* Attached imperatively: the panel is a plain container, and a keydown
463
+ handler written in the template would demand an ARIA role that the
464
+ wrapper around the search box and the listbox must not claim. */
465
+ const menuKeys = (node: HTMLElement) => {
466
+ node.addEventListener('keydown', handleMenuKeydown);
467
+ return { destroy: () => node.removeEventListener('keydown', handleMenuKeydown) };
468
+ };
469
+
345
470
  function handleSearchInput(event: Event): void {
346
471
  if (!(event.target instanceof HTMLInputElement)) {
347
472
  return;
@@ -354,7 +479,7 @@
354
479
  }
355
480
 
356
481
  function handleSearchFocus(): void {
357
- if (!open) {
482
+ if (!open && !restoringFocus) {
358
483
  openDropdown();
359
484
  }
360
485
  }
@@ -388,244 +513,337 @@
388
513
  class="select {classes ?? ''}"
389
514
  class:open
390
515
  class:disabled
516
+ class:error
391
517
  class:ghost={hierarchy === 'ghost'}
392
518
  bind:this={containerEl}
393
519
  {...typeof testId === 'string' ? { 'data-pw': testId, testID: testId } : {}}
394
520
  >
395
- <div
396
- class="select-trigger"
397
- bind:this={triggerEl}
398
- onclick={handleTriggerClick}
399
- onkeydown={handleKeydown}
400
- role="combobox"
401
- aria-expanded={open}
402
- aria-haspopup="listbox"
403
- aria-controls={listboxId}
404
- {...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
405
- tabindex={disabled ? -1 : searchable ? -1 : 0}
406
- >
407
- {#if typeof leftIcon === 'string' && leftIcon.length > 0}
408
- <Img
409
- inlineSvg
410
- src={leftIcon}
411
- alt=""
412
- fallback=""
413
- classes="select-left-icon"
414
- {...typeof leftIconTestId === 'string' ? { testId: leftIconTestId } : {}}
415
- />
416
- {/if}
417
- {#if multiple}
418
- {#if typeof triggerSummary === 'function'}
419
- {@render triggerSummary({ value, items })}
420
- {#if searchable}
421
- <input
422
- class="select-search"
423
- type="text"
424
- value={query}
425
- oninput={handleSearchInput}
426
- onfocus={handleSearchFocus}
427
- bind:this={searchInputEl}
428
- placeholder={value.length === 0 ? placeholder : ''}
429
- {disabled}
430
- autocomplete="off"
431
- tabindex={disabled ? -1 : 0}
432
- data-pw={typeof testId === 'string' ? `${testId}-search` : null}
433
- testID={typeof testId === 'string' ? `${testId}-search` : null}
434
- />
521
+ <div class="select-control" class:has-clear={showClearButton}>
522
+ <div
523
+ class="select-trigger"
524
+ bind:this={triggerEl}
525
+ onclick={handleTriggerClick}
526
+ onkeydown={handleKeydown}
527
+ role="combobox"
528
+ aria-expanded={open}
529
+ aria-haspopup="listbox"
530
+ aria-controls={listboxId}
531
+ aria-invalid={error ? 'true' : null}
532
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
533
+ {...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
534
+ tabindex={disabled ? -1 : searchInTrigger ? -1 : 0}
535
+ >
536
+ {#if typeof leftIcon === 'string' && leftIcon.length > 0}
537
+ <Img
538
+ inlineSvg
539
+ src={leftIcon}
540
+ alt=""
541
+ fallback=""
542
+ classes="select-left-icon"
543
+ {...typeof leftIconTestId === 'string' ? { testId: leftIconTestId } : {}}
544
+ />
545
+ {/if}
546
+ {#if multiple}
547
+ {#if typeof triggerSummary === 'function'}
548
+ {@render triggerSummary({ value, items })}
549
+ {#if searchInTrigger}
550
+ <input
551
+ class="select-search"
552
+ type="text"
553
+ value={query}
554
+ oninput={handleSearchInput}
555
+ onfocus={handleSearchFocus}
556
+ bind:this={searchInputEl}
557
+ placeholder={value.length === 0 ? placeholder : ''}
558
+ {disabled}
559
+ aria-invalid={error ? 'true' : null}
560
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
561
+ autocomplete="off"
562
+ tabindex={disabled ? -1 : 0}
563
+ data-pw={typeof testId === 'string' ? `${testId}-search` : null}
564
+ testID={typeof testId === 'string' ? `${testId}-search` : null}
565
+ />
566
+ {/if}
567
+ {:else}
568
+ {#each value as id (id)}
569
+ <Pill
570
+ text={getLabel(id)}
571
+ dismissible
572
+ {disabled}
573
+ ondismiss={() => removeItem(id)}
574
+ {...typeof testId === 'string' ? { testId: `${testId}-pill-${id}` } : {}}
575
+ />
576
+ {/each}
577
+ {#if searchInTrigger}
578
+ <input
579
+ class="select-search"
580
+ type="text"
581
+ value={query}
582
+ oninput={handleSearchInput}
583
+ onfocus={handleSearchFocus}
584
+ bind:this={searchInputEl}
585
+ placeholder={value.length === 0 ? placeholder : ''}
586
+ {disabled}
587
+ aria-invalid={error ? 'true' : null}
588
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
589
+ autocomplete="off"
590
+ tabindex={disabled ? -1 : 0}
591
+ data-pw={typeof testId === 'string' ? `${testId}-search` : null}
592
+ testID={typeof testId === 'string' ? `${testId}-search` : null}
593
+ />
594
+ {:else if value.length === 0}
595
+ <span class="select-placeholder">{placeholder}</span>
596
+ {/if}
435
597
  {/if}
598
+ {:else if searchInTrigger}
599
+ <input
600
+ class="select-search"
601
+ type="text"
602
+ value={open ? query : displayText}
603
+ oninput={handleSearchInput}
604
+ onfocus={handleSearchFocus}
605
+ bind:this={searchInputEl}
606
+ placeholder={searchPlaceholder}
607
+ {disabled}
608
+ aria-invalid={error ? 'true' : null}
609
+ aria-describedby={hasErrorMessage ? errorMessageId : null}
610
+ autocomplete="off"
611
+ tabindex={disabled ? -1 : 0}
612
+ data-pw={typeof testId === 'string' ? `${testId}-search` : null}
613
+ testID={typeof testId === 'string' ? `${testId}-search` : null}
614
+ />
436
615
  {:else}
437
- {#each value as id (id)}
438
- <Pill
439
- text={getLabel(id)}
440
- dismissible
441
- {disabled}
442
- ondismiss={() => removeItem(id)}
443
- {...typeof testId === 'string' ? { testId: `${testId}-pill-${id}` } : {}}
444
- />
445
- {/each}
446
- {#if searchable}
447
- <input
448
- class="select-search"
449
- type="text"
450
- value={query}
451
- oninput={handleSearchInput}
452
- onfocus={handleSearchFocus}
453
- bind:this={searchInputEl}
454
- placeholder={value.length === 0 ? placeholder : ''}
455
- {disabled}
456
- autocomplete="off"
457
- tabindex={disabled ? -1 : 0}
458
- data-pw={typeof testId === 'string' ? `${testId}-search` : null}
459
- testID={typeof testId === 'string' ? `${testId}-search` : null}
460
- />
461
- {:else if value.length === 0}
462
- <span class="select-placeholder">{placeholder}</span>
463
- {/if}
616
+ <span class={displayText.length > 0 ? 'select-value' : 'select-placeholder'}>
617
+ {displayText.length > 0 ? displayText : placeholder}
618
+ </span>
464
619
  {/if}
465
- {:else if searchable}
466
- <input
467
- class="select-search"
468
- type="text"
469
- value={open ? query : displayText}
470
- oninput={handleSearchInput}
471
- onfocus={handleSearchFocus}
472
- bind:this={searchInputEl}
473
- placeholder={searchPlaceholder}
474
- {disabled}
475
- autocomplete="off"
476
- tabindex={disabled ? -1 : 0}
477
- data-pw={typeof testId === 'string' ? `${testId}-search` : null}
478
- testID={typeof testId === 'string' ? `${testId}-search` : null}
479
- />
480
- {:else}
481
- <span class={displayText.length > 0 ? 'select-value' : 'select-placeholder'}>
482
- {displayText.length > 0 ? displayText : placeholder}
483
- </span>
620
+ <!-- eslint-disable svelte/no-at-html-tags -->
621
+ <span class="select-arrow">{@html chevronDownSvg}</span>
622
+ </div>
623
+ {#if showClearButton}
624
+ <button
625
+ type="button"
626
+ class="select-clear"
627
+ aria-label="Clear selection"
628
+ onkeydown={(event) => {
629
+ if (event.key === 'Enter' || event.key === ' ') {
630
+ event.stopPropagation();
631
+ }
632
+ }}
633
+ onclick={handleClear}
634
+ data-pw={typeof testId === 'string' ? `${testId}-clear` : null}
635
+ testID={typeof testId === 'string' ? `${testId}-clear` : null}
636
+ >
637
+ <span aria-hidden="true">×</span>
638
+ </button>
484
639
  {/if}
485
- <!-- eslint-disable svelte/no-at-html-tags -->
486
- <span class="select-arrow">{@html chevronDownSvg}</span>
487
640
  </div>
488
641
 
489
- {#if open && !disabled}
642
+ {#if hasErrorMessage}
490
643
  <div
491
- class="select-dropdown"
492
- class:select-dropdown-right={dropdownAlign === 'right'}
493
- class:select-dropdown-portal={usePortal}
494
- bind:this={dropdownEl}
495
- bind:clientWidth={dropdownWidth}
496
- bind:clientHeight={dropdownHeight}
497
- role="listbox"
498
- id={listboxId}
499
- aria-multiselectable={multiple}
500
- style={portalStyle}
501
- use:portalToBody
644
+ id={errorMessageId}
645
+ role="alert"
646
+ class="select-error-message"
647
+ data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
648
+ testID={typeof testId === 'string' ? `${testId}-error-message` : null}
502
649
  >
503
- {#if filteredItems.length === 0}
504
- <div class="select-empty">No results</div>
505
- {:else}
506
- {#each optionRows as row, index (row.kind === 'select-all' ? 'select-all' : row.item.id)}
507
- {#if row.kind === 'select-all'}
508
- <div
509
- class="select-option select-all"
510
- class:multi={multiple}
511
- class:selected={allFilteredSelected}
512
- class:highlighted={index === highlightedIndex}
513
- role="option"
514
- id={`${listboxId}-option-${index}`}
515
- aria-selected={allFilteredSelected}
516
- aria-label={selectAllIndeterminate
517
- ? `${selectAllLabel}, ${selectedFilteredCount} of ${filteredItems.length} selected`
518
- : selectAllLabel}
519
- tabindex="-1"
520
- {...typeof testId === 'string'
521
- ? { 'data-pw': `${testId}-select-all`, testID: `${testId}-select-all` }
522
- : {}}
523
- onclick={toggleSelectAll}
524
- onmouseenter={() => (highlightedIndex = index)}
525
- >
650
+ {errorMessage}
651
+ </div>
652
+ {/if}
653
+
654
+ {#snippet menuOptions()}
655
+ {#if filteredItems.length === 0}
656
+ <div class="select-empty">No results</div>
657
+ {:else}
658
+ {#each optionRows as row, index (row.kind === 'select-all' ? 'select-all' : row.item.id)}
659
+ {#if row.kind === 'select-all'}
660
+ <div
661
+ class="select-option select-all"
662
+ class:multi={multiple}
663
+ class:selected={allFilteredSelected}
664
+ class:highlighted={index === highlightedIndex}
665
+ role="option"
666
+ id={`${listboxId}-option-${index}`}
667
+ aria-selected={allFilteredSelected}
668
+ aria-label={selectAllIndeterminate
669
+ ? `${selectAllLabel}, ${selectedFilteredCount} of ${filteredItems.length} selected`
670
+ : selectAllLabel}
671
+ tabindex="-1"
672
+ {...typeof testId === 'string'
673
+ ? { 'data-pw': `${testId}-select-all`, testID: `${testId}-select-all` }
674
+ : {}}
675
+ onclick={toggleSelectAll}
676
+ onmouseenter={() => (highlightedIndex = index)}
677
+ >
678
+ {#if typeof optionIndicator === 'function'}
679
+ {@render optionIndicator({
680
+ checked: allFilteredSelected,
681
+ indeterminate: selectAllIndeterminate
682
+ })}
683
+ {:else}
684
+ <span
685
+ class="select-option-indicator"
686
+ class:checked={allFilteredSelected}
687
+ class:indeterminate={selectAllIndeterminate}
688
+ aria-hidden="true"
689
+ data-checked={allFilteredSelected ? 'true' : 'false'}
690
+ data-pw={typeof testId === 'string' ? `${testId}-select-all-indicator` : null}
691
+ testID={typeof testId === 'string' ? `${testId}-select-all-indicator` : null}
692
+ >
693
+ {#if allFilteredSelected}
694
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
695
+ <span class="select-option-check">{@html checkmarkSvg}</span>
696
+ {:else if selectAllIndeterminate}
697
+ <span
698
+ class="select-option-dash"
699
+ data-pw={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
700
+ testID={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
701
+ ></span>
702
+ {/if}
703
+ </span>
704
+ {/if}
705
+ {selectAllLabel}
706
+ </div>
707
+ {:else}
708
+ <div
709
+ class="select-option"
710
+ class:multi={multiple}
711
+ class:tickable={showSelectedTick && !multiple}
712
+ class:selected={value.includes(row.item.id)}
713
+ class:highlighted={index === highlightedIndex}
714
+ role="option"
715
+ id={`${listboxId}-option-${index}`}
716
+ aria-selected={value.includes(row.item.id)}
717
+ tabindex="-1"
718
+ {...typeof row.item.testId === 'string'
719
+ ? { 'data-pw': row.item.testId, testID: row.item.testId }
720
+ : typeof itemTestId === 'string'
721
+ ? {
722
+ 'data-pw': `${itemTestId}-${row.item.id}`,
723
+ testID: `${itemTestId}-${row.item.id}`
724
+ }
725
+ : typeof testId === 'string'
726
+ ? { 'data-pw': `${testId}-${row.item.id}`, testID: `${testId}-${row.item.id}` }
727
+ : {}}
728
+ onclick={() => selectItem(row.item.id)}
729
+ onmouseenter={() => (highlightedIndex = index)}
730
+ >
731
+ {#if multiple}
526
732
  {#if typeof optionIndicator === 'function'}
527
733
  {@render optionIndicator({
528
- checked: allFilteredSelected,
529
- indeterminate: selectAllIndeterminate
734
+ checked: value.includes(row.item.id),
735
+ indeterminate: false
530
736
  })}
531
737
  {:else}
532
738
  <span
533
739
  class="select-option-indicator"
534
- class:checked={allFilteredSelected}
535
- class:indeterminate={selectAllIndeterminate}
740
+ class:checked={value.includes(row.item.id)}
536
741
  aria-hidden="true"
537
- data-checked={allFilteredSelected ? 'true' : 'false'}
538
- data-pw={typeof testId === 'string' ? `${testId}-select-all-indicator` : null}
539
- testID={typeof testId === 'string' ? `${testId}-select-all-indicator` : null}
742
+ data-checked={value.includes(row.item.id) ? 'true' : 'false'}
743
+ data-pw={typeof testId === 'string'
744
+ ? `${testId}-option-indicator-${row.item.id}`
745
+ : null}
746
+ testID={typeof testId === 'string'
747
+ ? `${testId}-option-indicator-${row.item.id}`
748
+ : null}
540
749
  >
541
- {#if allFilteredSelected}
750
+ {#if value.includes(row.item.id)}
542
751
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
543
752
  <span class="select-option-check">{@html checkmarkSvg}</span>
544
- {:else if selectAllIndeterminate}
545
- <span
546
- class="select-option-dash"
547
- data-pw={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
548
- testID={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
549
- ></span>
550
753
  {/if}
551
754
  </span>
552
755
  {/if}
553
- {selectAllLabel}
554
- </div>
555
- {:else}
556
- <div
557
- class="select-option"
558
- class:multi={multiple}
559
- class:tickable={showSelectedTick && !multiple}
560
- class:selected={value.includes(row.item.id)}
561
- class:highlighted={index === highlightedIndex}
562
- role="option"
563
- id={`${listboxId}-option-${index}`}
564
- aria-selected={value.includes(row.item.id)}
565
- tabindex="-1"
566
- {...typeof row.item.testId === 'string'
567
- ? { 'data-pw': row.item.testId, testID: row.item.testId }
568
- : typeof itemTestId === 'string'
569
- ? {
570
- 'data-pw': `${itemTestId}-${row.item.id}`,
571
- testID: `${itemTestId}-${row.item.id}`
572
- }
573
- : typeof testId === 'string'
574
- ? { 'data-pw': `${testId}-${row.item.id}`, testID: `${testId}-${row.item.id}` }
575
- : {}}
576
- onclick={() => selectItem(row.item.id)}
577
- onmouseenter={() => (highlightedIndex = index)}
578
- >
579
- {#if multiple}
580
- {#if typeof optionIndicator === 'function'}
581
- {@render optionIndicator({
582
- checked: value.includes(row.item.id),
583
- indeterminate: false
584
- })}
585
- {:else}
586
- <span
587
- class="select-option-indicator"
588
- class:checked={value.includes(row.item.id)}
589
- aria-hidden="true"
590
- data-checked={value.includes(row.item.id) ? 'true' : 'false'}
591
- data-pw={typeof testId === 'string'
592
- ? `${testId}-option-indicator-${row.item.id}`
593
- : null}
594
- testID={typeof testId === 'string'
595
- ? `${testId}-option-indicator-${row.item.id}`
596
- : null}
597
- >
598
- {#if value.includes(row.item.id)}
599
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
600
- <span class="select-option-check">{@html checkmarkSvg}</span>
601
- {/if}
602
- </span>
603
- {/if}
604
- {/if}
605
- {#if typeof row.item.icon === 'string' && row.item.icon.length > 0}
606
- <Img
607
- inlineSvg
608
- src={row.item.icon}
609
- alt=""
610
- fallback=""
611
- classes="select-option-icon"
612
- />
613
- {/if}
614
- <span class="select-option-label">{row.item.label}</span>
615
- {#if showSelectedTick && !multiple && value.includes(row.item.id)}
616
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
617
- <span class="select-option-tick" aria-hidden="true">{@html checkmarkSvg}</span>
618
- {/if}
619
- </div>
620
- {/if}
621
- {/each}
622
- {/if}
623
- {#if typeof bottomContent === 'function'}
624
- <div class="select-bottom-content">
625
- {@render bottomContent()}
756
+ {/if}
757
+ {#if typeof row.item.icon === 'string' && row.item.icon.length > 0}
758
+ <Img inlineSvg src={row.item.icon} alt="" fallback="" classes="select-option-icon" />
759
+ {/if}
760
+ <span class="select-option-label">{row.item.label}</span>
761
+ {#if showSelectedTick && !multiple && value.includes(row.item.id)}
762
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
763
+ <span class="select-option-tick" aria-hidden="true">{@html checkmarkSvg}</span>
764
+ {/if}
765
+ </div>
766
+ {/if}
767
+ {/each}
768
+ {/if}
769
+ {#if typeof bottomContent === 'function' && !searchInMenu}
770
+ <div class="select-bottom-content">
771
+ {@render bottomContent()}
772
+ </div>
773
+ {/if}
774
+ {/snippet}
775
+
776
+ {#if open && !disabled}
777
+ {#if searchInMenu}
778
+ <!-- The panel itself carries no role here: a listbox may only contain
779
+ options, so the search box has to be a SIBLING of the listbox rather
780
+ than a child of it. `aria-controls` on the trigger still points at
781
+ the inner element that actually holds the options. -->
782
+ <div
783
+ class="select-dropdown"
784
+ class:select-dropdown-right={dropdownAlign === 'right'}
785
+ class:select-dropdown-portal={usePortal}
786
+ bind:this={dropdownEl}
787
+ bind:clientWidth={dropdownWidth}
788
+ bind:clientHeight={dropdownHeight}
789
+ style={portalStyle}
790
+ use:menuKeys
791
+ use:portalToBody
792
+ >
793
+ <input
794
+ class="select-menu-search"
795
+ type="search"
796
+ value={query}
797
+ oninput={handleSearchInput}
798
+ onkeydown={handleKeydown}
799
+ bind:this={searchInputEl}
800
+ placeholder={placeholder.length > 0 ? placeholder : 'Search'}
801
+ aria-label="Search options"
802
+ aria-controls={listboxId}
803
+ autocomplete="off"
804
+ data-pw={typeof testId === 'string' ? `${testId}-menu-search` : null}
805
+ testID={typeof testId === 'string' ? `${testId}-menu-search` : null}
806
+ />
807
+ <!-- tabindex="-1" because the list scrolls, and a scroll container is
808
+ a tab stop of its own in Chromium -- which swallowed the Tab that
809
+ should reach the pinned bottom actions. The options are navigated
810
+ from the search box via aria-activedescendant, so the list itself
811
+ never needs to hold focus. -->
812
+ <div
813
+ class="select-menu-list"
814
+ role="listbox"
815
+ id={listboxId}
816
+ aria-multiselectable={multiple}
817
+ tabindex="-1"
818
+ >
819
+ {@render menuOptions()}
626
820
  </div>
627
- {/if}
628
- </div>
821
+ {#if typeof bottomContent === 'function'}
822
+ <!-- Outside the listbox: a listbox may only contain options, so
823
+ pinned actions (clear-all, apply) would be invalid children and
824
+ assistive tech would announce them as options. -->
825
+ <div class="select-bottom-content">
826
+ {@render bottomContent()}
827
+ </div>
828
+ {/if}
829
+ </div>
830
+ {:else}
831
+ <div
832
+ class="select-dropdown"
833
+ class:select-dropdown-right={dropdownAlign === 'right'}
834
+ class:select-dropdown-portal={usePortal}
835
+ bind:this={dropdownEl}
836
+ bind:clientWidth={dropdownWidth}
837
+ bind:clientHeight={dropdownHeight}
838
+ role="listbox"
839
+ id={listboxId}
840
+ aria-multiselectable={multiple}
841
+ style={portalStyle}
842
+ use:portalToBody
843
+ >
844
+ {@render menuOptions()}
845
+ </div>
846
+ {/if}
629
847
  {/if}
630
848
  </div>
631
849
 
@@ -684,6 +902,84 @@
684
902
  box-shadow: var(--select-trigger-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
685
903
  }
686
904
 
905
+ /* Pressed feedback on the trigger. `:active` only, so it lasts exactly as
906
+ long as the pointer is down and never lingers as a stuck state the way a
907
+ class-driven one can. */
908
+ .select:not(.disabled) .select-trigger:active {
909
+ background: var(--select-trigger-pressed-background, #ededed);
910
+ }
911
+
912
+ /* The error border has to outrank BOTH hover and focus/open, or the field
913
+ stops reading as invalid the moment the user enters it -- which is the one
914
+ moment they most need to see it. Listed after them, at equal or higher
915
+ specificity, rather than reached for with !important. */
916
+ .select.error .select-trigger,
917
+ .select.error .select-trigger:hover,
918
+ .select.error .select-trigger:focus-within,
919
+ .select.error.open .select-trigger {
920
+ border-color: var(--select-trigger-error-border-color, #dc2626);
921
+ }
922
+
923
+ .select.error .select-trigger:focus-within,
924
+ .select.error.open .select-trigger {
925
+ box-shadow: var(--select-trigger-error-shadow, 0 0 0 2px rgba(220, 38, 38, 0.2));
926
+ }
927
+
928
+ .select-error-message {
929
+ margin-top: var(--select-error-message-margin-top, 4px);
930
+ font-size: var(--select-error-message-font-size, 12px);
931
+ color: var(--select-error-message-color, #dc2626);
932
+ }
933
+
934
+ .select-control {
935
+ position: relative;
936
+ }
937
+
938
+ .select-control.has-clear .select-trigger {
939
+ padding-inline-end: calc(var(--select-clear-size, 18px) + 36px);
940
+ }
941
+
942
+ .select-control.has-clear .select-arrow {
943
+ position: absolute;
944
+ right: 12px;
945
+ }
946
+
947
+ .select-clear {
948
+ position: absolute;
949
+ right: 32px;
950
+ top: 50%;
951
+ transform: translateY(-50%);
952
+ display: inline-flex;
953
+ align-items: center;
954
+ justify-content: center;
955
+ flex: none;
956
+ width: var(--select-clear-size, 18px);
957
+ height: var(--select-clear-size, 18px);
958
+ margin: 0;
959
+ padding: 0;
960
+ border: none;
961
+ border-radius: var(--select-clear-border-radius, var(--radius, 4px));
962
+ background: var(--select-clear-background, transparent);
963
+ color: var(--select-clear-color, #666666);
964
+ font-size: var(--select-clear-font-size, 16px);
965
+ line-height: 1;
966
+ cursor: pointer;
967
+ }
968
+
969
+ .select-clear:focus-visible {
970
+ outline: var(--select-clear-focus-outline, 2px solid currentColor);
971
+ outline-offset: 2px;
972
+ }
973
+
974
+ .select-clear:hover {
975
+ background: var(--select-clear-hover-background, #ededed);
976
+ color: var(--select-clear-hover-color, #111111);
977
+ }
978
+
979
+ .select-clear:active {
980
+ background: var(--select-clear-pressed-background, #e0e0e0);
981
+ }
982
+
687
983
  .select-value {
688
984
  flex: 1;
689
985
  white-space: nowrap;
@@ -756,6 +1052,41 @@
756
1052
  z-index: var(--select-dropdown-z-index, 10);
757
1053
  }
758
1054
 
1055
+ /* With the search box inside the panel, the PANEL must stop scrolling and
1056
+ the list scroll instead -- otherwise the search box scrolls away as soon
1057
+ as the user moves down the results, which is the one control they still
1058
+ need. The max-height moves down with the scrolling, so the panel keeps
1059
+ growing to fit the search box plus a list of the same height as before. */
1060
+ .select-dropdown:has(> .select-menu-list) {
1061
+ max-height: none;
1062
+ overflow-y: visible;
1063
+ display: flex;
1064
+ flex-direction: column;
1065
+ }
1066
+
1067
+ .select-menu-list {
1068
+ max-height: var(--select-dropdown-max-height, 200px);
1069
+ overflow-y: auto;
1070
+ }
1071
+
1072
+ .select-menu-search {
1073
+ flex: none;
1074
+ margin: var(--select-menu-search-margin, 6px);
1075
+ padding: var(--select-menu-search-padding, 6px 8px);
1076
+ border: var(--select-menu-search-border, 1px solid #cccccc);
1077
+ border-radius: var(--select-menu-search-border-radius, var(--radius, 4px));
1078
+ background: var(--select-menu-search-background, #ffffff);
1079
+ color: var(--select-menu-search-color, inherit);
1080
+ font-family: inherit;
1081
+ font-size: var(--select-menu-search-font-size, inherit);
1082
+ outline: none;
1083
+ }
1084
+
1085
+ .select-menu-search:focus-visible {
1086
+ border-color: var(--select-menu-search-focus-border-color, #2563eb);
1087
+ box-shadow: var(--select-menu-search-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
1088
+ }
1089
+
759
1090
  .select-dropdown-right {
760
1091
  left: auto;
761
1092
  right: var(--select-dropdown-right, 0);
@@ -821,6 +1152,18 @@
821
1152
  );
822
1153
  }
823
1154
 
1155
+ /* Pressed feedback comes last, and each selector carries enough specificity
1156
+ to outrank its non-active counterpart above. An option is always hovered
1157
+ when pressed and is often selected too, so a single low-specificity rule
1158
+ earlier in the sheet would be correct in isolation and invisible in
1159
+ practice -- which is how a pressed state ends up shipping as dead CSS.
1160
+ A press on an already-selected row still has to confirm the tap landed. */
1161
+ .select-option:active,
1162
+ .select-option.selected:active,
1163
+ .select-option.selected.highlighted:active {
1164
+ background: var(--select-option-pressed-background, #ededed);
1165
+ }
1166
+
824
1167
  .select-option-indicator {
825
1168
  display: inline-flex;
826
1169
  align-items: center;