@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,5 +1,10 @@
1
1
  <script lang="ts">
2
- import type { TableProperties, TableCheckboxSelectionConfig, TableRow } from './properties';
2
+ import type {
3
+ TableProperties,
4
+ TableCheckboxSelectionConfig,
5
+ TableRow,
6
+ SortDirection
7
+ } from './properties';
3
8
  import { normalizeColumns } from './normalizeColumns';
4
9
  import BuiltinCell from './BuiltinCell.svelte';
5
10
  import type { JSONValue } from 'type-decoder';
@@ -25,6 +30,7 @@
25
30
  sortable = true,
26
31
  sortableColumns,
27
32
  sortMode = 'client',
33
+ sortState,
28
34
  stickyHeader = false,
29
35
  isTableScrollable = false,
30
36
  isContentScrollable = false,
@@ -37,6 +43,7 @@
37
43
  empty,
38
44
  onrowclick,
39
45
  onsort,
46
+ onsortchange,
40
47
  classes,
41
48
  paginatorSlot,
42
49
  getRowTestId,
@@ -52,6 +59,7 @@
52
59
  headerTooltipIcon,
53
60
  headerTooltipPosition,
54
61
  usePortal = false,
62
+ mobileCardLayout = false,
55
63
  labels
56
64
  }: TableProperties = $props();
57
65
 
@@ -86,8 +94,36 @@
86
94
  });
87
95
 
88
96
  // ─── Sort state ──────────────────────────────────────────────────────────────
89
- let sortColumn = $state<number | null>(null);
90
- let sortDirection = $state<'asc' | 'desc'>('asc');
97
+ // Held as a column ID, never as a column index. The index of a field changes
98
+ // when columns are reordered or hidden, so index-keyed state follows the
99
+ // POSITION and silently starts sorting whichever field moved into it.
100
+ let internalSortColumnId = $state<string | null>(null);
101
+ let internalSortDirection = $state<SortDirection>('asc');
102
+
103
+ /** Positional tables declare no identity, so the index string is the ID. */
104
+ const columnIdAt = (colIndex: number): string => columns?.[colIndex]?.id ?? String(colIndex);
105
+
106
+ // Controlled-sort overlay, mirroring the controlled-selection overlay below:
107
+ // when the consumer supplies `sortState` (including `null`, meaning "sorted
108
+ // by nothing"), Table renders FROM it and never mutates it. Absent — every
109
+ // pre-existing consumer — the internal state behaves exactly as before.
110
+ let isControlledSort = $derived(typeof sortState !== 'undefined');
111
+ let activeSortColumnId = $derived(
112
+ isControlledSort ? (sortState?.columnId ?? null) : internalSortColumnId
113
+ );
114
+ let sortDirection = $derived(
115
+ isControlledSort ? (sortState?.direction ?? 'asc') : internalSortDirection
116
+ );
117
+ /** The sorted column's CURRENT index, or null when its ID matches none. */
118
+ let sortColumn = $derived.by((): number | null => {
119
+ if (activeSortColumnId === null) {
120
+ return null;
121
+ }
122
+ const index = effectiveHeaders.findIndex(
123
+ (_header, colIndex) => columnIdAt(colIndex) === activeSortColumnId
124
+ );
125
+ return index === -1 ? null : index;
126
+ });
91
127
 
92
128
  const isColumnSortable = (colIndex: number): boolean => {
93
129
  if (!sortable) {
@@ -104,26 +140,52 @@
104
140
  return;
105
141
  }
106
142
 
107
- if (sortColumn === colIndex) {
108
- sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
109
- } else {
110
- sortColumn = colIndex;
111
- sortDirection = 'asc';
143
+ const columnId = columnIdAt(colIndex);
144
+ const nextDirection: SortDirection =
145
+ activeSortColumnId === columnId && sortDirection === 'asc' ? 'desc' : 'asc';
146
+
147
+ if (!isControlledSort) {
148
+ internalSortColumnId = columnId;
149
+ internalSortDirection = nextDirection;
112
150
  }
113
- onsort?.(colIndex, sortDirection);
151
+ onsort?.(colIndex, nextDirection);
152
+ onsortchange?.({ columnId, direction: nextDirection });
114
153
  };
115
154
 
116
155
  // ─── Row click ───────────────────────────────────────────────────────────────
117
- const handleRowClick = (rowIndex: number, rowData: JSONValue[], originalIndex: number): void => {
156
+ const handleRowClick = (
157
+ event: MouseEvent,
158
+ rowIndex: number,
159
+ rowData: JSONValue[],
160
+ originalIndex: number
161
+ ): void => {
162
+ if (isRowActivationIgnored(event.target)) {
163
+ return;
164
+ }
118
165
  onrowclick?.(rowIndex, rowData, originalIndex);
119
166
  };
120
167
 
168
+ /**
169
+ * Enter/Space activates the row only when the ROW ITSELF holds focus.
170
+ *
171
+ * Built-in cells stop propagation, but a caller's `cell` snippet deliberately
172
+ * does not, so every key pressed inside a custom control used to bubble here:
173
+ * typing a space in an editable cell opened the record AND lost the space to
174
+ * `preventDefault()`, and Enter on a cell button fired the button and the row.
175
+ * Asking whether the row is the event's target answers that exactly — a key
176
+ * event targets the focused element — without an allowlist of "interactive"
177
+ * tags, which cannot be complete for inputs, editable regions, custom
178
+ * elements or portaled controls.
179
+ */
121
180
  const handleRowKeydown = (
122
181
  event: KeyboardEvent,
123
182
  rowIndex: number,
124
183
  rowData: JSONValue[],
125
184
  originalIndex: number
126
185
  ): void => {
186
+ if (event.target !== event.currentTarget) {
187
+ return;
188
+ }
127
189
  if (event.key === 'Enter' || event.key === ' ') {
128
190
  event.preventDefault();
129
191
  onrowclick?.(rowIndex, rowData, originalIndex);
@@ -133,6 +195,27 @@
133
195
  let isRowClickable = $derived(typeof onrowclick === 'function');
134
196
  let isStickyHeader = $derived(stickyHeader || isTableScrollable);
135
197
 
198
+ /**
199
+ * Click is NOT guarded the way keydown is, because "click anywhere on the row
200
+ * to open it" is the feature — a guard by event target would break clicking
201
+ * the row's own text, which is most of the row. Interactive custom-cell
202
+ * content opts out instead, by marking itself:
203
+ *
204
+ * <button data-row-activation="ignore">Retry</button>
205
+ *
206
+ * An attribute rather than an exported action, so it needs no import and
207
+ * survives being written by a consumer who only has markup. Built-in cells
208
+ * already stop propagation themselves and need nothing. Portaled controls
209
+ * (in-cell Menu/Select with `usePortal`) render outside the row entirely, so
210
+ * their clicks never reach it — they are safe without the marking, which is
211
+ * why this is deliberately not sold as a complete "is interactive" test.
212
+ */
213
+ const IGNORE_SELECTOR = '[data-row-activation="ignore"]';
214
+
215
+ const isRowActivationIgnored = (target: EventTarget | null): boolean => {
216
+ return target instanceof Element && target.closest(IGNORE_SELECTOR) !== null;
217
+ };
218
+
136
219
  // ─── Horizontal-scroll affordance ────────────────────────────────────────
137
220
  // The table clips columns behind an internal horizontal scroll on narrow
138
221
  // viewports, but a bare scroll container gives no visual hint that more
@@ -165,7 +248,15 @@
165
248
  };
166
249
 
167
250
  // ─── C2-3: Search ─────────────────────────────────────────────────────────
168
- let searchTerm = $state('');
251
+ // Same overlay shape as sort and selection: `searchConfig.searchTerm` takes
252
+ // ownership of the visible term, `onSearchTermChange` reports the would-be
253
+ // next one. Note this is orthogonal to `onsearchchange`, which delegates
254
+ // FILTERING — a controlled term still filters client-side on its own.
255
+ let internalSearchTerm = $state('');
256
+ let isControlledSearch = $derived(typeof searchConfig?.searchTerm === 'string');
257
+ let searchTerm = $derived(
258
+ isControlledSearch ? (searchConfig?.searchTerm ?? '') : internalSearchTerm
259
+ );
169
260
  let hasSearchConfig = $derived(!!searchConfig);
170
261
  let isServerSearch = $derived(typeof onsearchchange === 'function');
171
262
  let searchInputRef = $state<HTMLInputElement | null>(null);
@@ -175,9 +266,12 @@
175
266
  let isInlineSearchExpanded = $state(false);
176
267
 
177
268
  const updateSearch = (term: string): void => {
178
- searchTerm = term;
269
+ if (!isControlledSearch) {
270
+ internalSearchTerm = term;
271
+ }
179
272
  pageOverride = 1;
180
273
  pagination?.onPageChange?.(1);
274
+ searchConfig?.onSearchTermChange?.(term);
181
275
  if (isServerSearch) {
182
276
  onsearchchange?.(term);
183
277
  }
@@ -332,16 +426,20 @@
332
426
  let pageOverride = $state<number | null>(null);
333
427
  let pageSizeOverride = $state<number | null>(null);
334
428
  let paginationMode = $derived(pagination?.mode ?? 'client');
335
- let effectivePageSize = $derived(pageSizeOverride ?? pagination?.pageSize ?? 10);
336
- let effectivePage = $derived.by(() => {
337
- if (!pagination) {
338
- return 1;
339
- }
340
- if (paginationMode === 'server') {
341
- return pagination.page ?? 1;
342
- }
343
- return pageOverride ?? pagination.page ?? 1;
344
- });
429
+ // Server mode with a declared `pageSize` hands page-size ownership to the
430
+ // consumer, exactly as `selectedIds` does for selection: the selector emits
431
+ // `onPageSizeChange` as a REQUEST, and until the consumer answers by changing
432
+ // the prop, the chrome keeps describing the page the server actually served.
433
+ // Without a declared `pageSize` (and in client mode) the selector owns it, as
434
+ // before.
435
+ let isPageSizeControlled = $derived(
436
+ paginationMode === 'server' && typeof pagination?.pageSize === 'number'
437
+ );
438
+ let effectivePageSize = $derived(
439
+ isPageSizeControlled
440
+ ? (pagination?.pageSize ?? 10)
441
+ : (pageSizeOverride ?? pagination?.pageSize ?? 10)
442
+ );
345
443
  let paginationTotalItems = $derived.by(() => {
346
444
  if (!pagination) {
347
445
  return 0;
@@ -354,6 +452,29 @@
354
452
  let paginationTotalPages = $derived(
355
453
  Math.max(1, Math.ceil(paginationTotalItems / Math.max(1, effectivePageSize)))
356
454
  );
455
+ let requestedPage = $derived.by(() => {
456
+ if (!pagination) {
457
+ return 1;
458
+ }
459
+ if (paginationMode === 'server') {
460
+ return pagination.page ?? 1;
461
+ }
462
+ return pageOverride ?? pagination.page ?? 1;
463
+ });
464
+ // In client mode the row set can shrink from outside — rows deleted, replaced,
465
+ // or filtered away — while the reader sits on a later page, leaving the slice
466
+ // empty and the range text describing rows that no longer exist ("21-5 of 5").
467
+ // Table owns the page in this mode, so it resolves to the last page that has
468
+ // rows. Display-side only: no onPageChange is emitted, since no one asked for
469
+ // a page change, and the reader's chosen page is kept rather than overwritten,
470
+ // so a set that shrinks and comes back does not relocate them to page 1.
471
+ // Server mode is left alone — there the page is the consumer's and an
472
+ // unexpected page number is theirs to reconcile.
473
+ let effectivePage = $derived(
474
+ paginationMode === 'server'
475
+ ? requestedPage
476
+ : Math.min(Math.max(1, requestedPage), paginationTotalPages)
477
+ );
357
478
  let paginatedTableData = $derived.by(() => {
358
479
  if (!pagination || paginationMode === 'server') {
359
480
  return filteredTableData;
@@ -614,8 +735,29 @@
614
735
  return { ...own, ...consumer };
615
736
  };
616
737
 
738
+ // ─── Empty-state reason ───────────────────────────────────────────────────
739
+ // "No records yet" and "nothing matched" need different messages and offer
740
+ // different remedies, and a consumer cannot tell them apart from `rows` alone
741
+ // once the built-in search owns the term. A filter being ACTIVE is the
742
+ // signal, not whether rows survived it: that stays true in server mode, where
743
+ // the consumer hands back an already-filtered (and empty) page.
744
+ let hasActiveColumnFilter = $derived(
745
+ (columns ?? []).some((column) => typeof column.filter?.selectedValue === 'string')
746
+ );
747
+ let emptyReason = $derived<'no-rows' | 'no-matches'>(
748
+ searchTerm.trim() !== '' || hasActiveColumnFilter ? 'no-matches' : 'no-rows'
749
+ );
750
+
617
751
  let isCheckboxMode = $derived(!!checkboxSelection && checkboxSelection.enabled !== false);
618
752
  let isSingleSelect = $derived(checkboxSelection?.selectionMode === 'single');
753
+
754
+ // ─── Mobile record cards ─────────────────────────────────────────────────────
755
+ // Restating a role that is already implicit at desktop width (table stays
756
+ // `display: table`) is a no-op there and is what keeps the AX tree a real
757
+ // table once `.table-mobile-cards` switches `display` to `block` below the
758
+ // breakpoint — see the accessibility comment on `mobileCardLayout` in
759
+ // properties.ts and the `.table-mobile-cards` rules below.
760
+ const mobileRole = (role: string): string | null => (mobileCardLayout ? role : null);
619
761
  </script>
620
762
 
621
763
  {#if typeof tableTitle === 'string' && tableTitle.length > 0}
@@ -665,6 +807,7 @@
665
807
  {#if effectiveHeaders.length !== 0 || effectiveData.length !== 0}
666
808
  <div
667
809
  class="table-container {isTableScrollable ? 'scrollable-table' : ''} {classes ?? ''}"
810
+ class:table-mobile-cards={mobileCardLayout}
668
811
  data-pw={testId}
669
812
  testID={testId}
670
813
  >
@@ -674,16 +817,17 @@
674
817
  class:scrollable-right={canScrollRight}
675
818
  >
676
819
  <div class="table-scroll" use:trackHorizontalScroll>
677
- <table>
820
+ <table role={mobileRole('table')}>
678
821
  {#if caption}
679
822
  <caption class="sr-only">{caption}</caption>
680
823
  {/if}
681
- <thead>
682
- <tr>
824
+ <thead role={mobileRole('rowgroup')}>
825
+ <tr role={mobileRole('row')}>
683
826
  {#if isCheckboxMode && !isSingleSelect}
684
827
  <th
685
828
  class="table-header table-checkbox-col"
686
829
  class:table-header-sticky={isStickyHeader}
830
+ role={mobileRole('columnheader')}
687
831
  >
688
832
  <!-- Header tri-state checkbox: the library Checkbox in controlled mode,
689
833
  so the tri-state is computed here and never flipped locally. -->
@@ -705,13 +849,15 @@
705
849
  <th
706
850
  class="table-header table-checkbox-col"
707
851
  class:table-header-sticky={isStickyHeader}
852
+ role={mobileRole('columnheader')}
708
853
  >
709
854
  </th>
710
855
  {/if}
711
856
  {#if rowNumberColumn}
712
857
  <th
713
858
  class="table-header table-row-number-col"
714
- class:table-header-sticky={isStickyHeader}>{rowNumberLabel}</th
859
+ class:table-header-sticky={isStickyHeader}
860
+ role={mobileRole('columnheader')}>{rowNumberLabel}</th
715
861
  >
716
862
  {/if}
717
863
  {#each effectiveHeaders as header, colIndex (colIndex)}
@@ -720,6 +866,14 @@
720
866
  class="table-header"
721
867
  class:table-header-sticky={isStickyHeader}
722
868
  class:table-col-highlighted={headerColumn?.highlighted === true}
869
+ role={mobileRole('columnheader')}
870
+ aria-sort={isColumnSortable(colIndex)
871
+ ? sortColumn === colIndex
872
+ ? sortDirection === 'asc'
873
+ ? 'ascending'
874
+ : 'descending'
875
+ : 'none'
876
+ : null}
723
877
  data-pw={headerColumn?.testId ?? null}
724
878
  testID={headerColumn?.testId ?? null}
725
879
  style:text-align={headerColumn?.align ?? null}
@@ -879,16 +1033,17 @@
879
1033
  {/each}
880
1034
  </tr>
881
1035
  </thead>
882
- <tbody>
1036
+ <tbody role={mobileRole('rowgroup')}>
883
1037
  {#if filteredTableData.length === 0 && typeof empty === 'function'}
884
- <tr>
1038
+ <tr role={mobileRole('row')}>
885
1039
  <td
886
1040
  class="table-empty"
1041
+ role={mobileRole('cell')}
887
1042
  colspan={effectiveHeaders.length +
888
1043
  (isCheckboxMode ? 1 : 0) +
889
1044
  (rowNumberColumn ? 1 : 0)}
890
1045
  >
891
- {@render empty()}
1046
+ {@render empty({ reason: emptyReason, searchTerm })}
892
1047
  </td>
893
1048
  </tr>
894
1049
  {:else}
@@ -904,10 +1059,11 @@
904
1059
  class:table-row-selected={rowSelected}
905
1060
  class:table-summary-row={summaryRowIndex !== null &&
906
1061
  originalIndex === summaryRowIndex}
1062
+ role={mobileRole('row')}
907
1063
  data-pw={typeof getRowTestId === 'function' ? getRowTestId(row, rowIndex) : null}
908
1064
  testID={typeof getRowTestId === 'function' ? getRowTestId(row, rowIndex) : null}
909
1065
  onclick={isRowClickable
910
- ? () => handleRowClick(rowIndex, row, originalIndex)
1066
+ ? (mouseEvent) => handleRowClick(mouseEvent, rowIndex, row, originalIndex)
911
1067
  : null}
912
1068
  onkeydown={isRowClickable
913
1069
  ? (keyboardEvent) =>
@@ -920,10 +1076,19 @@
920
1076
  also fire its own handler when the checkbox is toggled. -->
921
1077
  <td
922
1078
  class="table-content table-checkbox-col"
1079
+ role={mobileRole('cell')}
923
1080
  onclick={(mouseEvent) => mouseEvent.stopPropagation()}
924
1081
  onkeydown={(keyboardEvent) => keyboardEvent.stopPropagation()}
925
1082
  >
926
- <Checkbox
1083
+ {#if mobileCardLayout}
1084
+ <!-- Real element, not `::before` — see the accessibility note
1085
+ on `mobileCardLayout` in properties.ts for why generated
1086
+ content cannot be the label. `aria-hidden` keeps a screen
1087
+ reader from announcing it a second time on top of the
1088
+ `columnheader`-derived name the checkbox's own
1089
+ `Select row …` aria-label already carries. -->
1090
+ <span class="table-mobile-label" aria-hidden="true">Select</span>
1091
+ {/if}<Checkbox
927
1092
  text=""
928
1093
  ariaLabel={labels?.selectRow?.(rowId) ??
929
1094
  `Select row ${rowId || 'non-selectable'}`}
@@ -936,7 +1101,11 @@
936
1101
  </td>
937
1102
  {/if}
938
1103
  {#if rowNumberColumn}
939
- <td class="table-content table-row-number-col">{rowNumberFor(pageRowIndex)}</td>
1104
+ <td class="table-content table-row-number-col" role={mobileRole('cell')}>
1105
+ {#if mobileCardLayout}
1106
+ <span class="table-mobile-label" aria-hidden="true">{rowNumberLabel}</span>
1107
+ {/if}<span class="table-mobile-value">{rowNumberFor(pageRowIndex)}</span>
1108
+ </td>
940
1109
  {/if}
941
1110
  {#each row as cellValue, colIndex (colIndex)}
942
1111
  {@const keyedColumn = columns?.[colIndex]}
@@ -945,20 +1114,20 @@
945
1114
  typeof cellValue === 'string' ||
946
1115
  typeof cellValue === 'number' ||
947
1116
  typeof cellValue === 'boolean'}
948
- <td
949
- class="table-content"
950
- class:table-col-highlighted={keyedColumn?.highlighted === true}
951
- data-pw={typeof getCellTestId === 'function'
952
- ? getCellTestId(row, cellValue, rowIndex)
953
- : null}
954
- testID={typeof getCellTestId === 'function'
955
- ? getCellTestId(row, cellValue, rowIndex)
956
- : null}
957
- style:text-align={keyedColumn?.align ?? null}
958
- style:width={keyedColumn?.width ?? null}
959
- style:max-width={keyedColumn?.maxWidth ?? null}
960
- title={keyedColumn?.maxWidth && isScalarCell ? String(cellValue) : null}
961
- >
1117
+ {@const cellLabel = effectiveHeaders[colIndex] ?? ''}
1118
+ <!--
1119
+ The value markup is a snippet, called with `{@render}` rather than
1120
+ inlined as a `<div>` sibling of the `{#if mobileCardLayout}` label
1121
+ below, because a raw element there is whitespace-sensitive: Svelte
1122
+ bakes the newline prettier inserts between a control-block boundary
1123
+ and the following sibling into a real, always-present single-space
1124
+ text node (present even with `mobileCardLayout` off, since the `{#if}`
1125
+ still leaves its anchor comment behind) — verified with a throwaway
1126
+ component during development. `{@render ...}` is a mustache tag, not
1127
+ an element, so prettier never separates it from `{/if}` and the space
1128
+ never appears.
1129
+ -->
1130
+ {#snippet cellValueBody()}
962
1131
  <div
963
1132
  class={isContentScrollable ? 'scrollable-content' : ''}
964
1133
  class:table-cell-clamp={keyedColumn?.maxWidth && isScalarCell}
@@ -985,6 +1154,25 @@
985
1154
  {cellValue}
986
1155
  {/if}
987
1156
  </div>
1157
+ {/snippet}
1158
+ <td
1159
+ class="table-content"
1160
+ class:table-col-highlighted={keyedColumn?.highlighted === true}
1161
+ role={mobileRole('cell')}
1162
+ data-pw={typeof getCellTestId === 'function'
1163
+ ? getCellTestId(row, cellValue, rowIndex)
1164
+ : null}
1165
+ testID={typeof getCellTestId === 'function'
1166
+ ? getCellTestId(row, cellValue, rowIndex)
1167
+ : null}
1168
+ style:text-align={keyedColumn?.align ?? null}
1169
+ style:width={keyedColumn?.width ?? null}
1170
+ style:max-width={keyedColumn?.maxWidth ?? null}
1171
+ title={keyedColumn?.maxWidth && isScalarCell ? String(cellValue) : null}
1172
+ >
1173
+ {#if mobileCardLayout}
1174
+ <span class="table-mobile-label" aria-hidden="true">{cellLabel}</span>
1175
+ {/if}{@render cellValueBody()}
988
1176
  </td>
989
1177
  {/each}
990
1178
  </tr>
@@ -1074,8 +1262,17 @@
1074
1262
  margin: var(--table-title-margin, 0px 0px 12px 0px);
1075
1263
  font-size: var(--table-title-font-size, var(--table-tile-font-size, 18px));
1076
1264
  font-weight: var(--table-title-font-weight, 600);
1077
- color: var(--table-title-color, #111827);
1078
- font-family: var(--table-title-font-family);
1265
+ /* Unlike .table-content, a heading legitimately has no background of its
1266
+ own -- it sits directly on whatever surface the consumer places it on.
1267
+ The old #111827 literal ignored that surface entirely: on a dark page
1268
+ it measured a contrast of 1.03 against #1a1a26, i.e. invisible. `color`
1269
+ already inherits by default, so `inherit` states that explicitly and
1270
+ lets the title track its container's text colour; `currentColor` would
1271
+ resolve to the same value here (the spec special-cases it to mean
1272
+ exactly `inherit` for the `color` property) but reads as if it were
1273
+ referencing a colour set elsewhere on this rule, which it isn't. */
1274
+ color: var(--table-title-color, inherit);
1275
+ font-family: var(--table-title-font-family, inherit);
1079
1276
  padding: var(--table-title-padding);
1080
1277
  }
1081
1278
 
@@ -1120,7 +1317,7 @@
1120
1317
  }
1121
1318
 
1122
1319
  .table-search-input::placeholder {
1123
- color: var(--table-search-placeholder-color, #9ca3af);
1320
+ color: var(--table-search-placeholder-color, #4b5563);
1124
1321
  }
1125
1322
 
1126
1323
  /* Hide browser-default clear button on search inputs */
@@ -1226,7 +1423,8 @@
1226
1423
  width: var(--table-scroll-scrim-width, 32px);
1227
1424
  opacity: 0;
1228
1425
  pointer-events: none;
1229
- transition: opacity 0.2s ease;
1426
+ transition: opacity var(--table-scroll-scrim-transition-duration, var(--motion-duration, 0.2s))
1427
+ var(--table-scroll-scrim-transition-easing, var(--motion-easing, ease));
1230
1428
  z-index: 2;
1231
1429
  }
1232
1430
 
@@ -1286,10 +1484,10 @@
1286
1484
  .table-header {
1287
1485
  background-color: var(--table-header-background, var(--table-header-border-bgcolor, #f9fafb));
1288
1486
  font-size: var(--table-header-font-size, 13px);
1289
- font-family: var(--table-header-font-family);
1487
+ font-family: var(--table-header-font-family, inherit);
1290
1488
  font-weight: var(--table-header-font-weight, 600);
1291
1489
  letter-spacing: var(--table-header-letter-spacing, 0.02em);
1292
- text-transform: var(--table-header-text-transform);
1490
+ text-transform: var(--table-header-text-transform, inherit);
1293
1491
  color: var(--table-header-color, var(--table-header-font-color, #6b7280));
1294
1492
  border-bottom: var(--table-header-border, var(--table-inner-border, none));
1295
1493
  }
@@ -1359,9 +1557,16 @@
1359
1557
  }
1360
1558
 
1361
1559
  .table-content {
1362
- background-color: var(--table-content-background, var(--table-content-border-bgcolor));
1560
+ /* The terminal #ffffff matters as much as the colour beside it. Without it
1561
+ the whole declaration is invalid at computed-value time when neither
1562
+ variable is set, so the cell falls back to `transparent` and inherits
1563
+ whatever surface it lands on -- while `color` still resolves to a
1564
+ hardcoded near-black. On a dark page that is #111827 on #1a1a26: a
1565
+ measured contrast of 1.03, i.e. invisible. The header row never had the
1566
+ bug because it pairs its colour with a background of its own. */
1567
+ background-color: var(--table-content-background, var(--table-content-border-bgcolor, #ffffff));
1363
1568
  font-size: var(--table-content-font-size, 14px);
1364
- font-family: var(--table-content-font-family);
1569
+ font-family: var(--table-content-font-family, inherit);
1365
1570
  color: var(--table-content-color, var(--table-content-font-color, #111827));
1366
1571
  }
1367
1572
 
@@ -1461,7 +1666,12 @@
1461
1666
  --checkbox-icon-size: var(--table-checkbox-icon-size, 12px);
1462
1667
  --checkbox-checkmark-color: var(--table-checkbox-icon-color, #ffffff);
1463
1668
  --checkbox-dash-color: var(--table-checkbox-icon-color, #ffffff);
1464
- --checkbox-transition: 0.15s;
1669
+ /* Named so a consumer can reach it. Table sets the Checkbox child's token
1670
+ directly, and a rule on the element beats anything declared at :root, so
1671
+ through <sui-table> the selection checkboxes' transition could not be
1672
+ slowed or stopped by any means -- custom properties are the only route
1673
+ across a shadow boundary and this one was already spoken for. */
1674
+ --checkbox-transition: var(--table-checkbox-transition, 0.15s);
1465
1675
  }
1466
1676
 
1467
1677
  /* ── Sort button ────────────────────────────────────────────────────────── */
@@ -1569,6 +1779,11 @@
1569
1779
  .table-paginator-size {
1570
1780
  display: inline-flex;
1571
1781
  min-width: var(--table-paginator-size-width, 72px);
1782
+ /* Select's standalone defaults (40px min-height, 8px 12px padding) render
1783
+ 58px tall next to the 36px pagination buttons sharing this row. Match the
1784
+ steppers instead of towering over them. */
1785
+ --select-trigger-min-height: var(--table-paginator-size-height, 36px);
1786
+ --select-trigger-padding: var(--table-paginator-size-padding, 0 8px);
1572
1787
  }
1573
1788
 
1574
1789
  /* ── Toolbar (bulk actions) ─────────────────────────────────────────────── */
@@ -1603,4 +1818,107 @@
1603
1818
  white-space: nowrap;
1604
1819
  border-width: 0;
1605
1820
  }
1821
+
1822
+ /* ── Mobile record cards (opt-in via `mobileCardLayout`) ──────────────────
1823
+ The label span is always in the DOM -- rendering it only under the media
1824
+ query below would mean an AT that queries computed style rather than
1825
+ layout (or a print stylesheet, or a future breakpoint change) sees no
1826
+ label at all until the exact 640px condition is re-evaluated. Keeping the
1827
+ element and toggling `display` is what lets `aria-hidden` on it (see the
1828
+ template) stay meaningful at every width: hidden today, but never a
1829
+ generated-content trick that could not carry that attribute in the first
1830
+ place. */
1831
+ .table-mobile-label {
1832
+ display: none;
1833
+ }
1834
+
1835
+ /* The breakpoint is a literal, not `var(--table-mobile-breakpoint)`: a
1836
+ custom property can't be read inside an `@media` condition (only inside
1837
+ a declaration value), so a themable breakpoint was never on the table --
1838
+ documented in docs/Table.md rather than silently unsupported. */
1839
+ @media (max-width: 640px) {
1840
+ /* `display: table` (and its `-row`/`-cell`/etc. companions) is what gives
1841
+ a table its IMPLICIT header/cell ARIA semantics -- switching every one
1842
+ of these to `block` for the stacked-card look throws that away, which
1843
+ is exactly why `mobileRole()` in the script restates it explicitly as
1844
+ `table`/`rowgroup`/`row`/`columnheader`/`cell` on the elements below.
1845
+ Without both halves this rule would silently turn every consumer's
1846
+ table into an AX-tree list the moment their viewport narrowed. */
1847
+ .table-mobile-cards table,
1848
+ .table-mobile-cards thead,
1849
+ .table-mobile-cards tbody,
1850
+ .table-mobile-cards tr,
1851
+ .table-mobile-cards th,
1852
+ .table-mobile-cards td {
1853
+ display: block;
1854
+ width: 100%;
1855
+ }
1856
+
1857
+ /* Headers are not shown as a row anymore -- their text now repeats per
1858
+ card as the `.table-mobile-label` beside each value -- but they stay
1859
+ in the DOM (not `display: none`) so `role="columnheader"` continues
1860
+ naming its `role="cell"` for any reading that still associates the
1861
+ two by DOM structure rather than only by the label span's text. */
1862
+ .table-mobile-cards thead {
1863
+ position: absolute;
1864
+ width: 1px;
1865
+ height: 1px;
1866
+ padding: 0;
1867
+ overflow: hidden;
1868
+ clip: rect(0, 0, 0, 0);
1869
+ clip-path: inset(50%);
1870
+ white-space: nowrap;
1871
+ border-width: 0;
1872
+ }
1873
+
1874
+ /* The horizontal-scroll affordance this table uses at desktop width (see
1875
+ .table-scroll / .table-scroll-shell above) has nothing to scroll once
1876
+ every cell is a full-width block, and a lingering scrim would fade a
1877
+ card edge that no longer clips anything. */
1878
+ .table-mobile-cards .table-scroll {
1879
+ overflow-x: visible;
1880
+ }
1881
+
1882
+ .table-mobile-cards .table-scroll-shell::before,
1883
+ .table-mobile-cards .table-scroll-shell::after {
1884
+ display: none;
1885
+ }
1886
+
1887
+ .table-mobile-cards tbody tr.table-row {
1888
+ border: var(--table-mobile-card-border, 1px solid #e5e7eb);
1889
+ border-radius: var(
1890
+ --table-mobile-card-radius,
1891
+ var(--table-border-radius, var(--radius, 4px))
1892
+ );
1893
+ padding: var(--table-mobile-card-padding, 12px);
1894
+ margin-block-end: var(--table-mobile-card-gap, 12px);
1895
+ }
1896
+
1897
+ .table-mobile-cards tbody tr.table-row:last-child {
1898
+ margin-block-end: 0;
1899
+ }
1900
+
1901
+ /* Each cell becomes a label/value pair instead of a table column; the
1902
+ label span (hidden above) is the only thing `display: inline` turns
1903
+ back on inside this query. */
1904
+ .table-mobile-cards td.table-content {
1905
+ display: flex;
1906
+ align-items: baseline;
1907
+ gap: var(--table-mobile-label-gap, 8px);
1908
+ border: none;
1909
+ }
1910
+
1911
+ .table-mobile-cards .table-mobile-label {
1912
+ display: inline;
1913
+ flex: 0 0 var(--table-mobile-label-width, 40%);
1914
+ font-weight: var(--table-mobile-label-font-weight, 600);
1915
+ color: var(--table-mobile-label-color, #6b7280);
1916
+ }
1917
+
1918
+ .table-mobile-cards .table-mobile-value,
1919
+ .table-mobile-cards td.table-content > div {
1920
+ flex: 1 1 auto;
1921
+ min-width: 0;
1922
+ }
1923
+ }
1606
1924
  </style>