@juspay/svelte-ui-components 4.51.0 → 4.52.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 (96) hide show
  1. package/dist/Animations/tokenizedFly.js +9 -2
  2. package/dist/AreaChart/AreaChart.svelte +41 -13
  3. package/dist/AreaChart/properties.d.ts +12 -0
  4. package/dist/AttachmentChipRow/AttachmentChipRow.svelte +7 -6
  5. package/dist/BarChart/BarChart.svelte +54 -6
  6. package/dist/BarChart/properties.d.ts +12 -0
  7. package/dist/Calendar/Calendar.svelte +124 -51
  8. package/dist/Card/Card.svelte +5 -0
  9. package/dist/ChatSuggestions/ChatSuggestions.svelte +1 -0
  10. package/dist/CheckListItem/CheckListItem.svelte +27 -5
  11. package/dist/ColorPicker/ColorPicker.svelte +40 -9
  12. package/dist/ColorPicker/properties.d.ts +8 -0
  13. package/dist/Combobox/Combobox.svelte +34 -2
  14. package/dist/CommandMenu/CommandMenu.svelte +35 -8
  15. package/dist/CommandMenu/properties.d.ts +6 -0
  16. package/dist/DateRangePicker/DateRangePicker.svelte +5 -5
  17. package/dist/DualAxisBarChart/DualAxisBarChart.svelte +41 -7
  18. package/dist/DualAxisBarChart/properties.d.ts +4 -0
  19. package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +104 -10
  20. package/dist/FileDropzoneTrigger/properties.d.ts +14 -4
  21. package/dist/FileInput/FileInput.svelte +38 -9
  22. package/dist/FileInput/context.d.ts +20 -0
  23. package/dist/FileInput/context.js +8 -0
  24. package/dist/FileInput/properties.d.ts +25 -1
  25. package/dist/FunnelChart/FunnelChart.svelte +20 -1
  26. package/dist/FunnelChart/properties.d.ts +4 -0
  27. package/dist/Gallery/Gallery.svelte +72 -27
  28. package/dist/HITL/HITL.svelte +64 -14
  29. package/dist/HITL/properties.d.ts +5 -0
  30. package/dist/LineChart/LineChart.svelte +105 -33
  31. package/dist/LineChart/properties.d.ts +12 -0
  32. package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
  33. package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
  34. package/dist/MediaPlayer/properties.d.ts +31 -0
  35. package/dist/Menu/Menu.svelte +6 -4
  36. package/dist/Modal/Modal.svelte +15 -12
  37. package/dist/Modal/properties.d.ts +29 -1
  38. package/dist/Pagination/Pagination.svelte +1 -0
  39. package/dist/PieChart/PieChart.svelte +51 -29
  40. package/dist/PieChart/properties.d.ts +4 -0
  41. package/dist/Pill/Pill.svelte +5 -0
  42. package/dist/Radio/Radio.svelte +11 -2
  43. package/dist/Radio/properties.d.ts +7 -2
  44. package/dist/SankeyChart/SankeyChart.svelte +339 -211
  45. package/dist/SankeyChart/properties.d.ts +4 -0
  46. package/dist/Scroller/Scroller.svelte +236 -4
  47. package/dist/Scroller/properties.d.ts +8 -0
  48. package/dist/Select/Select.svelte +46 -8
  49. package/dist/Select/properties.d.ts +17 -1
  50. package/dist/Sheet/Sheet.svelte +31 -4
  51. package/dist/Sheet/properties.d.ts +6 -0
  52. package/dist/SplitInput/SplitInput.svelte +17 -0
  53. package/dist/SplitInput/field-name.d.ts +29 -0
  54. package/dist/SplitInput/field-name.js +50 -0
  55. package/dist/SplitInput/properties.d.ts +18 -4
  56. package/dist/Status/Status.svelte +24 -12
  57. package/dist/Status/properties.d.ts +11 -0
  58. package/dist/Table/BuiltinCell.svelte +13 -5
  59. package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
  60. package/dist/Table/Table.svelte +78 -20
  61. package/dist/Table/a11y.d.ts +7 -0
  62. package/dist/Table/a11y.js +71 -0
  63. package/dist/Table/cellData.js +3 -0
  64. package/dist/Table/properties.d.ts +13 -5
  65. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
  66. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
  67. package/dist/Toast/Toast.svelte +70 -4
  68. package/dist/Toggle/Toggle.svelte +2 -0
  69. package/dist/Toggle/properties.d.ts +2 -0
  70. package/dist/_chart/Axis.svelte +10 -0
  71. package/dist/_chart/ChartContainer.svelte +32 -1
  72. package/dist/_chart/ChartTooltip.svelte +18 -3
  73. package/dist/_chart/LabelPlate.svelte +116 -0
  74. package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
  75. package/dist/_chart/a11y.d.ts +29 -0
  76. package/dist/_chart/a11y.js +44 -0
  77. package/dist/_chart/measure.d.ts +2 -0
  78. package/dist/_chart/measure.js +4 -0
  79. package/dist/_chart/tooltipPosition.d.ts +6 -0
  80. package/dist/_chart/tooltipPosition.js +30 -2
  81. package/dist/_chart/types.d.ts +22 -0
  82. package/dist/_interaction/focusable.d.ts +47 -0
  83. package/dist/_interaction/focusable.js +139 -0
  84. package/dist/assets/captions-on.svg +3 -0
  85. package/dist/assets/captions.svg +5 -0
  86. package/dist/index.d.ts +1 -1
  87. package/dist/index.js +1 -1
  88. package/dist/styles/theme-dark.css +9 -1
  89. package/dist/types.d.ts +2 -0
  90. package/dist/utils.d.ts +18 -2
  91. package/dist/utils.js +73 -28
  92. package/dist-codemod/codemod/migrate-audit.js +9 -7
  93. package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
  94. package/dist-codemod/codemod/migration-assets-layout.js +61 -0
  95. package/dist-wc/index.js +5872 -4333
  96. package/package.json +4 -2
@@ -0,0 +1,50 @@
1
+ const nonEmpty = (text) => {
2
+ const trimmed = typeof text === 'string' ? text.trim() : '';
3
+ return trimmed === '' ? null : trimmed;
4
+ };
5
+ const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
6
+ /**
7
+ * What one box holds, so the position reads as the user experiences it: the boxes
8
+ * of a PIN or one-time code are "digit 2 of 6", the boxes of a free-text code are
9
+ * "character 2 of 6", and anything wider than one character ("192", an area code)
10
+ * is just a "field" -- calling a three-digit octet a digit would be wrong.
11
+ */
12
+ const unitFor = (config) => {
13
+ if (config.maxLength !== 1) {
14
+ return 'field';
15
+ }
16
+ return config.dataType === 'tel' || config.dataType === 'number' ? 'digit' : 'character';
17
+ };
18
+ /** Capitalised only when nothing precedes it: after "One-time code," it stays lower-case. */
19
+ const defaultPosition = (config, index, total, leading) => {
20
+ const text = `${unitFor(config)} ${index + 1} of ${total}`;
21
+ return leading ? capitalise(text) : text;
22
+ };
23
+ /**
24
+ * The accessible name for one box of a SplitInput.
25
+ *
26
+ * A box on its own is meaningless -- "edit text, blank", six times -- so each one
27
+ * has to say which field it is. In order of how specific the caller was:
28
+ *
29
+ * 1. `config.ariaLabel`: the caller named this box by purpose ("Red", "Area
30
+ * code"). Used exactly as given; it already says which field it is.
31
+ * 2. `config.label`: the visible caption under the box ("R"), which is what the
32
+ * box is already called on screen -- the name has to contain it.
33
+ * 3. Otherwise the box's position: "digit 2 of 6".
34
+ *
35
+ * For 2 and 3 the group's name leads, because a screen reader's form-field list
36
+ * shows each control without its group: "One-time code, digit 2 of 6" is
37
+ * identifiable there, "digit 2 of 6" is not. With no group name the position is
38
+ * all there is, so the group should be named -- see `SplitInput`'s `ariaLabel`.
39
+ */
40
+ export const resolveFieldName = ({ index, total, config, groupLabel, positionLabel }) => {
41
+ const explicit = nonEmpty(config.ariaLabel);
42
+ if (explicit !== null) {
43
+ return explicit;
44
+ }
45
+ const group = nonEmpty(groupLabel);
46
+ const own = nonEmpty(config.label) ??
47
+ nonEmpty(positionLabel?.(index + 1, total)) ??
48
+ defaultPosition(config, index, total, group === null);
49
+ return group === null ? own : `${group}, ${own}`;
50
+ };
@@ -1,5 +1,5 @@
1
1
  import type { OptionalInputProperties } from '../Input/properties';
2
- export type FieldConfig = Pick<OptionalInputProperties, 'dataType' | 'maxLength' | 'min' | 'max' | 'placeholder' | 'validationPattern' | 'validators' | 'label' | 'autoComplete' | 'inputMode' | 'testId'>;
2
+ export type FieldConfig = Pick<OptionalInputProperties, 'dataType' | 'maxLength' | 'min' | 'max' | 'placeholder' | 'validationPattern' | 'validators' | 'label' | 'autoComplete' | 'inputMode' | 'testId' | 'ariaLabel'>;
3
3
  export type SplitInputProperties = MandatorySplitInputProperties & OptionalSplitInputProperties & SplitInputEventProperties;
4
4
  export type MandatorySplitInputProperties = {
5
5
  values: string[];
@@ -13,11 +13,25 @@ export type OptionalSplitInputProperties = {
13
13
  testId?: string;
14
14
  classes?: string;
15
15
  /**
16
- * Names the whole group ("One-time code"). The boxes are individually
17
- * meaningless, so without this a screen-reader user reaches four unlabelled
18
- * fields; naming the group is what makes them one control.
16
+ * Names the whole group ("One-time code"), and leads the name of every box in
17
+ * it ("One-time code, digit 2 of 6"), so each box is identifiable on its own --
18
+ * a screen reader's form-field list shows controls without their group. The
19
+ * boxes are individually meaningless, so naming the group is what makes them
20
+ * one control; leave it off and each box is named by its position alone
21
+ * ("Digit 2 of 6"), which says where it is but not what it is for.
22
+ *
23
+ * To name one box instead, set `ariaLabel` on its `fields` entry.
19
24
  */
20
25
  ariaLabel?: string;
26
+ /**
27
+ * Builds the position part of a box's accessible name from its 1-based
28
+ * position and the number of boxes. Replaces the English default ("digit 2 of
29
+ * 6" for single-digit boxes, "character 2 of 6" for single-character ones,
30
+ * "field 2 of 4" for wider boxes), so it is the hook for another language or
31
+ * a better noun ("octet 2 of 4"). A box with its own `ariaLabel` or visible
32
+ * `label` is named by that instead and never asks for a position.
33
+ */
34
+ positionLabel?: (position: number, total: number) => string;
21
35
  /**
22
36
  * Text shown, and announced, when the assembled value is in error ("That code
23
37
  * is not right"). Referenced by `aria-describedby` on the group rather than on
@@ -4,18 +4,24 @@
4
4
  import Img from '../Img/Img.svelte';
5
5
 
6
6
  /**
7
- * The documented default, kept as the default so this is not a breaking
8
- * change. It is a relative URL resolved against whatever page renders the
9
- * component, and the library ships no such file, so it only ever pointed at
10
- * something real for an app serving that exact path at its own root -- and
11
- * resolved to `<route>/icons/order-success-icon.svg` anywhere deeper.
7
+ * What `statusIcon` used to default to. It is a relative URL resolved against
8
+ * whatever page renders the component, and the library ships no such file, so
9
+ * it only ever pointed at something real for an app serving that exact path at
10
+ * its own root -- and resolved to `<route>/icons/order-success-icon.svg`
11
+ * anywhere deeper, where every card using the default made a request that
12
+ * 404ed before the fallback below rendered.
13
+ *
14
+ * It is no longer the default: an omitted `statusIcon` renders the built-in
15
+ * icon directly, with no request. The string is kept only so a consumer who
16
+ * spells it out themselves keeps the fallback they already had.
12
17
  */
13
18
  const LEGACY_DEFAULT_STATUS_ICON = 'icons/order-success-icon.svg';
14
19
 
15
20
  /**
16
- * Stands in when the legacy default cannot be fetched, so an app that does
17
- * host that path keeps getting its own file and every other app stops
18
- * rendering a broken image.
21
+ * The default icon, inlined so it needs no request and no packaged asset (a
22
+ * file would have to be resolved against the consumer's base path, the
23
+ * `svelte-package` output and the `<sui-*>` bundle alike). Also stands in when
24
+ * a consumer spells out the legacy default and it cannot be fetched.
19
25
  *
20
26
  * Carries no `role` or `aria-label` of its own. Both are in `Img`'s
21
27
  * allowlist, so a root declaring them would be copied onto the live host and
@@ -26,7 +32,7 @@
26
32
  "data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2048%2048'%3E%3Ccircle%20cx%3D'24'%20cy%3D'24'%20r%3D'22'%20fill%3D'%23e6f6ec'%20stroke%3D'%232e994c'%20stroke-width%3D'2'%2F%3E%3Cpath%20d%3D'M15%2024.5l6.5%206.5L33%2019.5'%20fill%3D'none'%20stroke%3D'%232e994c'%20stroke-width%3D'3.5'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%2F%3E%3C%2Fsvg%3E";
27
33
 
28
34
  let {
29
- statusIcon = LEGACY_DEFAULT_STATUS_ICON,
35
+ statusIcon,
30
36
  statusIconAlt = 'status',
31
37
  statusText = '',
32
38
  statusTextTag = 'div',
@@ -40,8 +46,14 @@
40
46
  testId
41
47
  }: StatusProperties = $props();
42
48
 
43
- // Scoped to the untouched default on purpose. Applying it to a caller's own
44
- // icon would answer a failed failure-icon with a success checkmark.
49
+ // `??` rather than a parameter default: a `<sui-status>` whose `status-icon`
50
+ // attribute is removed hands the component `null`, which a default would pass
51
+ // straight to Img.
52
+ const resolvedStatusIcon = $derived(statusIcon ?? BUILTIN_STATUS_ICON);
53
+
54
+ // Scoped to the legacy default on purpose. Applying it to a caller's own icon
55
+ // would answer a failed failure-icon with a success checkmark. The built-in
56
+ // default needs none: it cannot fail to load.
45
57
  const statusIconFallback = $derived(
46
58
  statusIcon === LEGACY_DEFAULT_STATUS_ICON ? BUILTIN_STATUS_ICON : null
47
59
  );
@@ -57,7 +69,7 @@
57
69
  {#if icon}
58
70
  {@render icon()}
59
71
  {:else}
60
- <Img inlineSvg src={statusIcon} fallback={statusIconFallback} alt={statusIconAlt} />
72
+ <Img inlineSvg src={resolvedStatusIcon} fallback={statusIconFallback} alt={statusIconAlt} />
61
73
  {/if}
62
74
  </div>
63
75
  <svelte:element
@@ -1,6 +1,17 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { ButtonProperties } from '../Button/properties';
3
3
  export type StatusProperties = StatusEventProperties & {
4
+ /**
5
+ * URL of the icon shown above the title. Omit it to get the built-in
6
+ * checkmark, which is inlined and costs no request.
7
+ *
8
+ * A URL you pass is used as given and never replaced when it fails to load:
9
+ * answering a failed failure-icon with a success checkmark would be worse
10
+ * than showing nothing. The one exception is the literal
11
+ * `'icons/order-success-icon.svg'` (the former default), which still falls
12
+ * back to the built-in checkmark so existing callers that spell it out are
13
+ * unchanged.
14
+ */
4
15
  statusIcon?: string;
5
16
  /**
6
17
  * Accessible name for the `statusIcon` image. Defaults to `'status'`, which
@@ -36,14 +36,19 @@
36
36
  value,
37
37
  rowIndex,
38
38
  originalIndex,
39
+ ariaLabel,
39
40
  usePortal = false
40
41
  }: {
41
42
  column: TableColumn;
42
43
  value: TableCellValue;
43
44
  rowIndex: number;
44
45
  originalIndex: number;
46
+ ariaLabel?: string;
45
47
  usePortal?: boolean;
46
48
  } = $props();
49
+ const fieldLabel = $derived(
50
+ ariaLabel?.trim() || `${column.label || column.id} for row ${originalIndex + 1}`
51
+ );
47
52
 
48
53
  // The td applies column.align as text-align, which flex containers ignore:
49
54
  // column-flex builtins stretch their children and row-flex builtins pack to
@@ -301,20 +306,22 @@
301
306
  {:else if column.type === 'toggle'}
302
307
  {@const data = asJsonObject(value) ?? {}}
303
308
  {@const isChecked = data.checked === true}
309
+ <!-- This is only an event boundary; the native Toggle input owns interaction. -->
310
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
304
311
  <span
305
312
  class="builtin-toggle"
306
- role="switch"
307
- aria-checked={isChecked ? 'true' : 'false'}
308
- aria-label={typeof data.ariaLabel === 'string' ? data.ariaLabel : null}
309
313
  data-pw={typeof data.testId === 'string' ? data.testId : null}
310
314
  testID={typeof data.testId === 'string' ? data.testId : null}
311
315
  onclick={stopClickPropagation}
312
316
  onkeydown={stopKeydownPropagation}
313
- tabindex={-1}
314
317
  >
315
318
  <Toggle
316
319
  checked={isChecked}
317
320
  text=""
321
+ inputRole="switch"
322
+ ariaLabel={typeof data.ariaLabel === 'string' && data.ariaLabel.trim()
323
+ ? data.ariaLabel
324
+ : fieldLabel}
318
325
  onclick={(newChecked) => column.onToggle?.(rowIndex, newChecked, originalIndex)}
319
326
  />
320
327
  </span>
@@ -328,6 +335,7 @@
328
335
  onkeydown={stopKeydownPropagation}
329
336
  >
330
337
  <Select
338
+ ariaLabel={selectData.ariaLabel?.trim() || fieldLabel}
331
339
  items={selectData.options}
332
340
  value={selectData.selectedId ? [selectData.selectedId] : []}
333
341
  placeholder={selectData.placeholder ?? ''}
@@ -360,7 +368,7 @@
360
368
  <Input
361
369
  value={inputData.value ?? ''}
362
370
  placeholder={inputData.placeholder ?? ''}
363
- ariaLabel={inputData.ariaLabel ?? null}
371
+ ariaLabel={inputData.ariaLabel?.trim() || fieldLabel}
364
372
  disable={inputData.disabled ?? false}
365
373
  testId={inputData.testId ?? ''}
366
374
  dataType={asInputDataType(inputData.dataType ?? null)}
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  value: TableCellValue;
5
5
  rowIndex: number;
6
6
  originalIndex: number;
7
+ ariaLabel?: string;
7
8
  usePortal?: boolean;
8
9
  };
9
10
  declare const BuiltinCell: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -6,6 +6,7 @@
6
6
  SortDirection
7
7
  } from './properties';
8
8
  import { normalizeColumns } from './normalizeColumns';
9
+ import { rowContextLabels, selectionControlIds } from './a11y';
9
10
  import BuiltinCell from './BuiltinCell.svelte';
10
11
  import type { JSONValue } from 'type-decoder';
11
12
  import { SvelteSet } from 'svelte/reactivity';
@@ -74,6 +75,9 @@
74
75
  // (pre-sort/pre-filter). Row refs are preserved through sort/filter/paginate,
75
76
  // so cell callbacks can hand consumers a sort-stable `originalIndex`.
76
77
  let originalIndexByRow = $derived(new Map(effectiveData.map((row, index) => [row, index])));
78
+ const tableUid = $props.id();
79
+ const selectionId = selectionControlIds(tableUid);
80
+ let rowLabels = $derived(rowContextLabels(effectiveData, columns));
77
81
  let effectiveSortableColumns = $derived(
78
82
  normalized ? normalized.sortableColumns : sortableColumns
79
83
  );
@@ -544,7 +548,7 @@
544
548
  rowIndex: number,
545
549
  originalIndex: number
546
550
  ): string => {
547
- return cfg.getRowId ? cfg.getRowId(row, rowIndex, originalIndex) : String(rowIndex);
551
+ return cfg.getRowId ? cfg.getRowId(row, rowIndex, originalIndex) : String(originalIndex);
548
552
  };
549
553
 
550
554
  let internalSelectedIds = new SvelteSet<string>();
@@ -569,12 +573,9 @@
569
573
  /**
570
574
  * Stable string ID for every row in the currently visible (filtered) view.
571
575
  *
572
- * Each entry is resolved by iterating `filteredTableData` and looking up the
573
- * row's pre-sort position via `sortedTableData.indexOf(row)`. Using the
574
- * pre-filter index as the default numeric ID keeps `String(originalIndex)`
575
- * stable even when the visible set shrinks or expands as the search term
576
- * changes — so a row that was "row 2" in the full set keeps ID `"2"` even
577
- * when it becomes the only visible result.
576
+ * The callback retains its current pre-filter sorted index plus originalIndex.
577
+ * The default ID uses the source index, so sort/filter/page transforms do not
578
+ * silently transfer selection to a different record.
578
579
  *
579
580
  * The Map lookup uses object reference equality, so row objects in
580
581
  * `filteredTableData` must be the same references as those in
@@ -594,7 +595,7 @@
594
595
  originalIndexByRow.get(row) ?? stableIndex
595
596
  );
596
597
  }
597
- return String(stableIndex);
598
+ return String(originalIndexByRow.get(row) ?? stableIndex);
598
599
  });
599
600
  });
600
601
 
@@ -718,13 +719,13 @@
718
719
  };
719
720
 
720
721
  /**
721
- * Attributes for one row checkbox (`rowIndex` -1 is the header). The row id
722
- * doubles as the element id the header's `aria-controls` points at; the
723
- * consumer's `getRowAttributes` is spread last so its own test attribute wins.
722
+ * Attributes for one checkbox (`rowIndex` -1 is the header). DOM identity is
723
+ * separate from the consumer's selection key. Preserve other caller attributes
724
+ * and valid unique custom IDs, while managing unsafe/colliding IDs ourselves.
724
725
  */
725
726
  const checkboxAttributes = (rowId: string, rowIndex: number): Record<string, string> => {
726
727
  const suffix = rowIndex === -1 ? 'select-all' : `row-checkbox-${rowId}`;
727
- const own: Record<string, string> = rowIndex === -1 ? {} : { id: `row-checkbox-${rowId}` };
728
+ const own: Record<string, string> = {};
728
729
  if (typeof testId === 'string') {
729
730
  own['data-pw'] = `${testId}-${suffix}`;
730
731
  own.testID = `${testId}-${suffix}`;
@@ -732,9 +733,36 @@
732
733
  const consumer = checkboxSelection?.getRowAttributes
733
734
  ? checkboxSelection.getRowAttributes(rowId, rowIndex)
734
735
  : {};
735
- return { ...own, ...consumer };
736
+ return {
737
+ ...own,
738
+ ...consumer,
739
+ id: selectionId(rowId, rowIndex === -1, consumer.id)
740
+ };
736
741
  };
737
742
 
743
+ let headerCheckboxAttributes = $derived(checkboxAttributes('__header__', -1));
744
+ // Evaluate caller attributes once for each rendered control. Both the DOM and
745
+ // its IDREF list consume the same result, including a caller-supplied ID.
746
+ let renderedCheckboxAttributes = $derived.by(() => {
747
+ if (!checkboxSelection || checkboxSelection.enabled === false) {
748
+ return new Map<JSONValue[], Record<string, string>>();
749
+ }
750
+ return new Map(
751
+ paginatedTableData.map((row, index) => {
752
+ const rowIndex = index + rowIndexOffset;
753
+ const rowId = rowIdByRow.get(row) ?? String(originalIndexByRow.get(row) ?? rowIndex);
754
+ return [row, checkboxAttributes(rowId, rowIndex)];
755
+ })
756
+ );
757
+ });
758
+ let controlledRowControlIds = $derived(
759
+ paginatedTableData.flatMap((row) => {
760
+ const rowId = rowIdByRow.get(row);
761
+ const id = renderedCheckboxAttributes.get(row)?.id;
762
+ return typeof rowId === 'string' && !isRowDisabled(rowId) && id ? [id] : [];
763
+ })
764
+ );
765
+
738
766
  // ─── Empty-state reason ───────────────────────────────────────────────────
739
767
  // "No records yet" and "nothing matched" need different messages and offer
740
768
  // different remedies, and a consumer cannot tell them apart from `rows` alone
@@ -821,7 +849,14 @@
821
849
  {#if caption}
822
850
  <caption class="sr-only">{caption}</caption>
823
851
  {/if}
824
- <thead role={mobileRole('rowgroup')}>
852
+ <thead
853
+ role={mobileRole('rowgroup')}
854
+ class:mobile-controls={(isCheckboxMode && !isSingleSelect) ||
855
+ (hasSearchConfig && isInlineSearch) ||
856
+ effectiveHeaders.some(
857
+ (_header, index) => isColumnSortable(index) || Boolean(columns?.[index]?.tooltip)
858
+ )}
859
+ >
825
860
  <tr role={mobileRole('row')}>
826
861
  {#if isCheckboxMode && !isSingleSelect}
827
862
  <th
@@ -837,10 +872,8 @@
837
872
  controlled
838
873
  checked={headerCheckboxState === 'all'}
839
874
  indeterminate={headerCheckboxState === 'some'}
840
- ariaControls={selectableRowIds
841
- .map((rowId) => `row-checkbox-${rowId}`)
842
- .join(' ')}
843
- attributes={checkboxAttributes('__header__', -1)}
875
+ ariaControls={controlledRowControlIds.join(' ')}
876
+ attributes={headerCheckboxAttributes}
844
877
  onclick={toggleAllSelection}
845
878
  />
846
879
  </th>
@@ -1096,7 +1129,7 @@
1096
1129
  controlled
1097
1130
  checked={rowSelected}
1098
1131
  disabled={rowDisabled}
1099
- attributes={checkboxAttributes(rowId, rowIndex)}
1132
+ attributes={renderedCheckboxAttributes.get(row)}
1100
1133
  onclick={() => toggleRowSelection(rowId)}
1101
1134
  />
1102
1135
  </td>
@@ -1148,9 +1181,16 @@
1148
1181
  {rowIndex}
1149
1182
  {originalIndex}
1150
1183
  {usePortal}
1184
+ ariaLabel={labels
1185
+ ?.cellEditor?.(
1186
+ cellLabel,
1187
+ rowLabels.get(row) ?? `row ${originalIndex + 1}`
1188
+ )
1189
+ ?.trim() ||
1190
+ `${cellLabel} for ${rowLabels.get(row) ?? `row ${originalIndex + 1}`}`}
1151
1191
  />
1152
1192
  {:else if typeof cell === 'function'}
1153
- {@render cell(cellValue, rowIndex, colIndex)}
1193
+ {@render cell(cellValue, rowIndex, colIndex, originalIndex)}
1154
1194
  {:else}
1155
1195
  {cellValue}
1156
1196
  {/if}
@@ -1790,6 +1830,7 @@
1790
1830
  align-items: center;
1791
1831
  gap: var(--table-paginator-gap, 12px);
1792
1832
  flex-wrap: wrap;
1833
+ min-width: 0;
1793
1834
  }
1794
1835
 
1795
1836
  .table-paginator-controls :global(.pagination) {
@@ -1872,6 +1913,10 @@
1872
1913
  .table-mobile-cards td {
1873
1914
  display: block;
1874
1915
  width: 100%;
1916
+ /* `width: 100%` is the card's outer width only if padding and border are
1917
+ inside it: the card row has both, so under content-box it was 26px wider
1918
+ than the table and its right edge ran past the column. */
1919
+ box-sizing: border-box;
1875
1920
  }
1876
1921
 
1877
1922
  /* Headers are not shown as a row anymore -- their text now repeats per
@@ -1891,6 +1936,19 @@
1891
1936
  border-width: 0;
1892
1937
  }
1893
1938
 
1939
+ /* A screen-reader-only header must not conceal operative sort or select-all
1940
+ controls. Keep those headers visible above the record cards, with the same
1941
+ controls, names and table relationships used at desktop width. */
1942
+ .table-mobile-cards thead.mobile-controls {
1943
+ position: static;
1944
+ width: 100%;
1945
+ height: auto;
1946
+ overflow: visible;
1947
+ clip: auto;
1948
+ clip-path: none;
1949
+ white-space: normal;
1950
+ }
1951
+
1894
1952
  /* The horizontal-scroll affordance this table uses at desktop width (see
1895
1953
  .table-scroll / .table-scroll-shell above) has nothing to scroll once
1896
1954
  every cell is a full-width block, and a lingering scrim would fade a
@@ -0,0 +1,7 @@
1
+ import type { TableCellValue, TableColumn } from './properties';
2
+ /** Readable row context, independent of editable/selected control values. */
3
+ export declare function rowContextLabel(row: readonly TableCellValue[], originalIndex: number, columns?: readonly TableColumn[]): string;
4
+ /** Constructed once per data projection, then read without mutation by Table. */
5
+ export declare function rowContextLabels(rows: readonly TableCellValue[][], columns?: readonly TableColumn[]): ReadonlyMap<TableCellValue[], string>;
6
+ /** Opaque per-instance IDs: distinct raw keys never collapse through slugging. */
7
+ export declare function selectionControlIds(instanceId: string): (key: string, header: boolean, requestedId?: string) => string;
@@ -0,0 +1,71 @@
1
+ const controlTypes = new Set(['input', 'select', 'toggle', 'button', 'action-group', 'popup-menu']);
2
+ /** Readable row context, independent of editable/selected control values. */
3
+ export function rowContextLabel(row, originalIndex, columns) {
4
+ for (const [index, value] of row.entries()) {
5
+ if (controlTypes.has(columns?.[index]?.type ?? '')) {
6
+ continue;
7
+ }
8
+ if (typeof value === 'string' && value.trim() !== '') {
9
+ return value;
10
+ }
11
+ if (typeof value === 'number') {
12
+ return String(value);
13
+ }
14
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
15
+ for (const key of ['text1', 'text', 'label', 'title', 'name']) {
16
+ const text = value[key];
17
+ if (typeof text === 'string' && text.trim() !== '') {
18
+ return text;
19
+ }
20
+ }
21
+ }
22
+ }
23
+ return `row ${originalIndex + 1}`;
24
+ }
25
+ /** Constructed once per data projection, then read without mutation by Table. */
26
+ export function rowContextLabels(rows, columns) {
27
+ const captions = rows.map((row, index) => rowContextLabel(row, index, columns));
28
+ const counts = new Map();
29
+ for (const label of captions) {
30
+ counts.set(label, (counts.get(label) ?? 0) + 1);
31
+ }
32
+ return new Map(rows.map((row, index) => [
33
+ row,
34
+ (counts.get(captions[index]) ?? 0) > 1
35
+ ? `${captions[index]}, record ${index + 1}`
36
+ : captions[index]
37
+ ]));
38
+ }
39
+ /** Opaque per-instance IDs: distinct raw keys never collapse through slugging. */
40
+ export function selectionControlIds(instanceId) {
41
+ const rows = new Map();
42
+ const owners = new Map();
43
+ const headerId = `table-${instanceId}-select-all`;
44
+ owners.set(headerId, 'header');
45
+ let sequence = 0;
46
+ const rowId = (key) => {
47
+ let id = rows.get(key);
48
+ if (typeof id !== 'string') {
49
+ do {
50
+ id = `table-${instanceId}-row-selection-${++sequence}`;
51
+ } while (owners.has(id));
52
+ rows.set(key, id);
53
+ owners.set(id, `row:${key}`);
54
+ }
55
+ return id;
56
+ };
57
+ return (key, header, requestedId) => {
58
+ const owner = header ? 'header' : `row:${key}`;
59
+ const generated = header ? headerId : rowId(key);
60
+ // Valid caller IDs remain usable for external labels/CSS. Invalid or conflicting
61
+ // IDs fall back to the managed ID; ownership persists across filter/page changes.
62
+ if (typeof requestedId === 'string' &&
63
+ requestedId !== '' &&
64
+ !/\s/.test(requestedId) &&
65
+ (!owners.has(requestedId) || owners.get(requestedId) === owner)) {
66
+ owners.set(requestedId, owner);
67
+ return requestedId;
68
+ }
69
+ return generated;
70
+ };
71
+ }
@@ -154,6 +154,9 @@ export const asSelectCellData = (value) => {
154
154
  options.push({ id: optionRecord.id, label: optionRecord.label });
155
155
  }
156
156
  const selectData = { options };
157
+ if (typeof record.ariaLabel === 'string') {
158
+ selectData.ariaLabel = record.ariaLabel;
159
+ }
157
160
  if (typeof record.selectedId === 'string') {
158
161
  selectData.selectedId = record.selectedId;
159
162
  }
@@ -128,6 +128,8 @@ export type TableSelectCellData = {
128
128
  selectedId?: string;
129
129
  placeholder?: string;
130
130
  disabled?: boolean;
131
+ /** Overrides the generated column/row field name on the real combobox. */
132
+ ariaLabel?: string;
131
133
  testId?: string;
132
134
  /** Per-option test id prefix — each option emits `data-pw="{itemTestId}-{id}"`. */
133
135
  itemTestId?: string;
@@ -349,7 +351,7 @@ export type TableColumn = {
349
351
  * - `onSelectionChange` — called whenever the selection set changes, receives
350
352
  * the new set of selected row IDs.
351
353
  * - `getRowId` — derives a stable string ID from a row + its index. Defaults
352
- * to `String(rowIndex)` when omitted.
354
+ * to the original source index when omitted.
353
355
  * - `disabledRowIds` — rows whose IDs appear in this set render a disabled,
354
356
  * unchecked checkbox and cannot be selected.
355
357
  */
@@ -376,7 +378,9 @@ export type TableCheckboxSelectionConfig = {
376
378
  /**
377
379
  * Generic DOM-attribute spread onto each row checkbox (`rowIndex` is `-1`
378
380
  * for the header select-all). An escape hatch for consumer-specific
379
- * attributes (e.g. native test IDs) without the library learning them.
381
+ * attributes (e.g. native test IDs) without the library learning them. A custom
382
+ * `id` must be stable and unique within its DOM scope; blank, whitespace-bearing
383
+ * or conflicting IDs use the generated per-instance control ID instead.
380
384
  */
381
385
  getRowAttributes?: (rowId: string, rowIndex: number) => Record<string, string>;
382
386
  };
@@ -521,8 +525,8 @@ export type TableProperties = OptionalTableProperties & TableEventProperties;
521
525
  * not ship an English UI.
522
526
  *
523
527
  * Every member is optional and falls back to an English default. All but
524
- * `rowsPerPage` keep the string Table has always used; `rowsPerPage` adds a
525
- * name to a control that previously had none.
528
+ * `rowsPerPage` and `cellEditor` keep the string Table has always used; those
529
+ * two add a name to a control that previously had none.
526
530
  *
527
531
  * The two that depend on a column, and the one that depends on a row, are
528
532
  * functions rather than templates with a placeholder: a translator needs to
@@ -547,6 +551,8 @@ export type TableLabels = {
547
551
  clearSearch?: string;
548
552
  /** Button that dismisses the search box. Default: `Close search`. */
549
553
  closeSearch?: string;
554
+ /** Built-in editor name. Default: `${columnLabel} for ${rowLabel}`. */
555
+ cellEditor?: (columnLabel: string, rowLabel: string) => string;
550
556
  /** Page-size selector in the built-in paginator footer. Default: `Rows per page`. */
551
557
  rowsPerPage?: string;
552
558
  };
@@ -667,7 +673,9 @@ export type OptionalTableProperties = {
667
673
  sortAscIcon?: Snippet;
668
674
  sortDescIcon?: Snippet;
669
675
  sortDefaultIcon?: Snippet;
670
- cell?: Snippet<[JSONValue, number, number]>;
676
+ /** Value, current row index, column index and stable original source index.
677
+ * Existing three-parameter snippets keep working and may ignore the added index. */
678
+ cell?: Snippet<[JSONValue, number, number, number]>;
671
679
  /**
672
680
  * Rendered in place of the rows when the view has none. Receives why
673
681
  * (`TableEmptyContext`) so one snippet can serve both "no records yet" and
@@ -33,6 +33,10 @@
33
33
  onchange
34
34
  }: ThemeSwitcherProperties = $props();
35
35
 
36
+ // `value` is the INITIAL selection, not a controlled binding (docs/ThemeSwitcher.md): once
37
+ // mounted the switcher owns the selection -- clicks, the stored preference and onMount below
38
+ // move it -- so a later change to the prop is deliberately not followed.
39
+ // svelte-ignore state_referenced_locally
36
40
  let currentValue: string = $state(value ?? 'system');
37
41
  let systemPreference: string = $state('light');
38
42