@juspay/svelte-ui-components 4.52.0 → 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 (40) 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/HITL/HITL.svelte +52 -11
  15. package/dist/HITL/properties.d.ts +5 -0
  16. package/dist/MediaPlayer/MediaPlayer.svelte +28 -13
  17. package/dist/MediaPlayer/properties.d.ts +2 -0
  18. package/dist/Modal/Modal.svelte +30 -43
  19. package/dist/Modal/properties.d.ts +5 -4
  20. package/dist/Pagination/Pagination.svelte +2 -0
  21. package/dist/Pagination/properties.d.ts +2 -0
  22. package/dist/Pill/Pill.svelte +61 -4
  23. package/dist/Pill/properties.d.ts +8 -16
  24. package/dist/Radio/Radio.svelte +6 -2
  25. package/dist/RatingGroup/RatingGroup.svelte +38 -26
  26. package/dist/Select/Select.svelte +11 -3
  27. package/dist/StatCard/StatCard.svelte +68 -17
  28. package/dist/StatCard/properties.d.ts +1 -1
  29. package/dist/Stepper/Step.svelte +2 -2
  30. package/dist/Table/Table.svelte +32 -2
  31. package/dist/Table/properties.d.ts +11 -5
  32. package/dist/Tabs/Tabs.svelte +187 -12
  33. package/dist/Toast/Toast.svelte +42 -12
  34. package/dist/_chart/LabelPlate.svelte +3 -1
  35. package/dist/styles/theme-dark.css +3 -0
  36. package/dist/utils.js +20 -2
  37. package/dist-codemod/codemod/cli.js +4 -4
  38. package/dist-wc/index.js +3203 -2794
  39. package/package.json +1 -1
  40. 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}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
2
+ import { onMount, onDestroy, tick } from 'svelte';
3
3
  import { get } from 'svelte/store';
4
4
  import Button from '../Button/Button.svelte';
5
5
  import Card from '../Card/Card.svelte';
@@ -43,6 +43,7 @@
43
43
  showConfirm = true,
44
44
  showEmptyParameters = true,
45
45
  countdownSeconds = 10,
46
+ expiresAt,
46
47
  autoCancelSeconds = 0,
47
48
  isMicMuted = false,
48
49
  // The resolver this replaced defaulted to null, and the call sites test for
@@ -88,9 +89,14 @@
88
89
  // Seeded from the prop; startCountdown re-seeds it on every (re)start.
89
90
  // svelte-ignore state_referenced_locally
90
91
  let timeRemaining = $state(countdownSeconds);
92
+ // svelte-ignore state_referenced_locally
93
+ let countdownDuration = $state(countdownSeconds);
94
+ let relativeDeadline: number | null = null;
95
+ let countdownDeadline: number | null = null;
91
96
  let countdownInterval: ReturnType<typeof setInterval> | null = null;
92
97
  let autoCancelTimeout: ReturnType<typeof setTimeout> | null = null;
93
98
  let originalMicState: boolean | null = null;
99
+ let initialMicToggle: Promise<void> | null = null;
94
100
 
95
101
  // Expiry from the caller takes precedence, including during a live request.
96
102
  // History mode renders the settled state from props; live mode from local state.
@@ -111,7 +117,7 @@
111
117
  });
112
118
  const isCompleted = $derived(userResponse !== null);
113
119
  const isApproved = $derived(userResponse === 'approved' || userResponse === 'auto-approved');
114
- const elapsedTime = $derived(countdownSeconds - timeRemaining);
120
+ const elapsedTime = $derived(Math.max(0, countdownDuration - timeRemaining));
115
121
  const approvalBlocked = (action: HITLAction): boolean => action !== 'rejected' && confirmDisabled;
116
122
 
117
123
  const completionText = $derived(
@@ -140,6 +146,13 @@
140
146
  };
141
147
 
142
148
  const restoreMicState = async (): Promise<void> => {
149
+ // An elapsed deadline may decide in the same mount that starts auto-mute.
150
+ // Finish that call and flush its controlled prop before restoring it.
151
+ if (initialMicToggle !== null) {
152
+ await initialMicToggle;
153
+ await tick();
154
+ initialMicToggle = null;
155
+ }
143
156
  // Consume restoration before awaiting: a failed decision may be retried
144
157
  // before the caller has updated isMicMuted, but must not toggle twice.
145
158
  const stateToRestore = originalMicState;
@@ -214,19 +227,41 @@
214
227
  return;
215
228
  }
216
229
  countdownActive = true;
217
- timeRemaining = countdownSeconds;
218
- countdownInterval = setInterval(() => {
230
+ const now = Date.now();
231
+ // Relative duration is anchored once; rerenders must not extend it. An
232
+ // explicit absolute expiry remains live while this timer is running.
233
+ relativeDeadline ??= now + countdownSeconds * 1000;
234
+ const updateCountdown = (): void => {
219
235
  // A sibling card's interaction pauses this countdown on its next tick.
220
- if (confirmDisabled || get(pauseAllConfirmationTimers)) {
236
+ if (
237
+ countdownSeconds <= 0 ||
238
+ confirmDisabled ||
239
+ isHistoryMode ||
240
+ isCompleted ||
241
+ get(pauseAllConfirmationTimers)
242
+ ) {
221
243
  stopCountdown();
222
244
  return;
223
245
  }
224
- timeRemaining = timeRemaining - 0.1;
246
+ const currentTime = Date.now();
247
+ const nextDeadline =
248
+ typeof expiresAt === 'number' && Number.isFinite(expiresAt)
249
+ ? expiresAt
250
+ : (relativeDeadline ?? now);
251
+ if (nextDeadline !== countdownDeadline) {
252
+ countdownDeadline = nextDeadline;
253
+ countdownDuration = Math.max(0, (nextDeadline - currentTime) / 1000);
254
+ }
255
+ timeRemaining = Math.max(0, (nextDeadline - currentTime) / 1000);
225
256
  if (timeRemaining <= 0) {
226
257
  stopCountdown();
227
258
  void decide('auto-approved');
228
259
  }
229
- }, 100);
260
+ };
261
+ updateCountdown();
262
+ if (countdownActive) {
263
+ countdownInterval = setInterval(updateCountdown, 100);
264
+ }
230
265
  };
231
266
 
232
267
  // Any interaction pauses this card's own countdown and every sibling's.
@@ -345,9 +380,15 @@
345
380
  if (onmictoggle !== null) {
346
381
  originalMicState = isMicMuted;
347
382
  if (!isMicMuted) {
348
- void Promise.resolve(onmictoggle()).catch(() => {
349
- // Auto-mute is best-effort.
350
- });
383
+ try {
384
+ // Preserve the existing synchronous mount invocation. Only the
385
+ // decision waits for its result and the controlled prop update.
386
+ initialMicToggle = Promise.resolve(onmictoggle()).catch(() => {
387
+ // Auto-mute is best-effort; settlement still follows a failed call.
388
+ });
389
+ } catch {
390
+ initialMicToggle = Promise.resolve();
391
+ }
351
392
  }
352
393
  }
353
394
  pauseAllConfirmationTimers.set(false);
@@ -679,7 +720,7 @@
679
720
  <div class="confirm-button">
680
721
  {#if countdownActive && !confirmDisabled}
681
722
  <div class="progress-anchor">
682
- <Progress value={elapsedTime} max={countdownSeconds} />
723
+ <Progress value={elapsedTime} max={countdownDuration} />
683
724
  </div>
684
725
  {/if}
685
726
  <Button
@@ -144,6 +144,11 @@ export type OptionalHITLProperties = {
144
144
  * button. `0` disables auto-approval.
145
145
  */
146
146
  countdownSeconds?: number;
147
+ /**
148
+ * Absolute server expiry in Unix milliseconds. Updates affect a running
149
+ * countdown; paused or settled cards never restart. Zero keeps manual-only approval.
150
+ */
151
+ expiresAt?: number;
147
152
  /**
148
153
  * Seconds until an untouched card auto-rejects — for confirmations that must
149
154
  * not auto-approve (e.g. OAuth) but should not block a conversation forever.
@@ -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;