@juspay/svelte-ui-components 4.27.6 → 4.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +140 -36
  2. package/dist/Animations/ModalAnimation.svelte +15 -1
  3. package/dist/Animations/OverlayAnimation.svelte +7 -1
  4. package/dist/AreaChart/AreaChart.svelte +366 -85
  5. package/dist/AspectRatio/AspectRatio.svelte +51 -0
  6. package/dist/AspectRatio/AspectRatio.svelte.d.ts +3 -0
  7. package/dist/AspectRatio/properties.d.ts +27 -0
  8. package/dist/AspectRatio/properties.js +1 -0
  9. package/dist/Banner/Banner.svelte +10 -4
  10. package/dist/Banner/properties.d.ts +8 -0
  11. package/dist/BarChart/BarChart.svelte +156 -49
  12. package/dist/Book/Book.svelte +14 -1
  13. package/dist/BrandLoader/BrandLoader.svelte +76 -12
  14. package/dist/Button/Button.svelte +56 -11
  15. package/dist/Calendar/Calendar.svelte +88 -61
  16. package/dist/Card/Card.svelte +1 -1
  17. package/dist/Carousel/Carousel.svelte +6 -3
  18. package/dist/ChatComposer/ChatComposer.svelte +54 -4
  19. package/dist/ChatComposer/dictationState.d.ts +15 -0
  20. package/dist/ChatComposer/dictationState.js +9 -0
  21. package/dist/ChatComposer/properties.d.ts +28 -1
  22. package/dist/ChatMessage/ChatMessage.svelte +27 -1
  23. package/dist/CheckListItem/CheckListItem.svelte +3 -3
  24. package/dist/Checkbox/Checkbox.svelte +84 -3
  25. package/dist/Checkbox/properties.d.ts +45 -13
  26. package/dist/ChipInput/ChipInput.svelte +70 -3
  27. package/dist/ChipInput/properties.d.ts +16 -0
  28. package/dist/Choicebox/Choicebox.svelte +156 -1
  29. package/dist/Choicebox/group.d.ts +63 -0
  30. package/dist/Choicebox/group.js +127 -0
  31. package/dist/Choicebox/properties.d.ts +46 -0
  32. package/dist/ColorPicker/ColorPicker.svelte +169 -20
  33. package/dist/ColorPicker/properties.d.ts +16 -0
  34. package/dist/Combobox/Combobox.svelte +87 -25
  35. package/dist/Combobox/properties.d.ts +23 -0
  36. package/dist/CommandMenu/CommandMenu.svelte +27 -11
  37. package/dist/ContextMenu/ContextMenu.svelte +31 -37
  38. package/dist/DateRangePicker/DateRangePicker.svelte +171 -73
  39. package/dist/DeltaIndicator/DeltaIndicator.svelte +3 -3
  40. package/dist/Draggable/Draggable.svelte +12 -7
  41. package/dist/DualAxisBarChart/DualAxisBarChart.svelte +11 -4
  42. package/dist/EmptyState/EmptyState.svelte +1 -1
  43. package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +1 -1
  44. package/dist/FileInput/FileInput.svelte +50 -1
  45. package/dist/FileInput/properties.d.ts +7 -0
  46. package/dist/FunnelChart/FunnelChart.svelte +40 -12
  47. package/dist/FunnelChart/properties.d.ts +7 -1
  48. package/dist/Gallery/Gallery.svelte +20 -13
  49. package/dist/Gallery/properties.d.ts +8 -0
  50. package/dist/GridItem/GridItem.svelte +31 -1
  51. package/dist/HITL/HITL.svelte +35 -9
  52. package/dist/Icon/Icon.svelte +19 -3
  53. package/dist/Input/Input.svelte +24 -10
  54. package/dist/Input/properties.d.ts +37 -1
  55. package/dist/InputButton/InputButton.svelte +86 -11
  56. package/dist/InputButton/properties.d.ts +10 -1
  57. package/dist/Label/Label.svelte +54 -0
  58. package/dist/Label/Label.svelte.d.ts +3 -0
  59. package/dist/Label/properties.d.ts +26 -0
  60. package/dist/Label/properties.js +1 -0
  61. package/dist/LineChart/LineChart.svelte +385 -94
  62. package/dist/ListItem/ListItem.svelte +121 -15
  63. package/dist/Loader/Loader.svelte +22 -2
  64. package/dist/LoadingDots/LoadingDots.svelte +21 -0
  65. package/dist/MediaUpload/MediaUpload.svelte +13 -3
  66. package/dist/Menu/Menu.svelte +56 -28
  67. package/dist/Modal/Modal.svelte +77 -42
  68. package/dist/Pagination/Pagination.svelte +9 -2
  69. package/dist/Phone/Phone.svelte +4 -0
  70. package/dist/PieChart/PieChart.svelte +282 -38
  71. package/dist/PieChart/properties.d.ts +9 -0
  72. package/dist/Pill/Pill.svelte +14 -5
  73. package/dist/Progress/Progress.svelte +20 -0
  74. package/dist/Radio/Radio.svelte +58 -1
  75. package/dist/Radio/properties.d.ts +23 -0
  76. package/dist/RatingGroup/RatingGroup.svelte +283 -0
  77. package/dist/RatingGroup/RatingGroup.svelte.d.ts +4 -0
  78. package/dist/RatingGroup/properties.d.ts +73 -0
  79. package/dist/RatingGroup/properties.js +1 -0
  80. package/dist/RelativeTime/RelativeTime.svelte +1 -1
  81. package/dist/SankeyChart/SankeyChart.svelte +272 -50
  82. package/dist/SankeyChart/properties.d.ts +9 -0
  83. package/dist/Scroller/Scroller.svelte +37 -2
  84. package/dist/Select/Select.svelte +166 -46
  85. package/dist/Select/properties.d.ts +7 -0
  86. package/dist/Separator/Separator.svelte +48 -0
  87. package/dist/Separator/Separator.svelte.d.ts +3 -0
  88. package/dist/Separator/properties.d.ts +22 -0
  89. package/dist/Separator/properties.js +1 -0
  90. package/dist/Sheet/Sheet.svelte +60 -21
  91. package/dist/Shimmer/Shimmer.svelte +15 -0
  92. package/dist/Slider/Slider.svelte +87 -6
  93. package/dist/Slider/properties.d.ts +26 -0
  94. package/dist/Snippet/Snippet.svelte +4 -0
  95. package/dist/Snippet/copyState.svelte.d.ts +13 -1
  96. package/dist/Snippet/copyState.svelte.js +27 -1
  97. package/dist/Snippet/properties.d.ts +7 -0
  98. package/dist/SoundKit/SoundKit.js +9 -8
  99. package/dist/SplitInput/SplitInput.svelte +68 -2
  100. package/dist/SplitInput/properties.d.ts +23 -0
  101. package/dist/StatCard/StatCard.svelte +6 -6
  102. package/dist/Status/Status.svelte +2 -2
  103. package/dist/Stepper/Step.svelte +19 -6
  104. package/dist/Stepper/Stepper.svelte +108 -35
  105. package/dist/Table/BuiltinCell.svelte +2 -2
  106. package/dist/Table/Table.svelte +373 -55
  107. package/dist/Table/properties.d.ts +134 -3
  108. package/dist/Tabs/Tabs.svelte +263 -65
  109. package/dist/Tabs/properties.d.ts +23 -0
  110. package/dist/TaskList/TaskList.svelte +14 -5
  111. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +23 -0
  112. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +13 -5
  113. package/dist/Toast/Toast.svelte +27 -2
  114. package/dist/Toggle/Toggle.svelte +79 -7
  115. package/dist/Toggle/Toggle.svelte.d.ts +1 -1
  116. package/dist/Toggle/properties.d.ts +30 -7
  117. package/dist/ToolCallLog/ToolCallLog.svelte +59 -21
  118. package/dist/ToolCallLog/properties.d.ts +10 -0
  119. package/dist/Toolbar/Toolbar.svelte +1 -1
  120. package/dist/Tooltip/Tooltip.svelte +20 -2
  121. package/dist/Tooltip/tooltip-action.js +39 -4
  122. package/dist/TypewriterText/TypewriterText.svelte +34 -2
  123. package/dist/TypewriterText/properties.d.ts +18 -5
  124. package/dist/_chart/ChartContainer.svelte +12 -0
  125. package/dist/_chart/ChartTooltip.svelte +18 -4
  126. package/dist/_chart/Legend.svelte +21 -0
  127. package/dist/_chart/Legend.svelte.d.ts +15 -0
  128. package/dist/_chart/geometry.d.ts +54 -1
  129. package/dist/_chart/geometry.js +122 -14
  130. package/dist/_chart/interactions.d.ts +5 -1
  131. package/dist/_chart/interactions.js +9 -5
  132. package/dist/_chart/scales.js +16 -0
  133. package/dist/_chart/temporal.d.ts +45 -0
  134. package/dist/_chart/temporal.js +116 -0
  135. package/dist/_field/description.d.ts +50 -0
  136. package/dist/_field/description.js +51 -0
  137. package/dist/_interaction/dismissal.d.ts +67 -0
  138. package/dist/_interaction/dismissal.js +137 -0
  139. package/dist/_interaction/focus.d.ts +73 -0
  140. package/dist/_interaction/focus.js +143 -0
  141. package/dist/assets/star.svg +5 -0
  142. package/dist/index.d.ts +13 -1
  143. package/dist/index.js +21 -1
  144. package/dist/reduced-motion.svelte.d.ts +37 -0
  145. package/dist/reduced-motion.svelte.js +44 -0
  146. package/dist/styles/theme-dark.css +778 -0
  147. package/dist/types.d.ts +4 -2
  148. package/dist/utils.d.ts +62 -1
  149. package/dist/utils.js +80 -21
  150. package/dist-codemod/{bin.js → codemod/bin.js} +13 -2
  151. package/dist-codemod/{cli.js → codemod/cli.js} +48 -3
  152. package/dist-codemod/codemod/migrate-audit.js +395 -0
  153. package/dist-codemod/codemod/migration-assets/legacy-display.css +120 -0
  154. package/dist-codemod/codemod/migration-assets/legacy-palette.css +106 -0
  155. package/dist-codemod/codemod/migration-assets/wc-display.json +492 -0
  156. package/dist-codemod/migrate/analyze.js +804 -0
  157. package/dist-codemod/migrate/chart-tooltip-selector.js +301 -0
  158. package/dist-wc/index.js +13992 -4323
  159. package/package.json +13 -6
  160. package/dist/Modal/focus-trap.d.ts +0 -42
  161. package/dist/Modal/focus-trap.js +0 -57
  162. /package/dist-codemod/{legacy-pairs.js → codemod/legacy-pairs.js} +0 -0
  163. /package/dist-codemod/{transform.js → codemod/transform.js} +0 -0
  164. /package/dist-codemod/{wc-children.js → codemod/wc-children.js} +0 -0
@@ -1,4 +1,7 @@
1
1
  <script lang="ts">
2
+ import { eventHitsInside, registerDismissible } from '../_interaction/dismissal';
3
+ import { getActiveElement } from '../_interaction/focus';
4
+ import { describeField } from '../_field/description';
2
5
  import { onMount, tick } from 'svelte';
3
6
  import type { SelectItem, SelectPlacement, SelectProperties } from './properties';
4
7
  import Pill from '../Pill/Pill.svelte';
@@ -126,7 +129,6 @@
126
129
 
127
130
  const instanceId = $props.id();
128
131
  const listboxId = `select-listbox-${instanceId}`;
129
- const errorMessageId = `${listboxId}-error`;
130
132
 
131
133
  /* `searchable` alone no longer says where the input goes, so every place
132
134
  that used to branch on it now asks which of the two it meant. Filtering
@@ -134,8 +136,28 @@
134
136
  const searchInTrigger = $derived(searchable && searchPosition !== 'menu');
135
137
  const searchInMenu = $derived(searchable && searchPosition === 'menu');
136
138
 
137
- const hasErrorMessage = $derived(
138
- error && typeof errorMessage === 'string' && errorMessage.trim().length > 0
139
+ /* `error` is an independent gate, not merely "is errorMessage non-empty" --
140
+ `error=false` with `errorMessage` still set is a state this component's two-prop
141
+ API explicitly permits (see properties.ts) and must render nothing, matching
142
+ today's behaviour before this ported to describeField. Passing `error ?
143
+ errorMessage : null` preserves that gate; passing `error` through `invalid` too
144
+ means aria-invalid tracks `error` alone, exactly like the literal `error ?
145
+ 'true' : null` this replaces, whether or not a message is set.
146
+ describeField's own hasText check does not trim (`value !== ''`), while this
147
+ gate did (`.trim().length > 0`) -- so a whitespace-only errorMessage rendered
148
+ nothing before and, ported as-is, would render an empty-looking alert div after.
149
+ The trim is kept here, in the glue, rather than tightened into hasText itself:
150
+ hasText is shared by five other consumers (Radio, Checkbox, Slider, Toggle,
151
+ FileInput) this change does not otherwise touch, and tightening it was out of
152
+ scope for a surgical port of Select alone. */
153
+ const field = $derived(
154
+ describeField(listboxId, {
155
+ error:
156
+ error && typeof errorMessage === 'string' && errorMessage.trim().length > 0
157
+ ? errorMessage
158
+ : null,
159
+ invalid: error
160
+ })
139
161
  );
140
162
 
141
163
  /* Nothing selected means nothing to clear, so the control is not rendered at
@@ -245,15 +267,30 @@
245
267
  });
246
268
 
247
269
  /**
248
- * Svelte action: relocates the dropdown to document.body when usePortal is set,
270
+ * Where a portalled node is allowed to land. Svelte scopes a custom element's
271
+ * CSS to its shadow root, so `document.body` is the one place the panel must
272
+ * never go from inside `<sui-select>`: it keeps its `svelte-*` scoping class
273
+ * and loses every rule behind it, plus the host's custom properties. Measured
274
+ * in Chromium -- position fell back to `static`, background to transparent,
275
+ * border and shadow to none, so the options rendered as bare text over the
276
+ * page. Its own root holds that stylesheet and still sits above every
277
+ * overflow/scroll ancestor between the two.
278
+ */
279
+ const portalTarget = (node: Node): Node => {
280
+ const root = node.getRootNode();
281
+ return root instanceof ShadowRoot ? root : document.body;
282
+ };
283
+
284
+ /**
285
+ * Svelte action: relocates the dropdown out to its root when usePortal is set,
249
286
  * so a position:fixed panel is never clipped by an overflow/scroll ancestor
250
287
  * (e.g. a table cell). No-op otherwise; `use:` actions never run during SSR.
251
288
  */
252
- const portalToBody = (node: HTMLElement) => {
289
+ const portalToRoot = (node: HTMLElement) => {
253
290
  if (!usePortal) {
254
291
  return;
255
292
  }
256
- document.body.appendChild(node);
293
+ portalTarget(node).appendChild(node);
257
294
  return { destroy: () => node.remove() };
258
295
  };
259
296
 
@@ -360,6 +397,9 @@
360
397
  if (disabled) {
361
398
  return;
362
399
  }
400
+ if (items.find((item) => item.id === id)?.disabled === true) {
401
+ return;
402
+ }
363
403
  if (multiple) {
364
404
  value = value.includes(id) ? value.filter((v) => v !== id) : [...value, id];
365
405
  } else {
@@ -426,6 +466,40 @@
426
466
  }
427
467
  }
428
468
 
469
+ // The select-all row is always selectable; only an item row can opt out via
470
+ // SelectItem.disabled. There is no group-header row kind to skip.
471
+ function isRowSelectable(row: SelectRow): boolean {
472
+ return row.kind === 'select-all' || row.item.disabled !== true;
473
+ }
474
+
475
+ function hasModifierKey(event: KeyboardEvent): boolean {
476
+ return event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
477
+ }
478
+
479
+ /**
480
+ * Home/End: jump the highlight to the first/last selectable row, skipping
481
+ * disabled options the same way `moveHighlight` skips nothing -- it just
482
+ * targets a different index and reuses the same scroll-into-view step. A
483
+ * no-op when no row in that direction is selectable, including an empty list.
484
+ */
485
+ async function moveHighlightToEdge(edge: 'first' | 'last'): Promise<void> {
486
+ const target =
487
+ edge === 'first'
488
+ ? optionRows.findIndex(isRowSelectable)
489
+ : optionRows.findLastIndex(isRowSelectable);
490
+ if (target < 0) {
491
+ return;
492
+ }
493
+ highlightedIndex = target;
494
+ await tick();
495
+ if (dropdownEl !== null) {
496
+ const el = dropdownEl.querySelector('.select-option.highlighted');
497
+ if (el instanceof HTMLElement) {
498
+ el.scrollIntoView({ block: 'nearest' });
499
+ }
500
+ }
501
+ }
502
+
429
503
  function handleTriggerClick(event: MouseEvent): void {
430
504
  if (event.target instanceof HTMLInputElement) {
431
505
  if (!open) {
@@ -500,12 +574,18 @@
500
574
  event.preventDefault();
501
575
  moveHighlight(-1);
502
576
  break;
503
- case 'Escape':
504
- if (open) {
505
- close();
506
- if (!searchInTrigger && triggerEl !== null) {
507
- triggerEl.focus();
508
- }
577
+ case 'Home':
578
+ // A caret-bearing text filter input owns Home/End for itself; only the
579
+ // dropdown's own chrome (or a non-searchable trigger) hands them to us.
580
+ if (open && !(event.target instanceof HTMLInputElement) && !hasModifierKey(event)) {
581
+ event.preventDefault();
582
+ moveHighlightToEdge('first');
583
+ }
584
+ break;
585
+ case 'End':
586
+ if (open && !(event.target instanceof HTMLInputElement) && !hasModifierKey(event)) {
587
+ event.preventDefault();
588
+ moveHighlightToEdge('last');
509
589
  }
510
590
  break;
511
591
  case 'Backspace':
@@ -525,7 +605,7 @@
525
605
  close();
526
606
  break;
527
607
  }
528
- const active = document.activeElement;
608
+ const active = getActiveElement(triggerEl);
529
609
  if (event.shiftKey) {
530
610
  /* Backwards out of the trigger genuinely leaves the widget; every
531
611
  other Shift+Tab walks back through the menu and must not close
@@ -596,24 +676,36 @@
596
676
  }
597
677
  }
598
678
 
599
- function handleClickOutside(event: Event): void {
600
- // A portaled dropdown lives outside containerEl, so a click on an option is
601
- // not contained by it — treat the dropdown node as "inside" too, otherwise a
602
- // multi-select would close on every pick.
603
- if (
604
- event.target instanceof Node &&
605
- containerEl !== null &&
606
- !containerEl.contains(event.target) &&
607
- !(dropdownEl !== null && dropdownEl.contains(event.target))
608
- ) {
609
- close();
610
- }
679
+ // Registered for exactly the dropdown's open span (the `{#if open && !disabled}`
680
+ // block below), same as Menu's dismissalAction, so this dropdown only answers
681
+ // Escape/outside-press while it is the topmost dismissible surface -- a select
682
+ // opened inside a Modal, Sheet, Menu, ContextMenu or CommandMenu no longer closes
683
+ // the layer behind it, and pressing Escape closes only this dropdown rather than
684
+ // being intercepted by an enclosing layer's own Escape handler.
685
+ function dismissalAction(_node: HTMLDivElement) {
686
+ const release = registerDismissible({
687
+ element: () => containerEl,
688
+ // A portaled dropdown lives outside containerEl, so a click on an option is
689
+ // not contained by it — treat the dropdown node as "inside" too, otherwise a
690
+ // multi-select would close on every pick.
691
+ onOutside: (event) => {
692
+ if (eventHitsInside(dropdownEl, event)) {
693
+ return;
694
+ }
695
+ close();
696
+ },
697
+ onEscape: () => {
698
+ close();
699
+ if (!searchInTrigger && triggerEl !== null) {
700
+ triggerEl.focus();
701
+ }
702
+ }
703
+ });
704
+ return { destroy: release };
611
705
  }
612
706
 
613
707
  onMount(() => {
614
- document.addEventListener('click', handleClickOutside);
615
708
  return () => {
616
- document.removeEventListener('click', handleClickOutside);
617
709
  if (open) {
618
710
  onclose?.();
619
711
  }
@@ -650,8 +742,8 @@
650
742
  aria-expanded={open}
651
743
  aria-haspopup="listbox"
652
744
  aria-controls={listboxId}
653
- aria-invalid={error ? 'true' : null}
654
- aria-describedby={hasErrorMessage ? errorMessageId : null}
745
+ aria-invalid={field.ariaInvalid}
746
+ aria-describedby={field.describedBy}
655
747
  {...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
656
748
  tabindex={disabled ? -1 : searchInTrigger ? -1 : 0}
657
749
  >
@@ -678,8 +770,8 @@
678
770
  bind:this={searchInputEl}
679
771
  placeholder={value.length === 0 ? placeholder : ''}
680
772
  {disabled}
681
- aria-invalid={error ? 'true' : null}
682
- aria-describedby={hasErrorMessage ? errorMessageId : null}
773
+ aria-invalid={field.ariaInvalid}
774
+ aria-describedby={field.describedBy}
683
775
  autocomplete="off"
684
776
  tabindex={disabled ? -1 : 0}
685
777
  data-pw={typeof testId === 'string' ? `${testId}-search` : null}
@@ -706,8 +798,8 @@
706
798
  bind:this={searchInputEl}
707
799
  placeholder={value.length === 0 ? placeholder : ''}
708
800
  {disabled}
709
- aria-invalid={error ? 'true' : null}
710
- aria-describedby={hasErrorMessage ? errorMessageId : null}
801
+ aria-invalid={field.ariaInvalid}
802
+ aria-describedby={field.describedBy}
711
803
  autocomplete="off"
712
804
  tabindex={disabled ? -1 : 0}
713
805
  data-pw={typeof testId === 'string' ? `${testId}-search` : null}
@@ -727,8 +819,8 @@
727
819
  bind:this={searchInputEl}
728
820
  placeholder={searchPlaceholder}
729
821
  {disabled}
730
- aria-invalid={error ? 'true' : null}
731
- aria-describedby={hasErrorMessage ? errorMessageId : null}
822
+ aria-invalid={field.ariaInvalid}
823
+ aria-describedby={field.describedBy}
732
824
  autocomplete="off"
733
825
  tabindex={disabled ? -1 : 0}
734
826
  data-pw={typeof testId === 'string' ? `${testId}-search` : null}
@@ -761,9 +853,9 @@
761
853
  {/if}
762
854
  </div>
763
855
 
764
- {#if hasErrorMessage}
856
+ {#if field.showsError}
765
857
  <div
766
- id={errorMessageId}
858
+ id={field.errorId}
767
859
  role="alert"
768
860
  class="select-error-message"
769
861
  data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
@@ -833,9 +925,11 @@
833
925
  class:tickable={showSelectedTick && !multiple}
834
926
  class:selected={value.includes(row.item.id)}
835
927
  class:highlighted={index === highlightedIndex}
928
+ class:select-option-disabled={row.item.disabled === true}
836
929
  role="option"
837
930
  id={`${listboxId}-option-${index}`}
838
931
  aria-selected={value.includes(row.item.id)}
932
+ aria-disabled={row.item.disabled === true ? 'true' : null}
839
933
  tabindex="-1"
840
934
  {...typeof row.item.testId === 'string'
841
935
  ? { 'data-pw': row.item.testId, testID: row.item.testId }
@@ -848,7 +942,11 @@
848
942
  ? { 'data-pw': `${testId}-${row.item.id}`, testID: `${testId}-${row.item.id}` }
849
943
  : {}}
850
944
  onclick={() => selectItem(row.item.id)}
851
- onmouseenter={() => (highlightedIndex = index)}
945
+ onmouseenter={() => {
946
+ if (row.item.disabled !== true) {
947
+ highlightedIndex = index;
948
+ }
949
+ }}
852
950
  >
853
951
  {#if multiple}
854
952
  {#if typeof optionIndicator === 'function'}
@@ -915,7 +1013,8 @@
915
1013
  bind:clientHeight={dropdownHeight}
916
1014
  style={portalStyle}
917
1015
  use:menuKeys
918
- use:portalToBody
1016
+ use:portalToRoot
1017
+ use:dismissalAction
919
1018
  >
920
1019
  <input
921
1020
  class="select-menu-search"
@@ -971,7 +1070,8 @@
971
1070
  id={listboxId}
972
1071
  aria-multiselectable={multiple}
973
1072
  style={portalStyle}
974
- use:portalToBody
1073
+ use:portalToRoot
1074
+ use:dismissalAction
975
1075
  >
976
1076
  {@render menuOptions()}
977
1077
  </div>
@@ -1122,7 +1222,7 @@
1122
1222
 
1123
1223
  .select-placeholder {
1124
1224
  flex: 1;
1125
- color: var(--select-placeholder-color, #999999);
1225
+ color: var(--select-placeholder-color, #666666);
1126
1226
  white-space: nowrap;
1127
1227
  overflow: hidden;
1128
1228
  text-overflow: ellipsis;
@@ -1143,7 +1243,7 @@
1143
1243
  }
1144
1244
 
1145
1245
  .select-search::placeholder {
1146
- color: var(--select-placeholder-color, #999999);
1246
+ color: var(--select-placeholder-color, #6b7280);
1147
1247
  }
1148
1248
 
1149
1249
  .select-arrow {
@@ -1154,7 +1254,8 @@
1154
1254
  height: var(--select-arrow-size, 16px);
1155
1255
  color: var(--select-arrow-color, #666666);
1156
1256
  flex-shrink: 0;
1157
- transition: transform 0.15s;
1257
+ transition: transform var(--select-arrow-transition-duration, var(--motion-duration, 0.15s))
1258
+ var(--select-arrow-transition-easing, var(--motion-easing, ease));
1158
1259
  }
1159
1260
 
1160
1261
  .select.open .select-arrow {
@@ -1262,7 +1363,8 @@
1262
1363
  color: var(--select-option-color, #333333);
1263
1364
  font-size: var(--select-option-font-size, inherit);
1264
1365
  cursor: pointer;
1265
- transition: background 0.1s;
1366
+ transition: background var(--select-option-transition-duration, var(--motion-duration, 0.1s))
1367
+ var(--select-option-transition-easing, var(--motion-easing, ease));
1266
1368
  }
1267
1369
 
1268
1370
  /* Per-option leading icon (SelectItem.icon). Kept inline + vertically centred so
@@ -1294,6 +1396,12 @@
1294
1396
  color: var(--select-option-selected-color, var(--select-option-color, #333333));
1295
1397
  }
1296
1398
 
1399
+ .select-option-disabled {
1400
+ opacity: var(--select-option-disabled-opacity, 0.4);
1401
+ cursor: var(--select-option-disabled-cursor, not-allowed);
1402
+ pointer-events: none;
1403
+ }
1404
+
1297
1405
  .select-option.selected.highlighted {
1298
1406
  background: var(
1299
1407
  --select-option-selected-hover-background,
@@ -1326,8 +1434,11 @@
1326
1434
  background-color: var(--select-option-indicator-background, transparent);
1327
1435
  color: var(--select-option-indicator-color, currentColor);
1328
1436
  transition:
1329
- background-color 0.15s,
1330
- border-color 0.15s;
1437
+ background-color
1438
+ var(--select-option-indicator-transition-duration, var(--motion-duration, 0.15s))
1439
+ var(--select-option-indicator-transition-easing, var(--motion-easing, ease)),
1440
+ border-color var(--select-option-indicator-transition-duration, var(--motion-duration, 0.15s))
1441
+ var(--select-option-indicator-transition-easing, var(--motion-easing, ease));
1331
1442
  }
1332
1443
 
1333
1444
  .select-option-indicator.checked {
@@ -1430,4 +1541,13 @@
1430
1541
  width: 100%;
1431
1542
  height: 100%;
1432
1543
  }
1544
+
1545
+ /* The chevron rotates between closed and open. Small, but it is a transform,
1546
+ and the two end states are each meaningful, so dropping the tween just makes
1547
+ the flip instant. */
1548
+ @media (prefers-reduced-motion: reduce) {
1549
+ .select-arrow {
1550
+ transition: none;
1551
+ }
1552
+ }
1433
1553
  </style>
@@ -20,6 +20,13 @@ export type SelectItem = {
20
20
  * the current selection in the closed trigger.
21
21
  */
22
22
  icon?: string;
23
+ /**
24
+ * When `true`, the option is rendered non-interactive (dimmed, `aria-disabled`)
25
+ * and cannot be chosen by click or Enter/Space. `Home` and `End` skip past
26
+ * disabled options to land on the nearest selectable row at each end of the
27
+ * list — mirroring `ComboboxItem.disabled`.
28
+ */
29
+ disabled?: boolean;
23
30
  };
24
31
  export type MandatorySelectProperties = {
25
32
  items: SelectItem[] | string[];
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { SeparatorProperties } from './properties';
3
+
4
+ let {
5
+ orientation = 'horizontal',
6
+ decorative = true,
7
+ testId,
8
+ classes
9
+ }: SeparatorProperties = $props();
10
+
11
+ const isVertical = $derived(orientation === 'vertical');
12
+ </script>
13
+
14
+ <!--
15
+ Only a *meaningful* separator belongs in the accessibility tree (design
16
+ principle #4: a11y reasoned about per-state, not bolted on). A decorative
17
+ rule between unrelated content has no `role` at all and is `aria-hidden`, so
18
+ it is skipped entirely rather than read as an empty, nameless landmark. A
19
+ non-decorative one takes `role="separator"` -- `aria-orientation` is only
20
+ set for `vertical`, matching Tabs' convention elsewhere in this library,
21
+ since the ARIA default for the separator role is already `horizontal`.
22
+ -->
23
+ <div
24
+ class="separator {classes ?? ''}"
25
+ class:vertical={isVertical}
26
+ data-orientation={orientation}
27
+ data-pw={typeof testId === 'string' ? testId : null}
28
+ testID={typeof testId === 'string' ? testId : null}
29
+ role={decorative ? null : 'separator'}
30
+ aria-orientation={!decorative && isVertical ? 'vertical' : null}
31
+ aria-hidden={decorative ? 'true' : null}
32
+ ></div>
33
+
34
+ <style>
35
+ .separator {
36
+ flex-shrink: 0;
37
+ border: none;
38
+ width: var(--separator-length, 100%);
39
+ height: var(--separator-thickness, 1px);
40
+ background: var(--separator-background, #e0e0e0);
41
+ margin: var(--separator-margin, 0);
42
+ }
43
+
44
+ .separator.vertical {
45
+ width: var(--separator-thickness, 1px);
46
+ height: var(--separator-length, 100%);
47
+ }
48
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const Separator: import("svelte").Component<import("./properties").OptionalSeparatorProperties, {}, "">;
2
+ type Separator = ReturnType<typeof Separator>;
3
+ export default Separator;
@@ -0,0 +1,22 @@
1
+ export type SeparatorOrientation = 'horizontal' | 'vertical';
2
+ export type SeparatorProperties = OptionalSeparatorProperties;
3
+ export type MandatorySeparatorProperties = Record<string, never>;
4
+ export type OptionalSeparatorProperties = {
5
+ /**
6
+ * Axis the dividing line runs across. `'horizontal'` renders a full-width
7
+ * line (for stacking content vertically); `'vertical'` renders a
8
+ * full-height line (for stacking content side by side).
9
+ */
10
+ orientation?: SeparatorOrientation;
11
+ /**
12
+ * A decorative separator (the default) carries no meaning of its own — it's
13
+ * purely a visual rule between unrelated content — so it is hidden from
14
+ * assistive technology (`aria-hidden="true"`) and takes no ARIA role. Set
15
+ * `false` when the separator actually demarcates two semantically distinct
16
+ * sections (e.g. two groups in a menu); it then takes `role="separator"`
17
+ * with a matching `aria-orientation` so the boundary is announced too.
18
+ */
19
+ decorative?: boolean;
20
+ testId?: string;
21
+ classes?: string;
22
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,9 +1,12 @@
1
1
  <script lang="ts">
2
+ import { reducedMotion } from '../reduced-motion.svelte';
2
3
  import type { SheetProperties } from './properties';
3
4
  import { fly, fade } from 'svelte/transition';
4
5
  import { lockBodyScroll, unlockBodyScroll } from '../utils';
5
6
  import { tick } from 'svelte';
6
7
  import Button from '../Button/Button.svelte';
8
+ import { focusTrapTabTarget, getActiveElement } from '../_interaction/focus';
9
+ import { registerDismissible } from '../_interaction/dismissal';
7
10
 
8
11
  let {
9
12
  open = $bindable(false),
@@ -27,6 +30,26 @@
27
30
  let sheetPanel: HTMLDivElement | null = $state(null);
28
31
 
29
32
  let flyParams = $derived.by(() => {
33
+ // Under reduced motion every side behaves as `center` already does. That is
34
+ // not an invented fallback: the `center` case below documents it in the
35
+ // component's own words -- "a centered dialog only fades, so x/y stay at 0
36
+ // and `fly` degrades to exactly the overlay's own fade transition". The
37
+ // panel still appears, it simply stops travelling 400px to get there.
38
+ //
39
+ // A directive's parameters are a JS object, so no @media block can reach
40
+ // them; the guard has to be here.
41
+ //
42
+ // Reading `reducedMotion.current` (real `$state`) rather than calling
43
+ // `prefersReducedMotion()` (a plain, un-reactive read) is what keeps this
44
+ // derived subscribed even on this early-return branch, where `side` below
45
+ // is never reached. Sheet's demo keeps one instance mounted across many
46
+ // open/close cycles (`{#if open}` lives inside Sheet, not around it), so a
47
+ // preference the user toggles mid-session has to reach an already-mounted
48
+ // panel -- a plain read here would latch whatever it saw on first
49
+ // evaluation and never revisit it.
50
+ if (reducedMotion.current) {
51
+ return { x: 0, y: 0, duration: 300 };
52
+ }
30
53
  switch (side) {
31
54
  case 'left':
32
55
  return { x: -400, y: 0, duration: 300 };
@@ -57,12 +80,14 @@
57
80
  }
58
81
  }
59
82
 
83
+ // Escape moved out to the shared dismissal module (see registerDismissible
84
+ // in scrollLockAction below) so only the topmost dismissible layer -- this
85
+ // sheet, or a Menu/CommandMenu opened on top of it -- answers Escape,
86
+ // instead of every open surface's own keydown handler reacting to the same
87
+ // press. Tab and Enter/Space stay here: neither is a cross-component
88
+ // ownership question, just this panel's own trap and its overlay's
89
+ // click-equivalent activation.
60
90
  function handleKeyDown(event: KeyboardEvent) {
61
- if (event.key === 'Escape') {
62
- close();
63
- return;
64
- }
65
-
66
91
  // The overlay only carries role="button" while it is dismissible, and a
67
92
  // role="button" has to answer Enter/Space as well as click (WCAG 2.1
68
93
  // SC 2.1.1) -- tabindex="-1" keeps it out of the Tab sequence, but the
@@ -80,29 +105,37 @@
80
105
  return;
81
106
  }
82
107
 
83
- if (event.key === 'Tab' && sheetPanel !== null) {
84
- const focusable = sheetPanel.querySelectorAll<HTMLElement>(
85
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
86
- );
87
- const first = focusable.item(0);
88
- const last = focusable.item(focusable.length - 1);
89
-
90
- if (first === null || last === null) {
91
- return;
92
- }
93
-
94
- const atEdge = document.activeElement === (event.shiftKey ? first : last);
95
- if (atEdge) {
108
+ if (event.key === 'Tab') {
109
+ const target = focusTrapTabTarget({
110
+ container: sheetPanel,
111
+ activeElement: getActiveElement(sheetPanel),
112
+ shiftKey: event.shiftKey
113
+ });
114
+ if (target !== null) {
96
115
  event.preventDefault();
97
- (event.shiftKey ? last : first).focus();
116
+ target.focus();
98
117
  }
99
118
  }
100
119
  }
101
120
 
102
121
  function scrollLockAction(node: HTMLElement) {
103
- const previousFocus = document.activeElement;
122
+ // getActiveElement rather than document.activeElement: when the sheet is
123
+ // used through its custom element the opener lives inside a shadow root,
124
+ // where document.activeElement reports only the host and the real opener
125
+ // could never be handed focus back.
126
+ const previousFocus = getActiveElement(node);
104
127
  let active = true;
105
128
  lockBodyScroll();
129
+ // Registered here rather than in an onMount/onDestroy pair: this action's
130
+ // mount/destroy already brackets exactly the open span (the overlay only
131
+ // exists while `open` is true, per the `{#if open}` below), the same
132
+ // span the scroll lock above uses. `element` reads `sheetPanel` lazily --
133
+ // it is not bound yet at this point in a fresh open, same reason
134
+ // sheetPanel.focus() below is deferred through tick().
135
+ const releaseDismissible = registerDismissible({
136
+ element: () => sheetPanel,
137
+ onEscape: close
138
+ });
106
139
  tick().then(() => {
107
140
  if (active && sheetPanel !== null) {
108
141
  sheetPanel.focus();
@@ -111,11 +144,17 @@
111
144
  return {
112
145
  destroy() {
113
146
  active = false;
147
+ releaseDismissible();
114
148
  unlockBodyScroll();
149
+ // Only take focus back when it is still somewhere the sheet is
150
+ // responsible for -- inside the panel, or nowhere at all. If the user
151
+ // has already moved on to another control, stealing it back would be
152
+ // the more disruptive bug.
153
+ const current = getActiveElement(node);
115
154
  if (
116
155
  previousFocus instanceof HTMLElement &&
117
156
  previousFocus.isConnected &&
118
- (document.activeElement === document.body || node.contains(document.activeElement))
157
+ (current === null || current === document.body || node.contains(current))
119
158
  ) {
120
159
  previousFocus.focus();
121
160
  }
@@ -45,4 +45,19 @@
45
45
  transform: translateX(100%);
46
46
  }
47
47
  }
48
+ /* An indefinite animation is the case this preference exists for: it never
49
+ ends, so a user who asked the OS to minimise motion gets a permanent loop.
50
+ The animation stops, but the element must still read as "busy" -- a guard
51
+ that leaves nothing on screen, or leaves a frame that means something else,
52
+ is worse than the motion it removed. This block lives in the component's own
53
+ <style> because that is the only stylesheet that reaches inside the shadow
54
+ root a custom-element consumer gets. */
55
+ @media (prefers-reduced-motion: reduce) {
56
+ .shimmer::after {
57
+ /* The sweep is the only animated part; the base block underneath is the
58
+ skeleton. Removing the sweep leaves exactly that -- a flat placeholder
59
+ -- rather than a highlight band frozen somewhere across it. */
60
+ display: none;
61
+ }
62
+ }
48
63
  </style>