@juspay/svelte-ui-components 4.52.1 → 4.53.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 (39) hide show
  1. package/README.md +14 -0
  2. package/dist/AttachmentChipRow/AttachmentChipRow.svelte +3 -2
  3. package/dist/AttachmentChipRow/properties.d.ts +2 -0
  4. package/dist/Book/Book.svelte +2 -0
  5. package/dist/Book/properties.d.ts +2 -0
  6. package/dist/Chat/Chat.svelte +2 -0
  7. package/dist/Chat/properties.d.ts +2 -0
  8. package/dist/Chat/types.d.ts +2 -0
  9. package/dist/ChatMessage/ChatMessage.svelte +18 -3
  10. package/dist/ChatMessage/properties.d.ts +6 -0
  11. package/dist/ChatMessageList/ChatMessageList.svelte +31 -7
  12. package/dist/ChatMessageList/properties.d.ts +2 -0
  13. package/dist/Choicebox/Choicebox.svelte +33 -23
  14. package/dist/DateRangePicker/DateRangePicker.svelte +82 -33
  15. package/dist/DateRangePicker/properties.d.ts +18 -2
  16. package/dist/Draggable/Draggable.svelte +4 -2
  17. package/dist/MediaPlayer/MediaPlayer.svelte +28 -13
  18. package/dist/MediaPlayer/properties.d.ts +2 -0
  19. package/dist/Modal/Modal.svelte +30 -43
  20. package/dist/Modal/properties.d.ts +5 -4
  21. package/dist/Pagination/Pagination.svelte +2 -0
  22. package/dist/Pagination/properties.d.ts +2 -0
  23. package/dist/Pill/Pill.svelte +61 -4
  24. package/dist/Pill/properties.d.ts +8 -16
  25. package/dist/Radio/Radio.svelte +6 -2
  26. package/dist/RatingGroup/RatingGroup.svelte +38 -26
  27. package/dist/Select/Select.svelte +8 -0
  28. package/dist/StatCard/StatCard.svelte +68 -17
  29. package/dist/StatCard/properties.d.ts +1 -1
  30. package/dist/Stepper/Step.svelte +2 -2
  31. package/dist/Table/Table.svelte +32 -2
  32. package/dist/Table/properties.d.ts +11 -5
  33. package/dist/Tabs/Tabs.svelte +187 -12
  34. package/dist/ToolCallLog/ToolCallLog.svelte +4 -4
  35. package/dist/utils.js +20 -2
  36. package/dist-codemod/codemod/cli.js +4 -4
  37. package/dist-wc/index.js +3134 -2717
  38. package/package.json +3 -2
  39. package/scripts/postinstall.mjs +13 -15
@@ -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
@@ -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;