@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
@@ -26,14 +26,15 @@
26
26
  removeIcon,
27
27
  fileIcon,
28
28
  testId,
29
- classes
29
+ classes,
30
+ ariaLabel = 'Pending attachments'
30
31
  }: AttachmentChipRowProperties = $props();
31
32
  </script>
32
33
 
33
34
  {#if images.length > 0 || videos.length > 0 || files.length > 0}
34
35
  <Scroller
35
36
  direction="horizontal"
36
- ariaLabel="Pending attachments"
37
+ {ariaLabel}
37
38
  showArrows={false}
38
39
  hideScrollbar={false}
39
40
  showGradient={false}
@@ -44,4 +44,6 @@ export type OptionalAttachmentChipRowProperties = {
44
44
  fileIcon?: Snippet;
45
45
  testId?: string;
46
46
  classes?: string;
47
+ /** Name of this attachment strip. Default: `Pending attachments`. */
48
+ ariaLabel?: string;
47
49
  };
@@ -11,6 +11,7 @@
11
11
  showNavigation = true,
12
12
  showPageIndicator = true,
13
13
  enableSwipe = false,
14
+ ariaLabel = 'Book',
14
15
  testId,
15
16
  previousIcon,
16
17
  nextIcon,
@@ -108,6 +109,7 @@
108
109
  onmousedown={handleMouseDown}
109
110
  onmouseup={handleMouseUp}
110
111
  role="region"
112
+ aria-label={ariaLabel}
111
113
  tabindex="0"
112
114
  >
113
115
  <div class="book-viewport">
@@ -14,6 +14,8 @@ export type OptionalBookProperties = {
14
14
  showNavigation?: boolean;
15
15
  showPageIndicator?: boolean;
16
16
  enableSwipe?: boolean;
17
+ /** Accessible name for the keyboard-operated book region. Defaults to "Book". */
18
+ ariaLabel?: string;
17
19
  testId?: string;
18
20
  previousIcon?: Snippet;
19
21
  nextIcon?: Snippet;
@@ -23,6 +23,7 @@
23
23
  hideScrollbar = false,
24
24
  jump = true,
25
25
  jumpLabel = 'Jump to latest',
26
+ messageListLabel,
26
27
  jumpIcon,
27
28
  toolStatus = null,
28
29
  suggestions = [],
@@ -97,6 +98,7 @@
97
98
  {/if}
98
99
 
99
100
  <ChatMessageList
101
+ ariaLabel={messageListLabel ?? (title.trim() ? `${title} messages` : 'Conversation messages')}
100
102
  {messages}
101
103
  {autoscroll}
102
104
  {scrollPolicy}
@@ -23,6 +23,8 @@ export type OptionalChatProperties = {
23
23
  hideScrollbar?: ChatMessageListProperties['hideScrollbar'];
24
24
  jump?: ChatMessageListProperties['jump'];
25
25
  jumpLabel?: ChatMessageListProperties['jumpLabel'];
26
+ /** Transcript name; defaults to the chat title plus `messages`, or `Conversation messages`. */
27
+ messageListLabel?: ChatMessageListProperties['ariaLabel'];
26
28
  jumpIcon?: ChatMessageListProperties['jumpIcon'];
27
29
  toolStatus?: ChatToolStatus | null;
28
30
  suggestions?: ChatSuggestion[];
@@ -11,6 +11,8 @@ export type ChatMessageData = {
11
11
  * MarkdownText). Wins over `html` and `content` in the bubble.
12
12
  */
13
13
  markdown?: string;
14
+ /** Accessible table scroll-region name for static and streamed markdown bubbles. */
15
+ markdownTableLabel?: string;
14
16
  streaming?: boolean;
15
17
  /**
16
18
  * Reveal this message progressively as it grows. Opt-in per message so it cannot stack with
@@ -10,12 +10,14 @@
10
10
  import { onDestroy, onMount } from 'svelte';
11
11
  import { partyOf } from '../Chat/roles';
12
12
  import type { ChatMessageProperties } from './properties';
13
+ import type { RenderMarkdownOptions } from '../MarkdownText/properties';
13
14
 
14
15
  let {
15
16
  role,
16
17
  content = '',
17
18
  html,
18
19
  markdown,
20
+ markdownTableLabel,
19
21
  body,
20
22
  streaming = false,
21
23
  typewriter = false,
@@ -90,7 +92,9 @@
90
92
  `marked` — a peer dependency — is only pulled in at runtime, never during
91
93
  SSR, and rendering `markdown` degrades to the `html`/`content` fallback
92
94
  until the module resolves or when the peer is absent. */
93
- let renderMarkdownFn = $state<((source: string) => string) | null>(null);
95
+ let renderMarkdownFn = $state<
96
+ ((source: string, options?: RenderMarkdownOptions) => string) | null
97
+ >(null);
94
98
 
95
99
  onMount(() => {
96
100
  void import('../MarkdownText/markdown')
@@ -102,8 +106,19 @@
102
106
  });
103
107
  });
104
108
 
109
+ // Both rendering branches share the same options-aware renderer. Capture the label
110
+ // reactively so a locale/name update also refreshes already-revealed typewriter HTML.
111
+ let messageMarkdownRenderer = $derived.by(() => {
112
+ const renderer = renderMarkdownFn;
113
+ if (renderer === null) {
114
+ return null;
115
+ }
116
+ const options: RenderMarkdownOptions =
117
+ typeof markdownTableLabel === 'string' ? { tableLabel: markdownTableLabel } : {};
118
+ return (source: string) => renderer(source, options);
119
+ });
105
120
  let effectiveHtml = $derived(
106
- hasMarkdown && renderMarkdownFn !== null ? renderMarkdownFn(markdown ?? '') : html
121
+ hasMarkdown && messageMarkdownRenderer !== null ? messageMarkdownRenderer(markdown ?? '') : html
107
122
  );
108
123
  let hasHtml = $derived(typeof effectiveHtml === 'string' && effectiveHtml.length > 0);
109
124
 
@@ -128,7 +143,7 @@
128
143
  (!hasMarkdown || renderMarkdownFn !== null)
129
144
  );
130
145
  let typewriterRenderText = $derived(
131
- hasMarkdown && renderMarkdownFn !== null ? { renderText: renderMarkdownFn } : {}
146
+ hasMarkdown && messageMarkdownRenderer !== null ? { renderText: messageMarkdownRenderer } : {}
132
147
  );
133
148
  // Clamped at the boundary the way LoadingDots clamps `dots`, so the prop's floor is part
134
149
  // of this component's contract rather than something a consumer discovers. The finite
@@ -20,6 +20,12 @@ export type OptionalChatMessageProperties = {
20
20
  * (for server-rendered markdown use `MarkdownText` or `renderMarkdown`).
21
21
  */
22
22
  markdown?: string;
23
+ /**
24
+ * Accessible name for each table scroll region rendered from `markdown`, including
25
+ * progressive/typewriter rendering. Omitted or empty leaves the shared renderer's
26
+ * unnamed, keyboard-scrollable wrapper unchanged. Does not alter `html` or `body`.
27
+ */
28
+ markdownTableLabel?: string;
23
29
  /**
24
30
  * Replaces the rendered bubble body with arbitrary markup while keeping the
25
31
  * message chrome (role styling, avatar, header, attachments, copy/retry/
@@ -28,13 +28,15 @@
28
28
  onretry,
29
29
  onfeedback,
30
30
  testId,
31
- classes
31
+ classes,
32
+ ariaLabel = 'Conversation messages'
32
33
  }: ChatMessageListProperties = $props();
33
34
 
34
35
  let listEl: HTMLElement | null = $state(null);
35
36
  let innerEl: HTMLElement | null = $state(null);
36
37
  let atBottom = $state(true);
37
38
  let scrollable = $state(false);
39
+ let keyboardScrollable = $state(false);
38
40
  let pinActive = false;
39
41
  // Set when a turn without pinHold has asked for its reservation back, but the reply is not yet
40
42
  // tall enough to hold the pinned position without it. Cleared by tryReleasePin.
@@ -78,6 +80,7 @@
78
80
 
79
81
  function reportScrollState(node: HTMLElement): void {
80
82
  atBottom = isNearBottom(node);
83
+ keyboardScrollable = node.scrollHeight - node.clientHeight > 1;
81
84
  scrollable = node.scrollHeight - node.clientHeight > NEAR_BOTTOM_THRESHOLD;
82
85
  onscrollstate?.({ atBottom, scrollable });
83
86
  }
@@ -260,19 +263,29 @@
260
263
  if (typeof ResizeObserver === 'undefined') {
261
264
  return;
262
265
  }
266
+ let pendingFrame: number | null = null;
263
267
  const observer = new ResizeObserver(() => {
264
- if (listEl === null) {
268
+ if (pendingFrame !== null) {
265
269
  return;
266
270
  }
267
- if (scrollPolicy === 'near-bottom' && autoscroll && atBottom) {
268
- listEl.scrollTop = listEl.scrollHeight;
269
- }
270
- tryReleasePin();
271
- reportScrollState(listEl);
271
+ pendingFrame = requestAnimationFrame(() => {
272
+ pendingFrame = null;
273
+ if (listEl === null || !node.isConnected) {
274
+ return;
275
+ }
276
+ if (scrollPolicy === 'near-bottom' && autoscroll && atBottom) {
277
+ listEl.scrollTop = listEl.scrollHeight;
278
+ }
279
+ tryReleasePin();
280
+ reportScrollState(listEl);
281
+ });
272
282
  });
273
283
  observer.observe(node);
274
284
  return {
275
285
  destroy(): void {
286
+ if (pendingFrame !== null) {
287
+ cancelAnimationFrame(pendingFrame);
288
+ }
276
289
  observer.disconnect();
277
290
  }
278
291
  };
@@ -291,9 +304,14 @@
291
304
  };
292
305
  </script>
293
306
 
307
+ <!-- The transcript is an independent scrolling surface. Native keyboard focus
308
+ enables the browser's scroll keys while it overflows, even without actions. -->
309
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
294
310
  <div
295
311
  class="chat-message-list {classes ?? ''}"
296
312
  role="log"
313
+ aria-label={ariaLabel}
314
+ tabindex={keyboardScrollable ? 0 : null}
297
315
  aria-live="polite"
298
316
  data-pw={typeof testId === 'string' ? testId : null}
299
317
  testID={typeof testId === 'string' ? testId : null}
@@ -323,6 +341,7 @@
323
341
  content={msg.content}
324
342
  html={msg.html}
325
343
  markdown={msg.markdown}
344
+ markdownTableLabel={msg.markdownTableLabel}
326
345
  body={typeof messageBody === 'function' ? bodyFor : null}
327
346
  streaming={msg.streaming}
328
347
  typewriter={msg.typewriter}
@@ -352,6 +371,11 @@
352
371
  </div>
353
372
 
354
373
  <style>
374
+ .chat-message-list:focus-visible {
375
+ outline: var(--chat-message-list-focus-outline, 2px solid currentColor);
376
+ outline-offset: -2px;
377
+ }
378
+
355
379
  .chat-message-list {
356
380
  box-sizing: border-box;
357
381
  display: flex;
@@ -44,6 +44,8 @@ export type OptionalChatMessageListProperties = {
44
44
  allowCopy?: boolean;
45
45
  testId?: string;
46
46
  classes?: string;
47
+ /** Accessible transcript name. Default: `Conversation messages`. */
48
+ ariaLabel?: string;
47
49
  };
48
50
  export type ChatMessageListEventProperties = {
49
51
  /**
@@ -82,12 +82,20 @@
82
82
  onclick?.(selected);
83
83
  }
84
84
 
85
- // The form control is `tabindex="-1"`, so the browser's own "focus the invalid
86
- // field" step would land on an element the user cannot see or reach. Focus the
87
- // card that carries the role instead.
88
- function handleInvalid(e: Event): void {
89
- e.preventDefault();
90
- card?.focus();
85
+ // Let native validation choose the first invalid control. Redirect only its
86
+ // actual focus to the visible card, so checkValidity() stays a silent query
87
+ // and later invalid fields cannot replace the first field's focus.
88
+ function handleNativeFocus(event: FocusEvent & { currentTarget: HTMLInputElement }): void {
89
+ const carrier = event.currentTarget;
90
+ // Finish native interactive validation before moving focus. Firefox can
91
+ // open its validation UI after a synchronous handoff and swallow the next
92
+ // pointer click. A later owner or an unmounted card must keep its focus.
93
+ queueMicrotask(() => {
94
+ const root = carrier.getRootNode();
95
+ if (card?.isConnected && 'activeElement' in root && root.activeElement === carrier) {
96
+ card.focus();
97
+ }
98
+ });
91
99
  }
92
100
 
93
101
  function handleKeyDown(e: KeyboardEvent): void {
@@ -143,21 +151,6 @@
143
151
  testID={testId}
144
152
  {@attach groupMembership}
145
153
  >
146
- <input
147
- type={mode === 'radio' ? 'radio' : 'checkbox'}
148
- class="native-control"
149
- checked={selected}
150
- {disabled}
151
- {value}
152
- {required}
153
- name={typeof name === 'string' ? name : null}
154
- form={typeof form === 'string' ? form : null}
155
- oninvalid={handleInvalid}
156
- tabindex={-1}
157
- aria-hidden="true"
158
- data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
159
- testID={typeof testId === 'string' ? `${testId}-native-input` : null}
160
- />
161
154
  {#if typeof children === 'function'}
162
155
  {@render children()}
163
156
  {/if}
@@ -171,6 +164,23 @@
171
164
  {/if}
172
165
  </div>
173
166
 
167
+ <!-- Native form participation remains outside the interactive accessibility owner. -->
168
+ <input
169
+ type={mode === 'radio' ? 'radio' : 'checkbox'}
170
+ class="native-control"
171
+ checked={selected}
172
+ {disabled}
173
+ {value}
174
+ {required}
175
+ name={typeof name === 'string' ? name : null}
176
+ form={typeof form === 'string' ? form : null}
177
+ onfocus={handleNativeFocus}
178
+ tabindex={-1}
179
+ aria-hidden="true"
180
+ data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
181
+ testID={typeof testId === 'string' ? `${testId}-native-input` : null}
182
+ />
183
+
174
184
  {#if field.showsError}
175
185
  <div
176
186
  id={field.errorId}
@@ -197,8 +207,8 @@
197
207
  .native-control {
198
208
  position: absolute;
199
209
  opacity: 0;
200
- width: 0;
201
- height: 0;
210
+ width: 1px;
211
+ height: 1px;
202
212
  margin: 0;
203
213
  pointer-events: none;
204
214
  }
@@ -134,13 +134,15 @@
134
134
  }
135
135
  </script>
136
136
 
137
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
138
- <!-- svelte-ignore a11y_no_static_element_interactions -->
137
+ <!-- This named content group moves with arrow keys; it can contain independent
138
+ controls, so button semantics would hide their accessibility semantics. -->
139
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
139
140
  <div
140
141
  class="draggable {classes ?? ''}"
141
142
  class:dragging={active !== null}
142
143
  data-pw={typeof testId === 'string' ? testId : null}
143
144
  tabindex={disabled ? -1 : 0}
145
+ role="group"
144
146
  aria-label={dragLabel}
145
147
  style:transform={`translate(${x}px, ${y}px)`}
146
148
  onpointerdown={startDrag}
@@ -17,6 +17,7 @@
17
17
  src,
18
18
  type,
19
19
  alt = '',
20
+ ariaLabel = 'Video player',
20
21
  autoplay = true,
21
22
  loop = false,
22
23
  controls = false,
@@ -212,13 +213,6 @@
212
213
  onpause?.(event);
213
214
  }
214
215
 
215
- function handleVideoKeydown(event: KeyboardEvent): void {
216
- if (event.key === 'Enter' || event.key === ' ') {
217
- event.preventDefault();
218
- togglePlayback();
219
- }
220
- }
221
-
222
216
  function handleTimeUpdate(): void {
223
217
  if (videoPlayer === null) {
224
218
  return;
@@ -390,6 +384,7 @@
390
384
  bind:muted
391
385
  {src}
392
386
  class="media"
387
+ aria-label={ariaLabel}
393
388
  {controls}
394
389
  {autoplay}
395
390
  {loop}
@@ -399,11 +394,6 @@
399
394
  onpause={handlePause}
400
395
  ontimeupdate={handleTimeUpdate}
401
396
  onloadedmetadata={handleLoadedMetadata}
402
- onclick={controls ? null : togglePlayback}
403
- onkeydown={controls ? null : handleVideoKeydown}
404
- role={controls ? null : 'button'}
405
- tabindex={controls ? null : 0}
406
- aria-label={controls ? null : playing ? 'Pause video' : 'Play video'}
407
397
  >
408
398
  {#if hasCaptions}
409
399
  <track
@@ -417,6 +407,14 @@
417
407
  </video>
418
408
 
419
409
  {#if !controls}
410
+ <!-- A video cannot carry role="button". The full-surface native button
411
+ preserves pointer/keyboard playback without nesting the other controls. -->
412
+ <button
413
+ type="button"
414
+ class="media-surface-action"
415
+ aria-label={playing ? 'Pause video' : 'Play video'}
416
+ onclick={togglePlayback}
417
+ ></button>
420
418
  <div class="overlay">
421
419
  <div class="center-controls">
422
420
  <div class="control center-control">
@@ -571,6 +569,23 @@
571
569
  --image-margin: 0px;
572
570
  }
573
571
 
572
+ .media-surface-action {
573
+ position: absolute;
574
+ inset: 0;
575
+ padding: 0;
576
+ border: 0;
577
+ border-radius: inherit;
578
+ background: transparent;
579
+ cursor: pointer;
580
+ }
581
+
582
+ .media-surface-action:focus-visible {
583
+ /* Keep both colors inside the clipping container and visible over imagery. */
584
+ outline: var(--media-player-focus-outline, 2px solid #ffffff);
585
+ outline-offset: var(--media-player-focus-outline-offset, -2px);
586
+ box-shadow: var(--media-player-focus-box-shadow, inset 0 0 0 4px #000000);
587
+ }
588
+
574
589
  .overlay {
575
590
  /* Decorative overlay space leaves the native video pointer route operative. */
576
591
  pointer-events: none;
@@ -601,7 +616,7 @@
601
616
  reachable for keyboard users too -- :hover alone left them visibility:hidden (and
602
617
  so out of the tab order) for anyone not using a mouse. */
603
618
  .media-player:hover .overlay,
604
- .overlay:focus-within {
619
+ .media-player:focus-within .overlay {
605
620
  background-color: var(--media-player-overlay-hover-color, #0000004d);
606
621
  --center-controls-visibility: visible;
607
622
  --bottom-controls-visibility: visible;
@@ -7,6 +7,8 @@ export type MandatoryMediaPlayerProperties = {
7
7
  };
8
8
  export type OptionalMediaPlayerProperties = {
9
9
  alt?: string;
10
+ /** Accessible name for the video. Image naming uses alt. Defaults to "Video player". */
11
+ ariaLabel?: string;
10
12
  fallback?: string;
11
13
  autoplay?: boolean;
12
14
  loop?: boolean;
@@ -10,6 +10,7 @@
10
10
  import Img from '../Img/Img.svelte';
11
11
 
12
12
  let overlayDiv: HTMLDivElement | null = $state(null);
13
+ let overlayButton: HTMLButtonElement | null = $state(null);
13
14
  let modalContent: HTMLDivElement | null = $state(null);
14
15
  let backPressed = $state(false);
15
16
  // Captured in onMount right before focus moves in, restored in onDestroy.
@@ -54,12 +55,8 @@
54
55
  overlayAriaLabel
55
56
  }: ModalProperties = $props();
56
57
 
57
- // The overlay only takes role="button" and becomes keyboard-reachable when a
58
- // click on it would actually do something. Without either handler, clicking
59
- // or activating it is already a no-op (see handleOverlayClick's guard) --
60
- // announcing it as a focusable "button" in that case is worse for assistive
61
- // tech than leaving it unannounced, same reasoning already applied to the
62
- // header images below.
58
+ // A dismissible backdrop has its own native button, beside the content panel.
59
+ // It must never make the whole panel an interactive descendant of a button.
63
60
  const overlayDismissible = $derived(
64
61
  typeof onoverlayclick === 'function' || typeof ondismiss === 'function'
65
62
  );
@@ -142,7 +139,7 @@
142
139
  };
143
140
 
144
141
  const handleOverlayClick = (event: MouseEvent): void => {
145
- if (event.target === overlayDiv) {
142
+ if (event.target === overlayDiv || event.target === overlayButton) {
146
143
  debounce(() => {
147
144
  onoverlayclick?.();
148
145
  ondismiss?.();
@@ -185,37 +182,6 @@
185
182
  });
186
183
  };
187
184
 
188
- // The overlay's own Enter/Space activation, additional to the shared
189
- // handleKeyDown (Tab/onkeydown-prop -- Escape is the dismissal module's,
190
- // via handleEscape) that svelte:window already delivers here via
191
- // bubbling -- this handler must not also call handleKeyDown itself, or
192
- // every keydown on/under the overlay would run it twice (once from this
193
- // direct call, once from the window listener seeing the same event bubble
194
- // past it), double-firing onkeydown and re-running the Tab trap. Enter/Space
195
- // activation is scoped to this handler, not folded into handleKeyDown itself,
196
- // because handleKeyDown also runs
197
- // from the window listener for a keypress anywhere in the modal (e.g. an
198
- // input field) -- it must not treat every Enter/Space in the modal as an
199
- // overlay dismissal. This handler is bound to the overlay div, so a keydown
200
- // on any focusable descendant (the back button, a footer button, an input
201
- // field) bubbles up and reaches it too -- the target check below is what
202
- // actually stops that bubbled Enter/Space from being read as an activation
203
- // of the overlay itself, mirroring the guard handleOverlayClick already has
204
- // for the click case.
205
- const handleOverlayKeyDown = (event: KeyboardEvent): void => {
206
- if (
207
- (event.key === 'Enter' || event.key === ' ') &&
208
- overlayDismissible &&
209
- event.target === overlayDiv
210
- ) {
211
- event.preventDefault();
212
- debounce(() => {
213
- onoverlayclick?.();
214
- ondismiss?.();
215
- });
216
- }
217
- };
218
-
219
185
  // Fix [major]: role=button image divs need Enter/Space handlers for WCAG 2.1 SC 2.1.1.
220
186
  // Keyboard activation (Enter/Space) invokes the same prop callbacks as click.
221
187
  const handleLeftImageKeyDown = (event: KeyboardEvent): void => {
@@ -307,7 +273,8 @@
307
273
 
308
274
  {#if typeof content === 'function'}
309
275
  <OverlayAnimation fadeIn={overlayFadeIn}>
310
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
276
+ <!-- Native backdrop activation bubbles here; only the backdrop target dismisses. -->
277
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
311
278
  <div
312
279
  bind:this={overlayDiv}
313
280
  use:portalAction={{ usePortal }}
@@ -316,13 +283,18 @@
316
283
  ? `--modal-overlay-backdrop-filter: ${overlayBackdropFilter};`
317
284
  : null}
318
285
  onclick={handleOverlayClick}
319
- onkeydown={handleOverlayKeyDown}
320
- role={overlayDismissible ? 'button' : null}
321
- tabindex={overlayDismissible ? 0 : null}
322
- aria-label={overlayAriaLabel ?? null}
323
286
  data-pw={testId}
324
287
  testID={testId}
325
288
  >
289
+ {#if overlayDismissible}
290
+ <button
291
+ bind:this={overlayButton}
292
+ type="button"
293
+ class="modal-overlay-action"
294
+ aria-label={overlayAriaLabel ?? 'Dismiss modal'}
295
+ tabindex={role === 'dialog' || role === 'alertdialog' ? -1 : 0}
296
+ ></button>
297
+ {/if}
326
298
  <ModalAnimation enable={enableTransition} {align} {transitionType} {entryAnimation}>
327
299
  <div
328
300
  bind:this={modalContent}
@@ -437,6 +409,21 @@
437
409
  margin: var(--modal-margin);
438
410
  }
439
411
 
412
+ .modal-overlay-action {
413
+ position: absolute;
414
+ inset: 0;
415
+ z-index: -1;
416
+ padding: 0;
417
+ border: 0;
418
+ background: transparent;
419
+ cursor: pointer;
420
+ }
421
+
422
+ .modal-overlay-action:focus-visible {
423
+ outline: var(--modal-overlay-focus-outline, 2px solid #2563eb);
424
+ outline-offset: var(--modal-overlay-focus-outline-offset, -2px);
425
+ }
426
+
440
427
  .overlay-active {
441
428
  /* Prefer the modal-specific token; fall back to the legacy generic
442
429
  --background-color (kept for backward compatibility), then the default.
@@ -58,12 +58,13 @@ export type OptionalModalProperties = {
58
58
  /** Accessible name for the left header image's role="button" wrapper (e.g. a back control). Rendered as aria-label. */
59
59
  leftImageAriaLabel?: string;
60
60
  /**
61
- * Accessible name for the overlay's `role="button"` wrapper, rendered as
61
+ * Accessible name for the independent native backdrop button, rendered as
62
62
  * `aria-label` -- same pattern as `leftImageAriaLabel` and
63
63
  * `header.buttonAriaLabel`. Only meaningful when the overlay is dismissible
64
64
  * (an `onoverlayclick` or `ondismiss` handler is supplied), which is the
65
- * only state where the overlay is announced as a focusable "button" at all.
66
- * Default: unset (no accessible name).
65
+ * only state where a backdrop action exists. Named dialogs keep that action
66
+ * outside their Tab cycle; generic panels retain Enter/Space activation.
67
+ * Default: "Dismiss modal".
67
68
  */
68
69
  overlayAriaLabel?: string;
69
70
  testId?: string;
@@ -107,7 +108,7 @@ export type OptionalModalProperties = {
107
108
  * ARIA role for the modal content panel. Default: unset -- the panel
108
109
  * renders no `role` and no `aria-modal` at all, byte-for-byte matching
109
110
  * behavior prior to this prop's introduction, so an existing consumer who
110
- * never touches `role` sees no DOM/ARIA change. Set it explicitly (e.g.
111
+ * never touches `role` sees no panel role/aria-modal change. Set it explicitly (e.g.
111
112
  * `'dialog'` or `'alertdialog'`) to opt in: the panel then also carries
112
113
  * `aria-modal="true"`, since a role without aria-modal is not a complete
113
114
  * dialog announcement.
@@ -9,6 +9,7 @@
9
9
  testId,
10
10
  onchange,
11
11
  classes,
12
+ ariaLabel = 'Pagination',
12
13
  hasMore = false,
13
14
  prevButtonTestId,
14
15
  nextButtonTestId,
@@ -69,6 +70,7 @@
69
70
  <nav
70
71
  class="pagination {classes ?? ''}"
71
72
  class:disabled
73
+ aria-label={ariaLabel}
72
74
  data-pw={typeof testId === 'string' && testId.length > 0 ? testId : null}
73
75
  testID={typeof testId === 'string' && testId.length > 0 ? testId : null}
74
76
  >
@@ -8,6 +8,8 @@ export type OptionalPaginationProperties = {
8
8
  disabled?: boolean;
9
9
  testId?: string;
10
10
  classes?: string;
11
+ /** Name of the page-navigation landmark. Default: `Pagination`. */
12
+ ariaLabel?: string;
11
13
  /**
12
14
  * Cursor-pagination hint. When `true`, the next button stays enabled even
13
15
  * when `currentPage >= totalPages` — use this when the total page count is