@juspay/svelte-ui-components 4.52.1 → 4.53.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 (35) hide show
  1. package/dist/AttachmentChipRow/AttachmentChipRow.svelte +3 -2
  2. package/dist/AttachmentChipRow/properties.d.ts +2 -0
  3. package/dist/Book/Book.svelte +2 -0
  4. package/dist/Book/properties.d.ts +2 -0
  5. package/dist/Chat/Chat.svelte +2 -0
  6. package/dist/Chat/properties.d.ts +2 -0
  7. package/dist/Chat/types.d.ts +2 -0
  8. package/dist/ChatMessage/ChatMessage.svelte +18 -3
  9. package/dist/ChatMessage/properties.d.ts +6 -0
  10. package/dist/ChatMessageList/ChatMessageList.svelte +31 -7
  11. package/dist/ChatMessageList/properties.d.ts +2 -0
  12. package/dist/Choicebox/Choicebox.svelte +33 -23
  13. package/dist/Draggable/Draggable.svelte +4 -2
  14. package/dist/MediaPlayer/MediaPlayer.svelte +28 -13
  15. package/dist/MediaPlayer/properties.d.ts +2 -0
  16. package/dist/Modal/Modal.svelte +30 -43
  17. package/dist/Modal/properties.d.ts +5 -4
  18. package/dist/Pagination/Pagination.svelte +2 -0
  19. package/dist/Pagination/properties.d.ts +2 -0
  20. package/dist/Pill/Pill.svelte +61 -4
  21. package/dist/Pill/properties.d.ts +8 -16
  22. package/dist/Radio/Radio.svelte +6 -2
  23. package/dist/RatingGroup/RatingGroup.svelte +38 -26
  24. package/dist/Select/Select.svelte +8 -0
  25. package/dist/StatCard/StatCard.svelte +68 -17
  26. package/dist/StatCard/properties.d.ts +1 -1
  27. package/dist/Stepper/Step.svelte +2 -2
  28. package/dist/Table/Table.svelte +32 -2
  29. package/dist/Table/properties.d.ts +11 -5
  30. package/dist/Tabs/Tabs.svelte +187 -12
  31. package/dist/utils.js +20 -2
  32. package/dist-codemod/codemod/cli.js +4 -4
  33. package/dist-wc/index.js +2964 -2589
  34. package/package.json +1 -1
  35. package/scripts/postinstall.mjs +13 -15
@@ -53,15 +53,25 @@
53
53
  let interactive = $derived(typeof onclick === 'function');
54
54
 
55
55
  // A <button> may not contain another button, and a dismissible pill is inherently two
56
- // controls. Rather than emit invalid markup, that combination degrades to the div root —
57
- // the same graceful-degradation rule Card applies to `as="a"` with no `href`.
56
+ // controls. A neutral div holds independent primary and dismiss buttons when
57
+ // both actions are present, preserving native activation for each action.
58
58
  const isButtonRoot = $derived(as === 'button' && !dismissible);
59
59
  const rootTag = $derived(isButtonRoot ? 'button' : 'div');
60
+ const hasSeparateAction = $derived(interactive && dismissible);
61
+ const rootAttrs = $derived(
62
+ hasSeparateAction
63
+ ? Object.fromEntries(
64
+ Object.entries(attrs ?? {}).filter(
65
+ ([key]) => !['aria-label', 'aria-labelledby', 'aria-describedby'].includes(key)
66
+ )
67
+ )
68
+ : attrs
69
+ );
60
70
 
61
71
  // The synthetic shim exists only because a <div> has no button semantics. A real button
62
72
  // already has them, and adding role/tabindex/keydown on top would duplicate the platform's
63
73
  // own activation — Enter would fire the handler twice.
64
- const needsInteractiveShim = $derived(interactive && !isButtonRoot);
74
+ const needsInteractiveShim = $derived(interactive && !isButtonRoot && !hasSeparateAction);
65
75
 
66
76
  // aria-expanded / aria-pressed / aria-disabled describe a control's state, so they are
67
77
  // meaningless on a plain label and are emitted only once the pill actually is one.
@@ -102,9 +112,10 @@
102
112
 
103
113
  <svelte:element
104
114
  this={rootTag}
105
- {...attrs ?? {}}
115
+ {...rootAttrs ?? {}}
106
116
  class="pill {pillToneClass(tone)} {classes ?? ''}"
107
117
  class:disabled
118
+ class:separate-action={hasSeparateAction}
108
119
  type={isButtonRoot ? 'button' : null}
109
120
  onclick={interactive ? handleClick : null}
110
121
  onkeydown={needsInteractiveShim ? handleKeydown : null}
@@ -117,6 +128,22 @@
117
128
  title={title ?? null}
118
129
  testID={typeof testId === 'string' ? testId : null}
119
130
  >
131
+ {#if hasSeparateAction}
132
+ <button
133
+ type="button"
134
+ class="pill-action"
135
+ {disabled}
136
+ aria-label={typeof attrs?.['aria-label'] === 'string' ? attrs['aria-label'] : text}
137
+ aria-labelledby={typeof attrs?.['aria-labelledby'] === 'string'
138
+ ? attrs['aria-labelledby']
139
+ : null}
140
+ aria-describedby={typeof attrs?.['aria-describedby'] === 'string'
141
+ ? attrs['aria-describedby']
142
+ : null}
143
+ aria-expanded={typeof ariaExpanded === 'boolean' ? ariaExpanded : null}
144
+ aria-pressed={typeof ariaPressed === 'boolean' ? ariaPressed : null}
145
+ ></button>
146
+ {/if}
120
147
  {#if typeof leadingIcon === 'function'}
121
148
  <span class="pill-leading-icon">{@render leadingIcon()}</span>
122
149
  {/if}
@@ -143,6 +170,35 @@
143
170
  </svelte:element>
144
171
 
145
172
  <style>
173
+ .separate-action {
174
+ position: relative;
175
+ }
176
+
177
+ .pill-action {
178
+ position: absolute;
179
+ inset: 0;
180
+ border: 0;
181
+ padding: 0;
182
+ border-radius: inherit;
183
+ background: transparent;
184
+ cursor: inherit;
185
+ outline: none;
186
+ }
187
+
188
+ .separate-action:has(> .pill-action:focus-visible) {
189
+ outline: var(--pill-focus-outline, 2px solid currentColor);
190
+ outline-offset: var(--pill-focus-outline-offset, 2px);
191
+ }
192
+
193
+ .separate-action > .pill-text,
194
+ .separate-action > .pill-leading-icon {
195
+ pointer-events: none;
196
+ }
197
+
198
+ .separate-action > .pill-dismiss {
199
+ position: relative;
200
+ }
201
+
146
202
  .pill {
147
203
  display: inline-flex;
148
204
  align-items: center;
@@ -176,6 +232,7 @@
176
232
  flex-shrink: var(--pill-flex-shrink);
177
233
  }
178
234
 
235
+ .pill.separate-action,
179
236
  .pill[role='button'],
180
237
  button.pill {
181
238
  cursor: var(--pill-cursor, pointer);
@@ -66,11 +66,10 @@ export type MandatoryPillProperties = {
66
66
  */
67
67
  export type PillTone = 'accent' | 'ok' | 'warn' | 'danger' | 'muted';
68
68
  /**
69
- * Root element for the pill. `'div'` is the default and renders exactly as before —
70
- * non-interactive, or carrying the synthetic `role="button"`/`tabindex`/keydown shim when
71
- * an `onclick` is supplied. `'button'` renders a real `<button type="button">` instead, so
72
- * a chip-shaped control gets native focus, activation and announcement rather than an
73
- * imitation of them.
69
+ * Root element for a label or single-action pill. The default div is a label,
70
+ * or a keyboard-operable button role when onclick is supplied. A button root
71
+ * uses native activation. Interactive dismissible pills use a neutral container
72
+ * with independent native primary and dismiss buttons.
74
73
  */
75
74
  export type PillRootTag = 'div' | 'button';
76
75
  export type OptionalPillProperties = {
@@ -83,17 +82,10 @@ export type OptionalPillProperties = {
83
82
  */
84
83
  tone?: PillTone;
85
84
  /**
86
- * Root element. Defaults to `'div'`, which renders exactly as before this prop existed.
87
- *
88
- * `'button'` gives a chip real control semantics — native keyboard activation, focus and
89
- * announcement — instead of the `role="button"`/`tabindex`/keydown shim a `<div>` needs.
90
- * Reach for it when the chip is a disclosure or a toggle (see `ariaExpanded` /
91
- * `ariaPressed`), which a shimmed div cannot express correctly.
92
- *
93
- * One combination cannot be honoured: a `<button>` may not contain another button, and a
94
- * `dismissible` pill is inherently two controls. `as="button"` together with
95
- * `dismissible` therefore falls back to the `div` root and its shim rather than emitting
96
- * invalid markup — the same rule `Card` applies to `as="a"` with no `href`.
85
+ * Root element for a label or single action. Defaults to 'div'; 'button' uses
86
+ * native keyboard activation and focus. Dismissible pills use a neutral div
87
+ * container. When onclick is supplied, that container holds independent native
88
+ * primary and dismiss buttons, including when as='button'.
97
89
  */
98
90
  as?: PillRootTag;
99
91
  /**
@@ -102,6 +102,7 @@
102
102
 
103
103
  <style>
104
104
  .radio-container {
105
+ position: relative;
105
106
  display: var(--radio-container-display, inline-flex);
106
107
  align-items: var(--radio-container-align-items, center);
107
108
  gap: var(--radio-container-gap, 8px);
@@ -116,8 +117,11 @@
116
117
  .radio-input {
117
118
  position: absolute;
118
119
  opacity: 0;
119
- width: 0;
120
- height: 0;
120
+ width: var(--radio-size, 20px);
121
+ height: var(--radio-size, 20px);
122
+ inset-inline-start: 0;
123
+ top: 50%;
124
+ transform: translateY(-50%);
121
125
  margin: 0;
122
126
  pointer-events: none;
123
127
  }
@@ -130,13 +130,23 @@
130
130
 
131
131
  let root: HTMLDivElement | null = $state(null);
132
132
 
133
- // The hidden native input is tabindex="-1", so the browser's own "focus the invalid
134
- // field" step would land on an element the user cannot see or reach. Focus the
135
- // role="slider" element that carries the group's single tab stop instead -- the same
136
- // pattern Checkbox.svelte uses for its own hidden control.
137
- function handleInvalid(e: Event): void {
138
- e.preventDefault();
139
- root?.focus();
133
+ // The browser decides whether validation is interactive and which invalid
134
+ // field is first. Forward the carrier's actual focus to the visible slider;
135
+ // an invalid event alone must not move focus during checkValidity().
136
+ function handleNativeFocus(event: FocusEvent & { currentTarget: HTMLInputElement }): void {
137
+ const carrier = event.currentTarget;
138
+ // Let interactive validation finish before moving focus, so Firefox's
139
+ // native validation UI cannot swallow the user's next star click.
140
+ queueMicrotask(() => {
141
+ const carrierRoot = carrier.getRootNode();
142
+ if (
143
+ root?.isConnected &&
144
+ 'activeElement' in carrierRoot &&
145
+ carrierRoot.activeElement === carrier
146
+ ) {
147
+ root.focus();
148
+ }
149
+ });
140
150
  }
141
151
 
142
152
  // A native number input can't distinguish "explicitly rated 0" from "never touched",
@@ -191,25 +201,27 @@
191
201
  {/if}
192
202
  </span>
193
203
  {/each}
194
- <input
195
- type="number"
196
- class="native-input"
197
- tabindex={-1}
198
- aria-hidden="true"
199
- min={0}
200
- max={hasValidMax ? max : null}
201
- step={allowHalf ? 0.5 : 1}
202
- value={submittedValue === null ? '' : submittedValue}
203
- {disabled}
204
- {required}
205
- name={typeof name === 'string' && submittedValue !== null ? name : null}
206
- form={typeof form === 'string' ? form : null}
207
- oninvalid={handleInvalid}
208
- data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
209
- testID={typeof testId === 'string' ? `${testId}-native-input` : null}
210
- />
211
204
  </div>
212
205
 
206
+ <!-- Native form participation remains outside the interactive accessibility owner. -->
207
+ <input
208
+ type="number"
209
+ class="native-input"
210
+ tabindex={-1}
211
+ aria-hidden="true"
212
+ min={0}
213
+ max={hasValidMax ? max : null}
214
+ step={allowHalf ? 0.5 : 1}
215
+ value={submittedValue === null ? '' : submittedValue}
216
+ {disabled}
217
+ {required}
218
+ name={typeof name === 'string' && submittedValue !== null ? name : null}
219
+ form={typeof form === 'string' ? form : null}
220
+ onfocus={handleNativeFocus}
221
+ data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
222
+ testID={typeof testId === 'string' ? `${testId}-native-input` : null}
223
+ />
224
+
213
225
  <style>
214
226
  .rating-group {
215
227
  display: var(--rating-group-display, inline-flex);
@@ -285,8 +297,8 @@
285
297
  .native-input {
286
298
  position: absolute;
287
299
  opacity: 0;
288
- width: 0;
289
- height: 0;
300
+ width: 1px;
301
+ height: 1px;
290
302
  pointer-events: none;
291
303
  }
292
304
  </style>
@@ -635,6 +635,14 @@
635
635
  }
636
636
  /* Outside the in-menu variant, Tab always means focus is leaving. */
637
637
  if (!searchInMenu) {
638
+ // Closing is rendered after this keydown. Firefox otherwise includes
639
+ // the still-present scrollable dropdown in native Tab navigation,
640
+ // then loses focus to body when that temporary target is removed.
641
+ // Exclude the closing panel immediately so native forward and backward
642
+ // navigation keep their real next stop, including across a shadow root.
643
+ if (dropdownEl !== null) {
644
+ dropdownEl.inert = true;
645
+ }
638
646
  close();
639
647
  break;
640
648
  }
@@ -31,6 +31,12 @@
31
31
  }: StatCardProperties = $props();
32
32
 
33
33
  const isInteractive = $derived(typeof onclick === 'function');
34
+ let actionButton: HTMLButtonElement | null = $state(null);
35
+ const actionName = $derived(
36
+ [title, value, subtitle]
37
+ .filter((text) => typeof text === 'string' && text.length > 0)
38
+ .join(' ') || 'Open metric details'
39
+ );
34
40
 
35
41
  /**
36
42
  * Which ONE value on this card rolls.
@@ -154,21 +160,26 @@
154
160
  hasTitle || Boolean(checkbox) || typeof headerRight === 'function'
155
161
  );
156
162
 
157
- const handleKeydown = (event: KeyboardEvent): void => {
158
- if (event.key === 'Enter' || event.key === ' ') {
159
- if (event.key === ' ') {
160
- event.preventDefault();
161
- }
162
- if (event.currentTarget instanceof HTMLElement) {
163
- event.currentTarget.click();
164
- }
165
- }
166
- };
167
-
168
163
  const handleCheckboxChange = (checked: boolean): void => {
169
164
  oncheckboxchange?.(checked);
170
165
  };
171
166
 
167
+ const handleCardClick = (event: MouseEvent): void => {
168
+ const target = event.composedPath()[0];
169
+ const control =
170
+ target instanceof Element
171
+ ? target.closest(
172
+ 'button, a[href], input, select, textarea, [role="button"], [role="checkbox"], [role="link"], [contenteditable="true"]'
173
+ )
174
+ : null;
175
+ // Snippet and slotted controls own their actions. Only this card's action
176
+ // button, or a click on its noninteractive body, delegates to onclick.
177
+ if (control !== null && control !== actionButton) {
178
+ return;
179
+ }
180
+ onclick?.(event);
181
+ };
182
+
172
183
  /**
173
184
  * Keep header checkbox interaction from bubbling to the card's click/keydown
174
185
  * handler — otherwise toggling the checkbox on an interactive card would also
@@ -179,18 +190,21 @@
179
190
  };
180
191
  </script>
181
192
 
182
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
193
+ <!-- Card pointer activation delegates to the same native action button's handler.
194
+ Child controls remain siblings of that button and keep their own semantics. -->
195
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
183
196
  <div
184
197
  {@attach measurePrimary(valueSignature)}
185
198
  class="statcard {classes ?? ''}"
186
199
  class:statcard-interactive={isInteractive}
187
200
  data-pw={typeof testId === 'string' ? testId : null}
188
201
  testID={typeof testId === 'string' ? testId : null}
189
- role={isInteractive ? 'button' : null}
190
- tabindex={isInteractive ? 0 : null}
191
- onclick={isInteractive ? onclick : null}
192
- onkeydown={isInteractive ? handleKeydown : null}
202
+ onclick={isInteractive ? handleCardClick : null}
193
203
  >
204
+ {#if isInteractive}
205
+ <button bind:this={actionButton} type="button" class="statcard-action" aria-label={actionName}
206
+ ></button>
207
+ {/if}
194
208
  {#if hasHeaderContent}
195
209
  <div
196
210
  class="statcard-header"
@@ -465,14 +479,51 @@
465
479
  }
466
480
 
467
481
  .statcard-interactive {
482
+ position: relative;
468
483
  cursor: var(--statcard-cursor, pointer);
469
484
  }
470
485
 
471
- .statcard-interactive:focus-visible {
486
+ .statcard-interactive:has(> .statcard-action:focus-visible) {
472
487
  outline: var(--statcard-focus-outline, 2px solid currentColor);
473
488
  outline-offset: var(--statcard-focus-outline-offset, 2px);
474
489
  }
475
490
 
491
+ .statcard-action {
492
+ position: absolute;
493
+ inset: 0;
494
+ z-index: 0;
495
+ padding: 0;
496
+ border: 0;
497
+ border-radius: inherit;
498
+ background: transparent;
499
+ cursor: inherit;
500
+ outline: none;
501
+ }
502
+
503
+ .statcard-interactive > :not(.statcard-action) {
504
+ position: relative;
505
+ z-index: 1;
506
+ pointer-events: none;
507
+ }
508
+
509
+ /* Text clicks reach the card action; controls and tooltip triggers stay usable. */
510
+ .statcard-interactive
511
+ :global(
512
+ :is(
513
+ button,
514
+ input,
515
+ select,
516
+ textarea,
517
+ a,
518
+ slot,
519
+ [role='checkbox'],
520
+ [tabindex],
521
+ .tooltip-container
522
+ )
523
+ ) {
524
+ pointer-events: auto;
525
+ }
526
+
476
527
  .statcard-header {
477
528
  order: var(--statcard-header-order, 0);
478
529
  display: flex;
@@ -183,7 +183,7 @@ export type OptionalStatCardProperties = {
183
183
  classes?: string;
184
184
  };
185
185
  export type StatCardEventProperties = {
186
- /** Makes the card interactive: adds `role="button"`, `tabindex=0`, and wires click/Enter/Space. */
186
+ /** Makes the card interactive with a native action button; child controls remain independent. */
187
187
  onclick?: (event: MouseEvent) => void;
188
188
  /** Fired when the header checkbox changes. */
189
189
  oncheckboxchange?: (checked: boolean) => void;
@@ -64,8 +64,8 @@
64
64
  onkeydown={handleKeydown}
65
65
  role={suppressRoleAndTabindex ? null : 'button'}
66
66
  tabindex={suppressRoleAndTabindex ? null : 0}
67
- aria-label={ariaLabel ?? null}
68
- aria-labelledby={ariaLabel ? null : labelId}
67
+ aria-label={suppressRoleAndTabindex ? null : (ariaLabel ?? null)}
68
+ aria-labelledby={suppressRoleAndTabindex || ariaLabel ? null : labelId}
69
69
  aria-current={status === 'active' ? 'step' : null}
70
70
  >
71
71
  {#if typeof icon === 'string' && icon.length > 0}
@@ -225,11 +225,24 @@
225
225
  // viewports, but a bare scroll container gives no visual hint that more
226
226
  // columns exist. Track whether either edge has hidden content and surface
227
227
  // it as edge scrims (see .table-scroll-shell styles).
228
+ // Caption identity must reach the actual keyboard scroll owner, not only its
229
+ // descendant table. Groups keep nested axes out of the landmark namespace and
230
+ // add no tab stop or keyboard handler; native overflow/fit focus stays intact.
231
+ let scrollCaption = $derived(
232
+ typeof caption === 'string' && caption.trim().length > 0 ? caption : null
233
+ );
234
+ let hasVerticalScrollOwner = $state(false);
228
235
  let canScrollLeft = $state(false);
229
236
  let canScrollRight = $state(false);
230
237
 
231
238
  const trackHorizontalScroll = (scrollNode: HTMLElement) => {
239
+ const containerNode = scrollNode.closest<HTMLElement>('.table-container');
232
240
  const updateScrollHints = () => {
241
+ const verticalOverflow = containerNode ? getComputedStyle(containerNode).overflowY : '';
242
+ hasVerticalScrollOwner =
243
+ containerNode !== null &&
244
+ (verticalOverflow === 'auto' || verticalOverflow === 'scroll') &&
245
+ containerNode.scrollHeight > containerNode.clientHeight;
233
246
  canScrollLeft = scrollNode.scrollLeft > 2;
234
247
  canScrollRight = scrollNode.scrollLeft + scrollNode.clientWidth < scrollNode.scrollWidth - 2;
235
248
  };
@@ -239,6 +252,9 @@
239
252
  // (async rows/columns arriving) change scrollWidth, so observe both.
240
253
  const hintResizeObserver = new ResizeObserver(updateScrollHints);
241
254
  hintResizeObserver.observe(scrollNode);
255
+ if (containerNode !== null) {
256
+ hintResizeObserver.observe(containerNode);
257
+ }
242
258
  const tableElement = scrollNode.querySelector('table');
243
259
  if (tableElement) {
244
260
  hintResizeObserver.observe(tableElement);
@@ -836,6 +852,8 @@
836
852
  <div
837
853
  class="table-container {isTableScrollable ? 'scrollable-table' : ''} {classes ?? ''}"
838
854
  class:table-mobile-cards={mobileCardLayout}
855
+ role={scrollCaption !== null && hasVerticalScrollOwner ? 'group' : null}
856
+ aria-label={hasVerticalScrollOwner ? scrollCaption : null}
839
857
  data-pw={testId}
840
858
  testID={testId}
841
859
  >
@@ -844,7 +862,12 @@
844
862
  class:scrollable-left={canScrollLeft}
845
863
  class:scrollable-right={canScrollRight}
846
864
  >
847
- <div class="table-scroll" use:trackHorizontalScroll>
865
+ <div
866
+ class="table-scroll"
867
+ role={scrollCaption !== null ? 'group' : null}
868
+ aria-label={scrollCaption}
869
+ use:trackHorizontalScroll
870
+ >
848
871
  <table role={mobileRole('table')}>
849
872
  {#if caption}
850
873
  <caption class="sr-only">{caption}</caption>
@@ -878,8 +901,9 @@
878
901
  />
879
902
  </th>
880
903
  {:else if isCheckboxMode && isSingleSelect}
881
- <!-- In single-select mode the header cell is an empty spacer -->
904
+ <!-- Selection is a real column even without a select-all action. -->
882
905
  <th
906
+ aria-label={labels?.selectionColumn ?? 'Row selection'}
883
907
  class="table-header table-checkbox-col"
884
908
  class:table-header-sticky={isStickyHeader}
885
909
  role={mobileRole('columnheader')}
@@ -1084,6 +1108,7 @@
1084
1108
  {#each paginatedTableData as row, pageRowIndex (rowIdByRow.get(row) ?? pageRowIndex)}
1085
1109
  {@const rowIndex = pageRowIndex + rowIndexOffset}
1086
1110
  {@const originalIndex = originalIndexByRow.get(row) ?? rowIndex}
1111
+ {@const rowContext = rowLabels.get(row) ?? `row ${originalIndex + 1}`}
1087
1112
  {@const rowId = rowIdByRow.get(row) ?? String(rowIndex)}
1088
1113
  {@const rowDisabled = isCheckboxMode && isRowDisabled(rowId)}
1089
1114
  {@const rowSelected = isCheckboxMode && isRowSelected(rowId)}
@@ -1094,6 +1119,9 @@
1094
1119
  class:table-summary-row={summaryRowIndex !== null &&
1095
1120
  originalIndex === summaryRowIndex}
1096
1121
  role={mobileRole('row')}
1122
+ aria-label={isRowClickable
1123
+ ? (labels?.rowAction?.(rowContext) ?? rowContext)
1124
+ : null}
1097
1125
  data-pw={typeof getRowTestId === 'function' ? getRowTestId(row, rowIndex) : null}
1098
1126
  testID={typeof getRowTestId === 'function' ? getRowTestId(row, rowIndex) : null}
1099
1127
  onclick={isRowClickable
@@ -1280,6 +1308,8 @@
1280
1308
  {/if}
1281
1309
  {#if !hideSteppers}
1282
1310
  <Pagination
1311
+ ariaLabel={labels?.pageNavigation ??
1312
+ (tableTitle ? `${tableTitle} pagination` : 'Pagination')}
1283
1313
  totalPages={paginationTotalPages}
1284
1314
  currentPage={effectivePage}
1285
1315
  hasMore={pagination.hasMore ?? false}
@@ -524,12 +524,11 @@ export type TableProperties = OptionalTableProperties & TableEventProperties;
524
524
  * Accessible names Table generates for its own controls, for callers who do
525
525
  * not ship an English UI.
526
526
  *
527
- * Every member is optional and falls back to an English default. All but
528
- * `rowsPerPage` and `cellEditor` keep the string Table has always used; those
529
- * two add a name to a control that previously had none.
527
+ * Every member is optional. Control labels use English defaults; rowAction
528
+ * uses the readable source-row caption.
530
529
  *
531
- * The two that depend on a column, and the one that depends on a row, are
532
- * functions rather than templates with a placeholder: a translator needs to
530
+ * Members that depend on a column or row are functions rather than templates
531
+ * with a placeholder: a translator needs to
533
532
  * put the header where their grammar wants it, not where an English sentence
534
533
  * happens to leave a slot.
535
534
  *
@@ -546,6 +545,8 @@ export type TableLabels = {
546
545
  * naming an empty row. Default: `Select row ${rowId || 'non-selectable'}`. */
547
546
  selectRow?: (rowId: string) => string;
548
547
  /** Header checkbox that selects every row on the page. Default: `Select all rows`. */
548
+ /** Accessible header name for the row-selection column, including single-selection mode. */
549
+ selectionColumn?: string;
549
550
  selectAllRows?: string;
550
551
  /** Button that empties the search box. Default: `Clear search`. */
551
552
  clearSearch?: string;
@@ -553,8 +554,12 @@ export type TableLabels = {
553
554
  closeSearch?: string;
554
555
  /** Built-in editor name. Default: `${columnLabel} for ${rowLabel}`. */
555
556
  cellEditor?: (columnLabel: string, rowLabel: string) => string;
557
+ /** Clickable row name. Receives stable readable source-row context. Default: rowLabel. */
558
+ rowAction?: (rowLabel: string) => string;
556
559
  /** Page-size selector in the built-in paginator footer. Default: `Rows per page`. */
557
560
  rowsPerPage?: string;
561
+ /** Page-navigation landmark name. Default: `Pagination`. */
562
+ pageNavigation?: string;
558
563
  };
559
564
  export type OptionalTableProperties = {
560
565
  labels?: TableLabels;
@@ -669,6 +674,7 @@ export type OptionalTableProperties = {
669
674
  isTableScrollable?: boolean;
670
675
  isContentScrollable?: boolean;
671
676
  testId?: string;
677
+ /** Visually hidden native table caption; non-empty also names existing scroll groups. */
672
678
  caption?: string;
673
679
  sortAscIcon?: Snippet;
674
680
  sortDescIcon?: Snippet;