@juspay/svelte-ui-components 4.17.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,6 +12,7 @@
12
12
  value = $bindable([]),
13
13
  multiple = false,
14
14
  searchable = false,
15
+ searchPosition = 'trigger',
15
16
  placeholder = '',
16
17
  disabled = false,
17
18
  error = false,
@@ -64,6 +65,12 @@
64
65
  const listboxId = `select-listbox-${instanceId}`;
65
66
  const errorMessageId = `${listboxId}-error`;
66
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
+
67
74
  const hasErrorMessage = $derived(
68
75
  error && typeof errorMessage === 'string' && errorMessage.trim().length > 0
69
76
  );
@@ -222,7 +229,7 @@
222
229
  onopen?.();
223
230
  highlightedIndex = -1;
224
231
  query = '';
225
- if (searchable) {
232
+ if (searchInTrigger) {
226
233
  await tick();
227
234
  if (searchInputEl !== null) {
228
235
  searchInputEl.focus();
@@ -246,6 +253,11 @@
246
253
  } else {
247
254
  value = [id];
248
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
+ }
249
261
  }
250
262
  onchange?.(value);
251
263
  }
@@ -319,6 +331,28 @@
319
331
  }
320
332
  }
321
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
+
322
356
  function handleKeydown(event: KeyboardEvent): void {
323
357
  if (disabled) {
324
358
  return;
@@ -357,7 +391,7 @@
357
391
  case 'Escape':
358
392
  if (open) {
359
393
  close();
360
- if (!searchable && triggerEl !== null) {
394
+ if (!searchInTrigger && triggerEl !== null) {
361
395
  triggerEl.focus();
362
396
  }
363
397
  }
@@ -370,14 +404,69 @@
370
404
  }
371
405
  }
372
406
  break;
373
- case 'Tab':
374
- 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) {
375
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();
376
447
  }
377
448
  break;
449
+ }
450
+ }
451
+ }
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);
378
459
  }
379
460
  }
380
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
+
381
470
  function handleSearchInput(event: Event): void {
382
471
  if (!(event.target instanceof HTMLInputElement)) {
383
472
  return;
@@ -442,7 +531,7 @@
442
531
  aria-invalid={error ? 'true' : null}
443
532
  aria-describedby={hasErrorMessage ? errorMessageId : null}
444
533
  {...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
445
- tabindex={disabled ? -1 : searchable ? -1 : 0}
534
+ tabindex={disabled ? -1 : searchInTrigger ? -1 : 0}
446
535
  >
447
536
  {#if typeof leftIcon === 'string' && leftIcon.length > 0}
448
537
  <Img
@@ -457,7 +546,7 @@
457
546
  {#if multiple}
458
547
  {#if typeof triggerSummary === 'function'}
459
548
  {@render triggerSummary({ value, items })}
460
- {#if searchable}
549
+ {#if searchInTrigger}
461
550
  <input
462
551
  class="select-search"
463
552
  type="text"
@@ -485,7 +574,7 @@
485
574
  {...typeof testId === 'string' ? { testId: `${testId}-pill-${id}` } : {}}
486
575
  />
487
576
  {/each}
488
- {#if searchable}
577
+ {#if searchInTrigger}
489
578
  <input
490
579
  class="select-search"
491
580
  type="text"
@@ -506,7 +595,7 @@
506
595
  <span class="select-placeholder">{placeholder}</span>
507
596
  {/if}
508
597
  {/if}
509
- {:else if searchable}
598
+ {:else if searchInTrigger}
510
599
  <input
511
600
  class="select-search"
512
601
  type="text"
@@ -562,146 +651,199 @@
562
651
  </div>
563
652
  {/if}
564
653
 
565
- {#if open && !disabled}
566
- <div
567
- class="select-dropdown"
568
- class:select-dropdown-right={dropdownAlign === 'right'}
569
- class:select-dropdown-portal={usePortal}
570
- bind:this={dropdownEl}
571
- bind:clientWidth={dropdownWidth}
572
- bind:clientHeight={dropdownHeight}
573
- role="listbox"
574
- id={listboxId}
575
- aria-multiselectable={multiple}
576
- style={portalStyle}
577
- use:portalToBody
578
- >
579
- {#if filteredItems.length === 0}
580
- <div class="select-empty">No results</div>
581
- {:else}
582
- {#each optionRows as row, index (row.kind === 'select-all' ? 'select-all' : row.item.id)}
583
- {#if row.kind === 'select-all'}
584
- <div
585
- class="select-option select-all"
586
- class:multi={multiple}
587
- class:selected={allFilteredSelected}
588
- class:highlighted={index === highlightedIndex}
589
- role="option"
590
- id={`${listboxId}-option-${index}`}
591
- aria-selected={allFilteredSelected}
592
- aria-label={selectAllIndeterminate
593
- ? `${selectAllLabel}, ${selectedFilteredCount} of ${filteredItems.length} selected`
594
- : selectAllLabel}
595
- tabindex="-1"
596
- {...typeof testId === 'string'
597
- ? { 'data-pw': `${testId}-select-all`, testID: `${testId}-select-all` }
598
- : {}}
599
- onclick={toggleSelectAll}
600
- onmouseenter={() => (highlightedIndex = index)}
601
- >
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}
602
732
  {#if typeof optionIndicator === 'function'}
603
733
  {@render optionIndicator({
604
- checked: allFilteredSelected,
605
- indeterminate: selectAllIndeterminate
734
+ checked: value.includes(row.item.id),
735
+ indeterminate: false
606
736
  })}
607
737
  {:else}
608
738
  <span
609
739
  class="select-option-indicator"
610
- class:checked={allFilteredSelected}
611
- class:indeterminate={selectAllIndeterminate}
740
+ class:checked={value.includes(row.item.id)}
612
741
  aria-hidden="true"
613
- data-checked={allFilteredSelected ? 'true' : 'false'}
614
- data-pw={typeof testId === 'string' ? `${testId}-select-all-indicator` : null}
615
- 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}
616
749
  >
617
- {#if allFilteredSelected}
750
+ {#if value.includes(row.item.id)}
618
751
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
619
752
  <span class="select-option-check">{@html checkmarkSvg}</span>
620
- {:else if selectAllIndeterminate}
621
- <span
622
- class="select-option-dash"
623
- data-pw={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
624
- testID={typeof testId === 'string' ? `${testId}-select-all-dash` : null}
625
- ></span>
626
753
  {/if}
627
754
  </span>
628
755
  {/if}
629
- {selectAllLabel}
630
- </div>
631
- {:else}
632
- <div
633
- class="select-option"
634
- class:multi={multiple}
635
- class:tickable={showSelectedTick && !multiple}
636
- class:selected={value.includes(row.item.id)}
637
- class:highlighted={index === highlightedIndex}
638
- role="option"
639
- id={`${listboxId}-option-${index}`}
640
- aria-selected={value.includes(row.item.id)}
641
- tabindex="-1"
642
- {...typeof row.item.testId === 'string'
643
- ? { 'data-pw': row.item.testId, testID: row.item.testId }
644
- : typeof itemTestId === 'string'
645
- ? {
646
- 'data-pw': `${itemTestId}-${row.item.id}`,
647
- testID: `${itemTestId}-${row.item.id}`
648
- }
649
- : typeof testId === 'string'
650
- ? { 'data-pw': `${testId}-${row.item.id}`, testID: `${testId}-${row.item.id}` }
651
- : {}}
652
- onclick={() => selectItem(row.item.id)}
653
- onmouseenter={() => (highlightedIndex = index)}
654
- >
655
- {#if multiple}
656
- {#if typeof optionIndicator === 'function'}
657
- {@render optionIndicator({
658
- checked: value.includes(row.item.id),
659
- indeterminate: false
660
- })}
661
- {:else}
662
- <span
663
- class="select-option-indicator"
664
- class:checked={value.includes(row.item.id)}
665
- aria-hidden="true"
666
- data-checked={value.includes(row.item.id) ? 'true' : 'false'}
667
- data-pw={typeof testId === 'string'
668
- ? `${testId}-option-indicator-${row.item.id}`
669
- : null}
670
- testID={typeof testId === 'string'
671
- ? `${testId}-option-indicator-${row.item.id}`
672
- : null}
673
- >
674
- {#if value.includes(row.item.id)}
675
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
676
- <span class="select-option-check">{@html checkmarkSvg}</span>
677
- {/if}
678
- </span>
679
- {/if}
680
- {/if}
681
- {#if typeof row.item.icon === 'string' && row.item.icon.length > 0}
682
- <Img
683
- inlineSvg
684
- src={row.item.icon}
685
- alt=""
686
- fallback=""
687
- classes="select-option-icon"
688
- />
689
- {/if}
690
- <span class="select-option-label">{row.item.label}</span>
691
- {#if showSelectedTick && !multiple && value.includes(row.item.id)}
692
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
693
- <span class="select-option-tick" aria-hidden="true">{@html checkmarkSvg}</span>
694
- {/if}
695
- </div>
696
- {/if}
697
- {/each}
698
- {/if}
699
- {#if typeof bottomContent === 'function'}
700
- <div class="select-bottom-content">
701
- {@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()}
702
820
  </div>
703
- {/if}
704
- </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}
705
847
  {/if}
706
848
  </div>
707
849
 
@@ -910,6 +1052,41 @@
910
1052
  z-index: var(--select-dropdown-z-index, 10);
911
1053
  }
912
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
+
913
1090
  .select-dropdown-right {
914
1091
  left: auto;
915
1092
  right: var(--select-dropdown-right, 0);
@@ -20,10 +20,28 @@ export type MandatorySelectProperties = {
20
20
  items: SelectItem[] | string[];
21
21
  };
22
22
  export type SelectHierarchy = 'default' | 'ghost';
23
+ /** Placement of the `searchable` filter input. See `searchPosition`. */
24
+ export type SelectSearchPosition = 'trigger' | 'menu';
23
25
  export type OptionalSelectProperties = {
24
26
  value?: string[];
25
27
  multiple?: boolean;
26
28
  searchable?: boolean;
29
+ /**
30
+ * Where the `searchable` filter input lives. `'trigger'` (the default) keeps
31
+ * today's behaviour: the input replaces the trigger's label and is focused
32
+ * automatically on open.
33
+ *
34
+ * `'menu'` puts it inside the open dropdown instead, which is what the
35
+ * design-system keyboard contract specifies — the trigger stays a plain
36
+ * combobox, and the search box is reached by Tab AFTER opening rather than
37
+ * by being focused for you. That difference is the point: with the input in
38
+ * the trigger there is no way to open the menu and keep focus on the
39
+ * combobox, so a keyboard user who wanted to arrow through the options had
40
+ * to type first.
41
+ *
42
+ * No effect unless `searchable` is also set.
43
+ */
44
+ searchPosition?: SelectSearchPosition;
27
45
  placeholder?: string;
28
46
  disabled?: boolean;
29
47
  /**