@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.
- package/README.md +14 -0
- package/dist/AttachmentChipRow/AttachmentChipRow.svelte +3 -2
- package/dist/AttachmentChipRow/properties.d.ts +2 -0
- package/dist/Book/Book.svelte +2 -0
- package/dist/Book/properties.d.ts +2 -0
- package/dist/Chat/Chat.svelte +2 -0
- package/dist/Chat/properties.d.ts +2 -0
- package/dist/Chat/types.d.ts +2 -0
- package/dist/ChatMessage/ChatMessage.svelte +18 -3
- package/dist/ChatMessage/properties.d.ts +6 -0
- package/dist/ChatMessageList/ChatMessageList.svelte +31 -7
- package/dist/ChatMessageList/properties.d.ts +2 -0
- package/dist/Choicebox/Choicebox.svelte +33 -23
- package/dist/DateRangePicker/DateRangePicker.svelte +82 -33
- package/dist/DateRangePicker/properties.d.ts +18 -2
- package/dist/Draggable/Draggable.svelte +4 -2
- package/dist/MediaPlayer/MediaPlayer.svelte +28 -13
- package/dist/MediaPlayer/properties.d.ts +2 -0
- package/dist/Modal/Modal.svelte +30 -43
- package/dist/Modal/properties.d.ts +5 -4
- package/dist/Pagination/Pagination.svelte +2 -0
- package/dist/Pagination/properties.d.ts +2 -0
- package/dist/Pill/Pill.svelte +61 -4
- package/dist/Pill/properties.d.ts +8 -16
- package/dist/Radio/Radio.svelte +6 -2
- package/dist/RatingGroup/RatingGroup.svelte +38 -26
- package/dist/Select/Select.svelte +8 -0
- package/dist/StatCard/StatCard.svelte +68 -17
- package/dist/StatCard/properties.d.ts +1 -1
- package/dist/Stepper/Step.svelte +2 -2
- package/dist/Table/Table.svelte +32 -2
- package/dist/Table/properties.d.ts +11 -5
- package/dist/Tabs/Tabs.svelte +187 -12
- package/dist/ToolCallLog/ToolCallLog.svelte +4 -4
- package/dist/utils.js +20 -2
- package/dist-codemod/codemod/cli.js +4 -4
- package/dist-wc/index.js +3134 -2717
- package/package.json +3 -2
- 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
|
-
.
|
|
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;
|
package/dist/Modal/Modal.svelte
CHANGED
|
@@ -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
|
-
//
|
|
58
|
-
//
|
|
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
|
-
<!--
|
|
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
|
|
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
|
|
66
|
-
*
|
|
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
|
|
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
|
package/dist/Pill/Pill.svelte
CHANGED
|
@@ -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.
|
|
57
|
-
//
|
|
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
|
-
{...
|
|
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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
/**
|
package/dist/Radio/Radio.svelte
CHANGED
|
@@ -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:
|
|
120
|
-
height:
|
|
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
|
|
134
|
-
// field
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:
|
|
289
|
-
height:
|
|
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
|
-
<!--
|
|
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
|
-
|
|
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;
|