@juspay/svelte-ui-components 4.27.5 → 4.28.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.
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
@@ -2,6 +2,21 @@ import type { JSONValue } from 'type-decoder';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { TooltipPosition } from '../Tooltip/properties';
4
4
  export type SortDirection = 'asc' | 'desc';
5
+ /**
6
+ * Which column a table is sorted by, addressed by column ID rather than by
7
+ * column index.
8
+ *
9
+ * `columnId` is a `TableColumn.id` in the keyed model, and the column's index
10
+ * rendered as a string in the positional model (`tableHeaders`/`tableData`),
11
+ * which has no other identity to offer. The distinction is the whole point of
12
+ * the type: an index follows a POSITION, so reordering or hiding a column
13
+ * silently re-points a stored sort at whichever field moved into that slot,
14
+ * while an ID follows the FIELD and a hidden field simply stops being sorted.
15
+ */
16
+ export type TableSortState = {
17
+ columnId: string;
18
+ direction: SortDirection;
19
+ };
5
20
  /**
6
21
  * Built-in cell renderer vocabulary for the keyed column model.
7
22
  *
@@ -358,9 +373,29 @@ export type TableCheckboxSelectionConfig = {
358
373
  */
359
374
  export type TablePaginationConfig = {
360
375
  mode?: 'client' | 'server';
361
- /** 1-indexed current page. Server mode: controlled by the consumer. */
376
+ /**
377
+ * 1-indexed current page — never 0-indexed, so an adapter over a
378
+ * zero-based engine (TanStack's `pageIndex`) converts once, here, and not
379
+ * again inside its own page handler.
380
+ *
381
+ * Server mode: authoritative. Table renders this page and `onPageChange` is
382
+ * a request the consumer may honor, delay, or refuse. Client mode: the
383
+ * starting page — Table then owns paging, and clamps the page it renders to
384
+ * the last page that actually has rows, so a dataset that shrinks from
385
+ * outside cannot leave the reader on a blank page past the end.
386
+ */
362
387
  page?: number;
363
- /** Rows per page. Default 10. */
388
+ /**
389
+ * Rows per page. Default 10.
390
+ *
391
+ * Server mode: declaring it makes it authoritative, the same way
392
+ * `checkboxSelection.selectedIds` takes ownership of selection —
393
+ * `onPageSizeChange` becomes a request the consumer answers by changing
394
+ * this value, and refusing leaves both the rows and the chrome at the size
395
+ * the server actually served. Omit it in server mode to keep the older
396
+ * behavior where the page-size selector updates the chrome on its own.
397
+ * Client mode: the starting size, which the selector then owns.
398
+ */
364
399
  pageSize?: number;
365
400
  /** Page-size selector options. Default [10, 25, 50, 100]; `[]` hides the selector. */
366
401
  pageSizeOptions?: number[];
@@ -432,6 +467,38 @@ export type TableSearchConfig = {
432
467
  searchableColumnIndices?: number[];
433
468
  testId?: string;
434
469
  displayMode?: 'toolbar' | 'inline';
470
+ /**
471
+ * Controlled search term. Omitted (every pre-existing consumer): the input
472
+ * owns its term through an internal state, unchanged. Provided: the input
473
+ * renders FROM this string and never mutates it — `onSearchTermChange`
474
+ * reports the would-be next term and the consumer decides, exactly as
475
+ * `checkboxSelection.selectedIds` does for selection.
476
+ *
477
+ * Independent of `onsearchchange`: that prop delegates FILTERING to the
478
+ * consumer, this one only delegates the visible term, so a controlled term
479
+ * still filters client-side unless `onsearchchange` is also supplied.
480
+ */
481
+ searchTerm?: string;
482
+ /**
483
+ * Reports every would-be search term, in controlled and uncontrolled mode
484
+ * alike. A controlled consumer feeds it back through `searchTerm`; an
485
+ * uncontrolled one may use it purely as a notification.
486
+ */
487
+ onSearchTermChange?: (searchTerm: string) => void;
488
+ };
489
+ /**
490
+ * Why the table is showing nothing, handed to the `empty` snippet.
491
+ *
492
+ * "No records yet" and "nothing matched your search" are different messages
493
+ * with different remedies, and only Table can tell them apart once the built-in
494
+ * search owns the term: a consumer reading `rows.length === 0` sees the same
495
+ * zero either way. `searchTerm` is passed alongside so the message can quote
496
+ * what was searched for.
497
+ */
498
+ export type TableEmptyContext = {
499
+ reason: 'no-rows' | 'no-matches';
500
+ /** The term currently filtering the view; `''` when none is active. */
501
+ searchTerm: string;
435
502
  };
436
503
  export type TableProperties = OptionalTableProperties & TableEventProperties;
437
504
  /**
@@ -487,6 +554,26 @@ export type OptionalTableProperties = {
487
554
  * (e.g. via a server query).
488
555
  */
489
556
  sortMode?: 'client' | 'server';
557
+ /**
558
+ * Controlled sort overlay, keyed by column ID (see `TableSortState`).
559
+ *
560
+ * Omitted (every pre-existing consumer): today's uncontrolled behavior —
561
+ * Table owns the sort state and a header click re-sorts it directly.
562
+ * Provided: the header indicators, `aria-sort`, and the client-side reorder
563
+ * all render FROM this value and Table never mutates it; a header click
564
+ * reports the would-be next state through `onsortchange` (and the
565
+ * unchanged `onsort`) and the consumer decides. This is what lets a sort
566
+ * held in a URL or on a server be restored into the built-in header.
567
+ *
568
+ * `null` is a controlled value meaning "no column is sorted" — pass it to
569
+ * clear a sort without handing sort ownership back to the table. A
570
+ * `columnId` that no currently rendered column carries (a hidden column)
571
+ * sorts nothing, rather than falling through to a neighboring field.
572
+ *
573
+ * Independent of `sortMode`: `'server'` still means Table does not reorder
574
+ * rows locally, controlled or not.
575
+ */
576
+ sortState?: TableSortState | null;
490
577
  /** Built-in paginator (see TablePaginationConfig). */
491
578
  pagination?: TablePaginationConfig;
492
579
  /**
@@ -526,6 +613,32 @@ export type OptionalTableProperties = {
526
613
  * edge. Defaults to `false` (in-flow rendering, unchanged).
527
614
  */
528
615
  usePortal?: boolean;
616
+ /**
617
+ * Opt-in stacked "record card" layout below a 640px viewport, in place
618
+ * of the default horizontal scroll. Off by default — flipping every existing
619
+ * consumer's table to a different layout at some width they never chose
620
+ * would be a breaking change to a published library, not a bug fix.
621
+ *
622
+ * CSS alone cannot build this for a Web Component consumer: the classic
623
+ * `td::before { content: attr(data-label) }` recipe needs a rule INSIDE the
624
+ * shadow tree, which a `<sui-table>` consumer's stylesheet can never reach.
625
+ * So Table carries the column→label mapping itself and renders it as markup
626
+ * (a real, `aria-hidden` element, not generated content — see the mobile
627
+ * label span in the template), which is compiled into the shadow root
628
+ * exactly like the rest of this file's styles and works identically through
629
+ * `<sui-table>` and the Svelte component.
630
+ *
631
+ * Stacking cells vertically means their CSS `display` can no longer be
632
+ * `table-cell` — which is also what silently drops the browser's implicit
633
+ * table/row/cell accessibility roles in several browser/AT combinations.
634
+ * This prop therefore also stamps explicit `role="table"/"rowgroup"/"row"/
635
+ * "columnheader"/"cell"` back onto the native elements (harmless and
636
+ * redundant at desktop width, load-bearing at the mobile breakpoint) — the
637
+ * documented fix for "CSS reformats a table but the AX tree must not lose
638
+ * it". See the `.table-mobile-cards` block in `<style>` for the layout and
639
+ * the accessibility decision recorded there for the visible-label choice.
640
+ */
641
+ mobileCardLayout?: boolean;
529
642
  sortable?: boolean;
530
643
  sortableColumns?: number[];
531
644
  stickyHeader?: boolean;
@@ -537,7 +650,14 @@ export type OptionalTableProperties = {
537
650
  sortDescIcon?: Snippet;
538
651
  sortDefaultIcon?: Snippet;
539
652
  cell?: Snippet<[JSONValue, number, number]>;
540
- empty?: Snippet;
653
+ /**
654
+ * Rendered in place of the rows when the view has none. Receives why
655
+ * (`TableEmptyContext`) so one snippet can serve both "no records yet" and
656
+ * "nothing matched", which are different messages with different remedies.
657
+ * Declaring it without parameters stays valid — every existing consumer is
658
+ * unaffected.
659
+ */
660
+ empty?: Snippet<[TableEmptyContext]>;
541
661
  classes?: string;
542
662
  /** Snippet rendered in a footer region below the table (e.g. a paginator). */
543
663
  paginatorSlot?: Snippet;
@@ -560,6 +680,17 @@ export type OptionalTableProperties = {
560
680
  export type TableEventProperties = {
561
681
  onrowclick?: (rowIndex: number, rowData: JSONValue[], originalIndex: number) => void;
562
682
  onsort?: (columnIndex: number, direction: SortDirection) => void;
683
+ /**
684
+ * Fires with every `onsort`, carrying the same change keyed by column ID —
685
+ * the input half of the controlled contract (`sortState`) speaks IDs, so the
686
+ * report does too, and a consumer never has to resolve an index against a
687
+ * column list that may since have changed.
688
+ *
689
+ * Only ever reports a sorted state: a header click cycles ascending →
690
+ * descending → ascending, exactly as before. Clearing a sort is the
691
+ * consumer's own act of setting `sortState` to `null`.
692
+ */
693
+ onsortchange?: (sortState: TableSortState) => void;
563
694
  /**
564
695
  * C2-3: When provided, the built-in client-side filtering is disabled and
565
696
  * this callback is called on every search input change instead, letting the
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- import { tick } from 'svelte';
2
+ import { prefersReducedMotion } from '../utils';
3
+ import { getActiveElement } from '../_interaction/focus';
3
4
  import type { TabItem, TabsProperties } from './properties';
4
5
  import Img from '../Img/Img.svelte';
5
6
  import chevronLeftSvg from '../assets/chevron-left.svg?raw';
@@ -13,6 +14,9 @@
13
14
  activeKey,
14
15
  disabled = false,
15
16
  orientation = 'horizontal',
17
+ activationMode = 'automatic',
18
+ loop = true,
19
+ dir,
16
20
  testId,
17
21
  scrollLeftIcon,
18
22
  scrollRightIcon,
@@ -34,19 +38,66 @@
34
38
  return typeof item === 'string' ? item : item.label;
35
39
  }
36
40
 
37
- const resolvedActiveIndex = $derived(
38
- isObjectMode
39
- ? items.findIndex((item) => {
40
- const tabItem = toTabItem(item);
41
- return tabItem !== null && tabItem.key === activeKey;
42
- })
43
- : activeIndex
44
- );
41
+ // What "the focused tab" means across a re-render. A keyed item keeps its identity
42
+ // when the list is reordered or trimmed; a plain string list has nothing stabler
43
+ // than its position.
44
+ function identityOf(item: string | TabItem, index: number): string {
45
+ return typeof item === 'object' ? item.key : String(index);
46
+ }
47
+
48
+ function isItemDisabled(index: number): boolean {
49
+ if (disabled) {
50
+ return true;
51
+ }
52
+ const item = items.at(index);
53
+ return typeof item === 'object' && item.disabled === true;
54
+ }
55
+
56
+ // -1 whenever nothing is selected: an out-of-range or non-integer `activeIndex`, an
57
+ // `activeKey` matching no item, or object items with no `activeKey` at all. The list
58
+ // then shows no selection rather than inventing one, and still offers a way in.
59
+ const selectedIndex = $derived.by(() => {
60
+ if (isObjectMode) {
61
+ return typeof activeKey === 'string'
62
+ ? items.findIndex((item) => toTabItem(item)?.key === activeKey)
63
+ : -1;
64
+ }
65
+ return Number.isInteger(activeIndex) && activeIndex >= 0 && activeIndex < items.length
66
+ ? activeIndex
67
+ : -1;
68
+ });
45
69
 
46
70
  function isActiveItem(index: number): boolean {
47
- return index === resolvedActiveIndex;
71
+ return index === selectedIndex;
48
72
  }
49
73
 
74
+ let focusedKey: string | null = $state(null);
75
+
76
+ const focusedIndex = $derived(
77
+ focusedKey === null
78
+ ? -1
79
+ : items.findIndex((item, index) => identityOf(item, index) === focusedKey)
80
+ );
81
+
82
+ const firstEnabledIndex = $derived(items.findIndex((_, index) => !isItemDisabled(index)));
83
+
84
+ // A tablist is one tab stop. It is the focused tab while the user is inside the
85
+ // list, the selected tab otherwise, and the first enabled tab when nothing is
86
+ // selected — so a list with no selection, or one whose selection is disabled, can
87
+ // still be reached by Tab. A fully disabled list has no tab stop at all.
88
+ const tabStopIndex = $derived.by(() => {
89
+ if (disabled) {
90
+ return -1;
91
+ }
92
+ if (focusedIndex >= 0 && !isItemDisabled(focusedIndex)) {
93
+ return focusedIndex;
94
+ }
95
+ if (selectedIndex >= 0 && !isItemDisabled(selectedIndex)) {
96
+ return selectedIndex;
97
+ }
98
+ return firstEnabledIndex;
99
+ });
100
+
50
101
  let scrollContainer: HTMLDivElement | null = null;
51
102
  let canScrollLeft = $state(false);
52
103
  let canScrollRight = $state(false);
@@ -63,6 +114,34 @@
63
114
  const showStartArrow = $derived(isVertical ? canScrollUp : canScrollLeft);
64
115
  const showEndArrow = $derived(isVertical ? canScrollDown : canScrollRight);
65
116
 
117
+ const explicitDir: 'ltr' | 'rtl' | null = $derived(dir === 'rtl' || dir === 'ltr' ? dir : null);
118
+
119
+ function tabElements(): HTMLElement[] {
120
+ return scrollContainer === null
121
+ ? []
122
+ : Array.from(scrollContainer.querySelectorAll<HTMLElement>('[role="tab"]'));
123
+ }
124
+
125
+ // Read at the moment a key is pressed rather than tracked as state: the direction
126
+ // that matters is the one in effect now, and it can come from an ancestor's `dir`
127
+ // attribute or from CSS, neither of which is reactive.
128
+ function isRightToLeft(): boolean {
129
+ if (explicitDir !== null) {
130
+ return explicitDir === 'rtl';
131
+ }
132
+ if (scrollContainer === null) {
133
+ return false;
134
+ }
135
+ const declared = scrollContainer.closest('[dir]');
136
+ if (declared !== null) {
137
+ const value = declared.getAttribute('dir');
138
+ if (value === 'rtl' || value === 'ltr') {
139
+ return value === 'rtl';
140
+ }
141
+ }
142
+ return getComputedStyle(scrollContainer).direction === 'rtl';
143
+ }
144
+
66
145
  function updateOverflow(): void {
67
146
  if (scrollContainer === null) {
68
147
  return;
@@ -105,109 +184,215 @@
105
184
  if (scrollContainer === null) {
106
185
  return;
107
186
  }
187
+ // This file already had a reduced-motion @media block, and it was counted as
188
+ // guarded on that alone -- but the block covers the indicator's CSS transition
189
+ // and cannot touch a scrollBy `behavior`, which is not a style. Right row, wrong
190
+ // reason: the JS scroll went on animating under the preference.
191
+ const behavior: ScrollBehavior = prefersReducedMotion() ? 'auto' : 'smooth';
108
192
  if (isVertical) {
109
193
  const amount = scrollContainer.clientHeight * 0.6;
110
194
  scrollContainer.scrollBy({
111
195
  top: edge === 'start' ? -amount : amount,
112
- behavior: 'smooth'
196
+ behavior
113
197
  });
114
198
  return;
115
199
  }
116
200
  const amount = scrollContainer.clientWidth * 0.6;
117
201
  scrollContainer.scrollBy({
118
202
  left: edge === 'start' ? -amount : amount,
119
- behavior: 'smooth'
203
+ behavior
120
204
  });
121
205
  }
122
206
 
123
- function handleTabClick(index: number): void {
124
- if (disabled) {
207
+ function activate(index: number): void {
208
+ if (isItemDisabled(index)) {
125
209
  return;
126
210
  }
211
+ const rawItem = items.at(index);
127
212
  if (isObjectMode) {
128
- const rawItem = items.at(index);
129
213
  if (typeof rawItem !== 'object') {
130
214
  return;
131
215
  }
132
- const tabItem = toTabItem(rawItem);
133
- if (tabItem === null) {
216
+ if (rawItem.key === activeKey) {
134
217
  return;
135
218
  }
136
- if (tabItem.key === activeKey) {
137
- return;
138
- }
139
- onkeychange?.(tabItem.key);
140
- } else {
141
- if (index === activeIndex) {
142
- return;
219
+ onkeychange?.(rawItem.key);
220
+ return;
221
+ }
222
+ if (typeof rawItem !== 'string' || index === activeIndex) {
223
+ return;
224
+ }
225
+ activeIndex = index;
226
+ onchange?.(index, rawItem);
227
+ }
228
+
229
+ function handleTabClick(index: number): void {
230
+ activate(index);
231
+ }
232
+
233
+ function focusTabAt(index: number): void {
234
+ const item = items.at(index);
235
+ if (typeof item !== 'string' && typeof item !== 'object') {
236
+ return;
237
+ }
238
+ focusedKey = identityOf(item, index);
239
+ tabElements().at(index)?.focus();
240
+ }
241
+
242
+ /** Next enabled item in `step`'s direction, or -1 when there is none to move to. */
243
+ function nextEnabledIndex(from: number, step: number): number {
244
+ const count = items.length;
245
+ if (count === 0) {
246
+ return -1;
247
+ }
248
+ let index = from;
249
+ for (let moved = 0; moved < count; moved += 1) {
250
+ index += step;
251
+ if (index < 0 || index >= count) {
252
+ if (!loop) {
253
+ return -1;
254
+ }
255
+ index = ((index % count) + count) % count;
143
256
  }
144
- const rawLabel = items.at(index);
145
- if (typeof rawLabel !== 'string') {
146
- return;
257
+ if (!isItemDisabled(index)) {
258
+ return index;
147
259
  }
148
- activeIndex = index;
149
- onchange?.(index, rawLabel);
150
260
  }
261
+ return -1;
151
262
  }
152
263
 
153
- // After a keyboard-driven selection the active item changes (directly in
154
- // string mode; via the consumer's activeKey update in object mode), and the
155
- // roving tabindex re-roves with it. Move DOM focus onto the newly active tab
156
- // so the user can keep arrowing — without this, focus is stranded on a
157
- // tabindex="-1" item and the roving pattern breaks down.
158
- function moveFocusToActiveTab(): void {
159
- void tick().then(() => {
160
- const activeTab = scrollContainer?.querySelector<HTMLElement>('[role="tab"][tabindex="0"]');
161
- activeTab?.focus();
162
- });
264
+ function edgeIndex(edge: 'start' | 'end'): number {
265
+ return edge === 'start'
266
+ ? items.findIndex((_, index) => !isItemDisabled(index))
267
+ : items.reduce((found: number, _, index) => (isItemDisabled(index) ? found : index), -1);
163
268
  }
164
269
 
165
- // WAI-ARIA APG tablist keyboard contract with activation-follows-focus:
166
- // Arrow keys (orientation-aware) move selection to the adjacent tab with
167
- // wrap-around, Home/End jump to the first/last tab. Without this, the roving
168
- // tabindex makes every non-active tab keyboard-unreachable.
270
+ // WAI-ARIA APG tablist keyboard contract: orientation-aware arrow keys move through
271
+ // the enabled tabs, Home/End jump to the ends, and Enter/Space select. Whether
272
+ // moving also selects is `activationMode`'s decision.
169
273
  function handleKeydown(event: KeyboardEvent, index: number): void {
274
+ // A tablist owns bare arrow keys, not the browser's and the OS's shortcuts built
275
+ // on them. Consuming Ctrl+Home or Shift+ArrowRight would take away scroll-to-top
276
+ // and text selection while the tablist happens to hold focus.
277
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
278
+ return;
279
+ }
280
+ if (disabled) {
281
+ return;
282
+ }
170
283
  if (event.key === 'Enter' || event.key === ' ') {
171
284
  event.preventDefault();
172
- handleTabClick(index);
285
+ activate(index);
173
286
  return;
174
287
  }
175
- const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
176
- const previousKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
177
- let targetIndex: number | null = null;
178
- if (event.key === nextKey) {
179
- targetIndex = (index + 1) % items.length;
180
- } else if (event.key === previousKey) {
181
- targetIndex = (index - 1 + items.length) % items.length;
288
+ const rightToLeft = !isVertical && isRightToLeft();
289
+ const forwardKey = isVertical ? 'ArrowDown' : rightToLeft ? 'ArrowLeft' : 'ArrowRight';
290
+ const backwardKey = isVertical ? 'ArrowUp' : rightToLeft ? 'ArrowRight' : 'ArrowLeft';
291
+ let targetIndex: number;
292
+ if (event.key === forwardKey) {
293
+ targetIndex = nextEnabledIndex(index, 1);
294
+ } else if (event.key === backwardKey) {
295
+ targetIndex = nextEnabledIndex(index, -1);
182
296
  } else if (event.key === 'Home') {
183
- targetIndex = 0;
297
+ targetIndex = edgeIndex('start');
184
298
  } else if (event.key === 'End') {
185
- targetIndex = items.length - 1;
186
- }
187
- if (targetIndex === null) {
299
+ targetIndex = edgeIndex('end');
300
+ } else {
188
301
  return;
189
302
  }
190
303
  event.preventDefault();
191
- if (disabled || targetIndex === index) {
304
+ if (targetIndex < 0 || targetIndex === index) {
305
+ return;
306
+ }
307
+ focusTabAt(targetIndex);
308
+ if (activationMode === 'automatic') {
309
+ activate(targetIndex);
310
+ }
311
+ }
312
+
313
+ function handleFocusIn(event: FocusEvent): void {
314
+ const target = event.target;
315
+ if (!(target instanceof HTMLElement)) {
316
+ return;
317
+ }
318
+ const index = tabElements().indexOf(target);
319
+ const item = items.at(index);
320
+ if (index < 0 || (typeof item !== 'string' && typeof item !== 'object')) {
321
+ return;
322
+ }
323
+ focusedKey = identityOf(item, index);
324
+ }
325
+
326
+ function handleFocusOut(event: FocusEvent): void {
327
+ const next = event.relatedTarget;
328
+ if (next instanceof HTMLElement && tabElements().includes(next)) {
192
329
  return;
193
330
  }
194
- handleTabClick(targetIndex);
195
- moveFocusToActiveTab();
331
+ focusedKey = null;
332
+ }
333
+
334
+ /** Identity of the selected item, for noticing that the selection itself changed. */
335
+ let lastSelectionKey: string | null = null;
336
+
337
+ // Keeps DOM focus and the roving tab stop honest after the list changes underneath
338
+ // them: the selection moved, the focused item was removed, reordered, or disabled.
339
+ // Runs from the same MutationObserver that already tracks overflow, so nothing here
340
+ // needs an effect. It never pulls focus into a list the user is not already in.
341
+ function reconcileFocus(): void {
342
+ const elements = tabElements();
343
+ const active = getActiveElement(scrollContainer);
344
+ const isInside = active instanceof HTMLElement && elements.includes(active);
345
+ const selectedItem = items.at(selectedIndex);
346
+ const selectionKey =
347
+ selectedIndex >= 0 && (typeof selectedItem === 'string' || typeof selectedItem === 'object')
348
+ ? identityOf(selectedItem, selectedIndex)
349
+ : null;
350
+ const selectionChanged = selectionKey !== lastSelectionKey;
351
+ lastSelectionKey = selectionKey;
352
+ if (tabStopIndex < 0) {
353
+ if (isInside && active instanceof HTMLElement) {
354
+ active.blur();
355
+ }
356
+ focusedKey = null;
357
+ return;
358
+ }
359
+ if (!isInside && focusedKey === null) {
360
+ return;
361
+ }
362
+ // Focus follows a selection made elsewhere, so a parent switching tabs does not
363
+ // strand the user on a tab that is no longer the selected one.
364
+ const targetIndex =
365
+ selectionChanged && selectedIndex >= 0 && !isItemDisabled(selectedIndex)
366
+ ? selectedIndex
367
+ : tabStopIndex;
368
+ const target = elements.at(targetIndex);
369
+ if (!(target instanceof HTMLElement)) {
370
+ return;
371
+ }
372
+ const item = items.at(targetIndex);
373
+ if (typeof item === 'string' || typeof item === 'object') {
374
+ focusedKey = identityOf(item, targetIndex);
375
+ }
376
+ if (getActiveElement(target) !== target) {
377
+ target.focus();
378
+ }
196
379
  }
197
380
 
198
381
  function initOverflow(node: HTMLDivElement): () => void {
199
382
  scrollContainer = node;
200
383
  updateOverflow();
201
384
  updateIndicator();
385
+ reconcileFocus();
202
386
  const observer = new MutationObserver(() => {
203
387
  updateOverflow();
204
388
  updateIndicator();
389
+ reconcileFocus();
205
390
  });
206
391
  observer.observe(node, {
207
392
  childList: true,
208
393
  subtree: true,
209
394
  attributes: true,
210
- attributeFilter: ['class']
395
+ attributeFilter: ['class', 'tabindex', 'aria-selected', 'aria-disabled']
211
396
  });
212
397
  const resizeObserver = new ResizeObserver(() => {
213
398
  updateOverflow();
@@ -226,7 +411,14 @@
226
411
  );
227
412
  </script>
228
413
 
229
- <div class={rootClass} class:disabled class:vertical={isVertical} data-pw={testId} testID={testId}>
414
+ <div
415
+ class={rootClass}
416
+ class:disabled
417
+ class:vertical={isVertical}
418
+ {...explicitDir === null ? {} : { dir: explicitDir }}
419
+ data-pw={testId}
420
+ testID={testId}
421
+ >
230
422
  {#if showStartArrow}
231
423
  <button
232
424
  class="tabs-arrow tabs-arrow-start"
@@ -249,8 +441,11 @@
249
441
  class:fade-bottom={canScrollDown}
250
442
  role="tablist"
251
443
  aria-orientation={isVertical ? 'vertical' : null}
444
+ data-orientation={orientation}
252
445
  {@attach initOverflow}
253
446
  onscroll={updateOverflow}
447
+ onfocusin={handleFocusIn}
448
+ onfocusout={handleFocusOut}
254
449
  >
255
450
  {#each items as item, index (isObjectMode ? (toTabItem(item)?.key ?? index) : index)}
256
451
  {@const tabItem = toTabItem(item)}
@@ -263,8 +458,11 @@
263
458
  class:active={isActiveItem(index)}
264
459
  role="tab"
265
460
  aria-selected={isActiveItem(index)}
266
- aria-disabled={disabled ? true : null}
267
- tabindex={isActiveItem(index) ? 0 : -1}
461
+ aria-disabled={isItemDisabled(index) ? 'true' : null}
462
+ tabindex={index === tabStopIndex ? 0 : -1}
463
+ data-state={isActiveItem(index) ? 'active' : 'inactive'}
464
+ data-disabled={isItemDisabled(index) ? '' : null}
465
+ data-orientation={orientation}
268
466
  data-pw={tabItem?.testId}
269
467
  testID={tabItem?.testId}
270
468
  onclick={() => handleTabClick(index)}
@@ -412,7 +610,7 @@
412
610
  }
413
611
 
414
612
  .tabs-arrow:hover {
415
- color: var(--tabs-arrow-hover-color, var(--tabs-active-color, #1a73e8));
613
+ color: var(--tabs-arrow-hover-color, var(--tabs-active-color, #0b57d0));
416
614
  background: var(
417
615
  --tabs-arrow-hover-background,
418
616
  var(--tabs-arrow-background, var(--tabs-bar-background, #ffffff))
@@ -507,7 +705,7 @@
507
705
  }
508
706
 
509
707
  .tabs-item.active {
510
- color: var(--tabs-active-color, #1a73e8);
708
+ color: var(--tabs-active-color, #0b57d0);
511
709
  font-weight: var(--tabs-active-font-weight, 600);
512
710
  background: var(--tabs-active-background, transparent);
513
711
  }
@@ -608,7 +806,7 @@
608
806
  font-weight: var(--tabs-section-label-font-weight, 700);
609
807
  letter-spacing: var(--tabs-section-label-letter-spacing, 0.04em);
610
808
  text-transform: var(--tabs-section-label-text-transform, uppercase);
611
- color: var(--tabs-section-label-color, var(--tabs-item-color, #999999));
809
+ color: var(--tabs-section-label-color, var(--tabs-item-color, #666666));
612
810
  white-space: nowrap;
613
811
  user-select: none;
614
812
  }
@@ -23,6 +23,13 @@ export type TabItem = {
23
23
  * `tab` snippet, since it sits outside the item row.
24
24
  */
25
25
  sectionLabel?: string;
26
+ /**
27
+ * Blocks selection of this item. It keeps its place and its `aria-disabled`
28
+ * state, but arrow keys, `Home`/`End` and clicks skip past it, and it never
29
+ * becomes the list's tab stop. An item selected from outside stays selected
30
+ * even while disabled, matching how a controlled parent's state is honoured.
31
+ */
32
+ disabled?: boolean;
26
33
  };
27
34
  export type TabsProperties = MandatoryTabsProperties & OptionalTabsProperties & TabsEventProperties;
28
35
  export type MandatoryTabsProperties = {
@@ -38,6 +45,22 @@ export type OptionalTabsProperties = {
38
45
  * rail) — indicator on the leading edge, scroll arrows point up/down.
39
46
  */
40
47
  orientation?: 'horizontal' | 'vertical';
48
+ /**
49
+ * `'automatic'` (default) selects a tab as soon as arrow keys reach it, which is
50
+ * right when switching panels is instant. `'manual'` moves focus only, and waits
51
+ * for `Enter`, `Space` or a click — the WAI-ARIA APG recommendation when showing
52
+ * a panel is expensive, since arrowing past three tabs should not load three
53
+ * panels.
54
+ */
55
+ activationMode?: 'automatic' | 'manual';
56
+ /** Whether arrow keys wrap around the ends. `true` by default. */
57
+ loop?: boolean;
58
+ /**
59
+ * Which way horizontal arrow keys read. Omitted, the inherited document direction
60
+ * decides, so an app that sets `dir` on a wrapper needs nothing here. Vertical
61
+ * lists are unaffected: `ArrowDown` always means "later in the list".
62
+ */
63
+ dir?: 'ltr' | 'rtl';
41
64
  testId?: string;
42
65
  scrollLeftIcon?: Snippet;
43
66
  scrollRightIcon?: Snippet;