@juspay/svelte-ui-components 4.1.2 → 4.3.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.
@@ -5,6 +5,9 @@
5
5
  import pauseSvg from '../assets/pause.svg?raw';
6
6
  import volumeSvg from '../assets/volume.svg?raw';
7
7
  import muteSvg from '../assets/mute.svg?raw';
8
+ import fullscreenSvg from '../assets/fullscreen.svg?raw';
9
+ import exitFullscreenSvg from '../assets/exit-fullscreen.svg?raw';
10
+ import Slider from '../Slider/Slider.svelte';
8
11
  import type { MediaPlayerProperties } from './properties';
9
12
 
10
13
  let {
@@ -24,14 +27,62 @@
24
27
  captionsSrc,
25
28
  captionsLabel,
26
29
  captionsSrcLang,
30
+ seekBar = false,
31
+ timeDisplay = false,
32
+ fullscreenButton = false,
33
+ fullscreenIcon,
34
+ exitFullscreenIcon,
35
+ currentTime = $bindable(0),
36
+ duration = $bindable(0),
27
37
  onplay,
28
38
  onpause,
29
39
  onvolumechange,
40
+ onseek,
41
+ ontimeupdate,
42
+ onfullscreenchange,
30
43
  testId,
31
44
  classes
32
45
  }: MediaPlayerProperties = $props();
33
46
 
47
+ // HTMLMediaElement.HAVE_METADATA. Named rather than inlined as 1, because `readyState >= 1`
48
+ // at a call site reads like a truthiness check rather than a specific media state.
49
+ const HAVE_METADATA = 1;
50
+ // Below one frame at 60fps: small enough that a real host write always clears it, large
51
+ // enough that float drift between the element's clock and the bound copy does not.
52
+ const SEEK_EPSILON = 0.01;
53
+ // The last position this component wrote to `currentTime`; see the sync effect below.
54
+ let syncedTime = 0;
55
+ // A host write that arrived before the element could accept it; applied once metadata is.
56
+ let pendingSeek: number | null = null;
57
+
34
58
  let videoPlayer: HTMLVideoElement | null = $state(null);
59
+ let container: HTMLDivElement | null = $state(null);
60
+ let isFullscreen = $state(false);
61
+
62
+ // Any of the three new controls puts something in the bottom row beyond the mute
63
+ // button, which is what decides whether that row needs to lay out as a bar.
64
+ const hasTransportRow = $derived(seekBar || timeDisplay || fullscreenButton);
65
+
66
+ /**
67
+ * `m:ss`, widening to `h:mm:ss` only once the media actually runs past an hour, so a
68
+ * 40-second clip does not read `0:00:40`. A media element reports NaN for duration
69
+ * until metadata arrives and Infinity for an open-ended stream; both format as `--:--`
70
+ * rather than leaking the raw value into the UI.
71
+ */
72
+ function formatTime(seconds: number): string {
73
+ if (!Number.isFinite(seconds) || seconds < 0) {
74
+ return '--:--';
75
+ }
76
+ const whole = Math.floor(seconds);
77
+ const hours = Math.floor(whole / 3600);
78
+ const minutes = Math.floor((whole % 3600) / 60);
79
+ const secs = whole % 60;
80
+ const padded = secs.toString().padStart(2, '0');
81
+ if (hours > 0) {
82
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${padded}`;
83
+ }
84
+ return `${minutes}:${padded}`;
85
+ }
35
86
 
36
87
  function togglePlayback(): void {
37
88
  if (videoPlayer === null) {
@@ -83,9 +134,158 @@
83
134
  togglePlayback();
84
135
  }
85
136
  }
137
+
138
+ function handleTimeUpdate(): void {
139
+ if (videoPlayer === null) {
140
+ return;
141
+ }
142
+ currentTime = videoPlayer.currentTime;
143
+ syncedTime = currentTime;
144
+ ontimeupdate?.(currentTime, duration);
145
+ }
146
+
147
+ function handleLoadedMetadata(): void {
148
+ if (videoPlayer === null) {
149
+ return;
150
+ }
151
+ duration = Number.isFinite(videoPlayer.duration) ? videoPlayer.duration : 0;
152
+ }
153
+
154
+ // `loadedmetadata` is a one-shot event, and a cached or fast-loading file reaches
155
+ // HAVE_METADATA before hydration attaches the handler above -- measured, not supposed:
156
+ // the element reported readyState 4 and duration 4 while the component still held 0.
157
+ // Nothing fires it again, so a seek bar and clock that only listened would stay dead
158
+ // for exactly the media that loaded well. Adopting what the element already knows when
159
+ // it binds covers that case; the handler covers the slower one.
160
+ // eslint-disable-next-line no-restricted-syntax
161
+ $effect(() => {
162
+ if (videoPlayer === null || videoPlayer.readyState < HAVE_METADATA) {
163
+ return;
164
+ }
165
+ if (duration === 0 && Number.isFinite(videoPlayer.duration)) {
166
+ duration = videoPlayer.duration;
167
+ }
168
+ if (pendingSeek !== null) {
169
+ const length = duration > 0 ? duration : videoPlayer.duration;
170
+ const clamped = Number.isFinite(length)
171
+ ? Math.min(Math.max(pendingSeek, 0), length)
172
+ : Math.max(pendingSeek, 0);
173
+ pendingSeek = null;
174
+ syncedTime = clamped;
175
+ currentTime = clamped;
176
+ videoPlayer.currentTime = clamped;
177
+ }
178
+ });
179
+
180
+ /**
181
+ * `currentTime` is bindable in both directions. Playback and scrubbing push outward
182
+ * through the two functions above; this carries a host's own write inward, which is
183
+ * what makes restoring a saved position work rather than merely look bound.
184
+ *
185
+ * `syncedTime` is a plain `let`, not `$state`, so writing it here does not re-run this
186
+ * effect. Without that, every outward update would read back as an inward one and the
187
+ * element would be re-seeked to the position it just reported -- a feedback loop that
188
+ * stutters playback. Comparing against it means only a value this component did not
189
+ * itself produce counts as a host write. `onseek` deliberately does not fire: a host
190
+ * restoring a position is not a user scrubbing.
191
+ */
192
+ // eslint-disable-next-line no-restricted-syntax
193
+ $effect(() => {
194
+ const requested = currentTime;
195
+ if (videoPlayer === null || Math.abs(requested - syncedTime) < SEEK_EPSILON) {
196
+ return;
197
+ }
198
+ // Before HAVE_METADATA the element has no timeline to seek within: the length is
199
+ // unknown, so the value cannot be clamped, and assigning currentTime is specified to
200
+ // set a default start position rather than seek -- and throws outright in some
201
+ // engines. Hold it and apply it when metadata arrives, which is the moment a restored
202
+ // position becomes meaningful anyway.
203
+ if (videoPlayer.readyState < HAVE_METADATA) {
204
+ pendingSeek = requested;
205
+ return;
206
+ }
207
+ const clamped =
208
+ duration > 0 ? Math.min(Math.max(requested, 0), duration) : Math.max(requested, 0);
209
+ syncedTime = clamped;
210
+ videoPlayer.currentTime = clamped;
211
+ });
212
+
213
+ /**
214
+ * Seeking writes the element directly rather than waiting for the bound value to
215
+ * settle, so a drag scrubs while the pointer is still down. `onseek` fires only from
216
+ * here, which is what separates a deliberate scrub from playback advancing on its own.
217
+ */
218
+ function handleSeek(value: number): void {
219
+ if (videoPlayer === null || duration <= 0) {
220
+ return;
221
+ }
222
+ const clamped = Math.min(Math.max(value, 0), duration);
223
+ videoPlayer.currentTime = clamped;
224
+ currentTime = clamped;
225
+ syncedTime = clamped;
226
+ onseek?.(clamped);
227
+ }
228
+
229
+ /**
230
+ * The fullscreen element as seen from wherever this component actually lives. In the
231
+ * web-component build the container sits inside a shadow root, and `document`
232
+ * retargets `fullscreenElement` to the host (`<sui-media-player>`), never the container
233
+ * itself -- so a plain document check reads false while genuinely fullscreen, leaving
234
+ * the icon stuck and `onfullscreenchange` silent. A ShadowRoot exposes the same
235
+ * accessor scoped to its own tree, which does resolve to the container.
236
+ */
237
+ function activeFullscreenElement(): Element | null {
238
+ const root = container?.getRootNode();
239
+ if (root instanceof ShadowRoot) {
240
+ return root.fullscreenElement;
241
+ }
242
+ return document.fullscreenElement;
243
+ }
244
+
245
+ /**
246
+ * Fullscreen is requested on the container, not the `<video>`. A fullscreen video
247
+ * element paints over everything, taking the overlay's play, mute and seek controls
248
+ * with it; the container keeps them on top of the media where they are usable.
249
+ */
250
+ async function toggleFullscreen(): Promise<void> {
251
+ if (container === null) {
252
+ return;
253
+ }
254
+ try {
255
+ if (activeFullscreenElement() === null) {
256
+ await container.requestFullscreen();
257
+ } else {
258
+ await document.exitFullscreen();
259
+ }
260
+ } catch {
261
+ // A rejected request (denied by the browser, or no user gesture behind it) leaves
262
+ // the player exactly as it was. `fullscreenchange` never fires, so `isFullscreen`
263
+ // still describes reality and the button still offers the action that failed.
264
+ }
265
+ }
266
+
267
+ // The only reliable signal for leaving fullscreen is the document's own event: Escape
268
+ // and the browser's chrome both exit without going through the button above.
269
+ // eslint-disable-next-line no-restricted-syntax
270
+ $effect(() => {
271
+ function syncFullscreen(): void {
272
+ const nowFullscreen = container !== null && activeFullscreenElement() === container;
273
+ if (nowFullscreen !== isFullscreen) {
274
+ isFullscreen = nowFullscreen;
275
+ onfullscreenchange?.(nowFullscreen);
276
+ }
277
+ }
278
+ document.addEventListener('fullscreenchange', syncFullscreen);
279
+ return () => document.removeEventListener('fullscreenchange', syncFullscreen);
280
+ });
86
281
  </script>
87
282
 
88
- <div class="media-player {classes ?? ''}" data-pw={typeof testId === 'string' ? testId : null}>
283
+ <div
284
+ bind:this={container}
285
+ class="media-player {classes ?? ''}"
286
+ class:fullscreen={isFullscreen}
287
+ data-pw={typeof testId === 'string' ? testId : null}
288
+ >
89
289
  {#if type === 'image'}
90
290
  <span class="media-image">
91
291
  <Img {src} {alt} {fallback} />
@@ -100,8 +300,11 @@
100
300
  {autoplay}
101
301
  {loop}
102
302
  playsinline
303
+ preload={hasTransportRow ? 'metadata' : null}
103
304
  onplay={handlePlay}
104
305
  onpause={handlePause}
306
+ ontimeupdate={handleTimeUpdate}
307
+ onloadedmetadata={handleLoadedMetadata}
105
308
  onclick={controls ? null : togglePlayback}
106
309
  onkeydown={controls ? null : handleVideoKeydown}
107
310
  role={controls ? null : 'button'}
@@ -139,7 +342,26 @@
139
342
  </Button>
140
343
  </div>
141
344
  </div>
142
- <div class="bottom-controls">
345
+ <div class="bottom-controls" class:transport={hasTransportRow}>
346
+ {#if seekBar}
347
+ <div class="seek" data-pw={typeof testId === 'string' ? `${testId}-seek` : null}>
348
+ <Slider
349
+ value={currentTime}
350
+ min={0}
351
+ max={duration > 0 ? duration : 1}
352
+ step={0.1}
353
+ disabled={duration <= 0}
354
+ oninput={handleSeek}
355
+ onchange={handleSeek}
356
+ ariaLabel="Seek"
357
+ />
358
+ </div>
359
+ {/if}
360
+ {#if timeDisplay}
361
+ <span class="time" data-pw={typeof testId === 'string' ? `${testId}-time` : null}>
362
+ {formatTime(currentTime)} / {formatTime(duration)}
363
+ </span>
364
+ {/if}
143
365
  <div class="control bottom-control">
144
366
  <Button onclick={toggleMute} ariaLabel={muted ? 'Unmute' : 'Mute'}>
145
367
  {#if muted}
@@ -157,6 +379,31 @@
157
379
  {/if}
158
380
  </Button>
159
381
  </div>
382
+ {#if fullscreenButton}
383
+ <div
384
+ class="control bottom-control"
385
+ data-pw={typeof testId === 'string' ? `${testId}-fullscreen` : null}
386
+ >
387
+ <Button
388
+ onclick={toggleFullscreen}
389
+ ariaLabel={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
390
+ >
391
+ {#if isFullscreen}
392
+ {#if typeof exitFullscreenIcon === 'function'}
393
+ {@render exitFullscreenIcon()}
394
+ {:else}
395
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
396
+ {@html exitFullscreenSvg}
397
+ {/if}
398
+ {:else if typeof fullscreenIcon === 'function'}
399
+ {@render fullscreenIcon()}
400
+ {:else}
401
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
402
+ {@html fullscreenSvg}
403
+ {/if}
404
+ </Button>
405
+ </div>
406
+ {/if}
160
407
  </div>
161
408
  </div>
162
409
  {/if}
@@ -241,6 +488,44 @@
241
488
  visibility: var(--bottom-controls-visibility);
242
489
  }
243
490
 
491
+ /* With a seek bar, time or fullscreen present the row becomes a transport bar: the
492
+ seek bar takes the free space and the rest sit beside it, centred on each other. */
493
+ .bottom-controls.transport {
494
+ justify-content: var(--media-player-transport-justify, flex-start);
495
+ align-items: center;
496
+ gap: var(--media-player-transport-gap, 12px);
497
+ }
498
+
499
+ .seek {
500
+ flex: 1;
501
+ min-width: 0;
502
+ --slider-track-color: var(--media-player-seek-track-color, #ffffff59);
503
+ --slider-fill-color: var(--media-player-seek-fill-color, #ffffff);
504
+ --slider-thumb-color: var(--media-player-seek-thumb-color, #ffffff);
505
+ }
506
+
507
+ .time {
508
+ flex-shrink: 0;
509
+ font-family: var(--media-player-time-font-family, inherit);
510
+ font-size: var(--media-player-time-font-size, 12px);
511
+ font-variant-numeric: tabular-nums;
512
+ color: var(--media-player-time-color, #ffffff);
513
+ white-space: nowrap;
514
+ }
515
+
516
+ /* In fullscreen the container is the fullscreen element, so it must fill the screen
517
+ rather than keep the fixed height a page layout gave it. */
518
+ .media-player.fullscreen {
519
+ height: 100%;
520
+ width: 100%;
521
+ background: var(--media-player-fullscreen-background, #000000);
522
+ }
523
+
524
+ .media-player.fullscreen .media {
525
+ height: 100%;
526
+ width: 100%;
527
+ }
528
+
244
529
  .control {
245
530
  --button-padding: var(--media-player-control-padding, 0px);
246
531
  --button-border: var(--media-player-control-border, none);
@@ -1,4 +1,4 @@
1
1
  import type { MediaPlayerProperties } from './properties';
2
- declare const MediaPlayer: import("svelte").Component<MediaPlayerProperties, {}, "playing" | "muted">;
2
+ declare const MediaPlayer: import("svelte").Component<MediaPlayerProperties, {}, "duration" | "playing" | "muted" | "currentTime">;
3
3
  type MediaPlayer = ReturnType<typeof MediaPlayer>;
4
4
  export default MediaPlayer;
@@ -24,6 +24,42 @@ export type OptionalMediaPlayerProperties = {
24
24
  captionsLabel?: string;
25
25
  /** BCP 47 language tag for the captions track, e.g. "en". Only meaningful with captionsSrc. */
26
26
  captionsSrcLang?: string;
27
+ /**
28
+ * Render a seek bar in the bottom controls. Video only. The bar is a library
29
+ * `Slider` bound to the media's position, so dragging it scrubs and playback
30
+ * moves the handle. Off by default: a player that was showing only play and mute
31
+ * keeps showing only play and mute.
32
+ *
33
+ * Turning any of the three on also sets `preload="metadata"` on the media, because a
34
+ * seek bar and a clock are unusable until the length is known and a paused player is
35
+ * not otherwise obliged to fetch it.
36
+ */
37
+ seekBar?: boolean;
38
+ /**
39
+ * Render elapsed and total time beside the controls, as `m:ss`, or `h:mm:ss` once
40
+ * the media runs past an hour. Video only, off by default.
41
+ */
42
+ timeDisplay?: boolean;
43
+ /**
44
+ * Render a button that takes the player in and out of fullscreen. Video only, off
45
+ * by default. Fullscreen is requested on the player's own container rather than on
46
+ * the `<video>`, so the overlay controls stay usable while fullscreen.
47
+ */
48
+ fullscreenButton?: boolean;
49
+ /** Replaces the default icon on the fullscreen button while not fullscreen. */
50
+ fullscreenIcon?: Snippet;
51
+ /** Replaces the default icon on the fullscreen button while fullscreen. */
52
+ exitFullscreenIcon?: Snippet;
53
+ /**
54
+ * Playback position in seconds. Bindable, and writable: setting it seeks, which is
55
+ * how a host can restore a saved position or drive its own scrubber.
56
+ */
57
+ currentTime?: number;
58
+ /**
59
+ * Media length in seconds, 0 until metadata loads. Bindable for reading; writing it
60
+ * does not resize the media.
61
+ */
62
+ duration?: number;
27
63
  testId?: string;
28
64
  classes?: string;
29
65
  };
@@ -31,4 +67,14 @@ export type MediaPlayerEventProperties = {
31
67
  onplay?: (event: Event) => void;
32
68
  onpause?: (event: Event) => void;
33
69
  onvolumechange?: (muted: boolean) => void;
70
+ /**
71
+ * The viewer moved the seek bar. Carries the position seeked to, in seconds.
72
+ * Fires only for a deliberate scrub, not for playback advancing on its own —
73
+ * `ontimeupdate` is the one that fires continuously.
74
+ */
75
+ onseek?: (currentTime: number) => void;
76
+ /** Playback position advanced. Carries the position and the length, both in seconds. */
77
+ ontimeupdate?: (currentTime: number, duration: number) => void;
78
+ /** The player entered or left fullscreen. Carries the state it is now in. */
79
+ onfullscreenchange?: (isFullscreen: boolean) => void;
34
80
  };
@@ -2,11 +2,13 @@
2
2
  import type { PillProperties } from './properties';
3
3
  import Button from '../Button/Button.svelte';
4
4
  import closeSvg from '../assets/close.svg?raw';
5
+ import { pillToneClass } from './pillTone';
5
6
 
6
7
  let {
7
8
  text,
8
9
  dismissible = false,
9
10
  disabled = false,
11
+ tone,
10
12
  testId,
11
13
  title,
12
14
  dismissIcon,
@@ -49,7 +51,7 @@
49
51
 
50
52
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
51
53
  <div
52
- class="pill {classes ?? ''}"
54
+ class="pill {pillToneClass(tone)} {classes ?? ''}"
53
55
  class:disabled
54
56
  onclick={interactive ? handleClick : null}
55
57
  onkeydown={interactive ? handleKeydown : null}
@@ -96,23 +98,60 @@
96
98
  justify-content: var(--pill-justify-content, center);
97
99
  text-align: var(--pill-text-align, center);
98
100
  gap: var(--pill-gap, 4px);
99
- background-color: var(--pill-background, #e0e0e0);
100
- color: var(--pill-color, #333333);
101
+ background-color: var(--pill-background, var(--_pill-tone-background, #e0e0e0));
102
+ color: var(--pill-color, var(--_pill-tone-color, #333333));
101
103
  font-size: var(--pill-font-size, 13px);
102
104
  font-weight: var(--pill-font-weight, 500);
103
105
  font-family: var(--pill-font-family);
106
+ letter-spacing: var(--pill-letter-spacing, normal);
107
+ text-transform: var(--pill-text-transform, none);
104
108
  padding: var(--pill-padding, 6px 10px);
105
109
  border-radius: var(--pill-border-radius, 999px);
106
110
  border: var(--pill-border, none);
107
- cursor: var(--pill-cursor, pointer);
111
+ /* Non-interactive by default: nothing is clickable, so nothing should look
112
+ clickable. The pointer default below applies only once role="button" is
113
+ actually present (see handleClick / `interactive`) — an explicit
114
+ --pill-cursor still wins in both cases. */
115
+ cursor: var(--pill-cursor, default);
108
116
  max-width: var(--pill-max-width);
109
117
  line-height: var(--pill-line-height, 1);
110
118
  flex-shrink: var(--pill-flex-shrink);
111
119
  }
112
120
 
121
+ .pill[role='button'] {
122
+ cursor: var(--pill-cursor, pointer);
123
+ }
124
+
125
+ /* Tone defaults — an internal --_pill-tone-* layer, mirroring how Button's
126
+ variant classes set --_btn-*: a plain --pill-background/--pill-color
127
+ (explicit, or via `classes`) always wins over the tone's default. */
128
+ .tone-accent {
129
+ --_pill-tone-background: var(--pill-tone-accent-background, #d1ecf1);
130
+ --_pill-tone-color: var(--pill-tone-accent-color, #0c5460);
131
+ }
132
+ .tone-ok {
133
+ --_pill-tone-background: var(--pill-tone-ok-background, #d4edda);
134
+ --_pill-tone-color: var(--pill-tone-ok-color, #155724);
135
+ }
136
+ .tone-warn {
137
+ --_pill-tone-background: var(--pill-tone-warn-background, #fff3cd);
138
+ --_pill-tone-color: var(--pill-tone-warn-color, #856404);
139
+ }
140
+ .tone-danger {
141
+ --_pill-tone-background: var(--pill-tone-danger-background, #f8d7da);
142
+ --_pill-tone-color: var(--pill-tone-danger-color, #721c24);
143
+ }
144
+ .tone-muted {
145
+ --_pill-tone-background: var(--pill-tone-muted-background, #f1f1f1);
146
+ --_pill-tone-color: var(--pill-tone-muted-color, #6b7280);
147
+ }
148
+
113
149
  .pill:hover:not(.disabled) {
114
- background-color: var(--pill-hover-background, var(--pill-background, #d0d0d0));
115
- color: var(--pill-hover-color, var(--pill-color, #333333));
150
+ background-color: var(
151
+ --pill-hover-background,
152
+ var(--pill-background, var(--_pill-tone-background, #d0d0d0))
153
+ );
154
+ color: var(--pill-hover-color, var(--pill-color, var(--_pill-tone-color, #333333)));
116
155
  }
117
156
 
118
157
  .pill.disabled {
@@ -0,0 +1,15 @@
1
+ import type { PillTone } from './properties';
2
+ /**
3
+ * Canonical tone list, in the order documented in docs/Pill.md and mirrored by the
4
+ * `.tone-*` rules in Pill.svelte's `<style>` block.
5
+ */
6
+ export declare const PILL_TONES: readonly PillTone[];
7
+ /**
8
+ * Resolves the CSS class Pill.svelte applies for a given tone. Pure and exported — rather
9
+ * than five inline `class:tone-x={tone === 'x'}` directives — so the mapping is unit-testable
10
+ * without rendering: this repo's vitest suite runs no browser, so a `.svelte` template's DOM
11
+ * output cannot be asserted on directly (see `src/lib/Table/normalizeColumns.ts` for the same
12
+ * pattern). No tone passed resolves to `''`, so the class list — and therefore the rendered
13
+ * background/color — is unchanged from before this prop existed.
14
+ */
15
+ export declare function pillToneClass(tone?: PillTone): string;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Canonical tone list, in the order documented in docs/Pill.md and mirrored by the
3
+ * `.tone-*` rules in Pill.svelte's `<style>` block.
4
+ */
5
+ export const PILL_TONES = ['accent', 'ok', 'warn', 'danger', 'muted'];
6
+ /**
7
+ * Resolves the CSS class Pill.svelte applies for a given tone. Pure and exported — rather
8
+ * than five inline `class:tone-x={tone === 'x'}` directives — so the mapping is unit-testable
9
+ * without rendering: this repo's vitest suite runs no browser, so a `.svelte` template's DOM
10
+ * output cannot be asserted on directly (see `src/lib/Table/normalizeColumns.ts` for the same
11
+ * pattern). No tone passed resolves to `''`, so the class list — and therefore the rendered
12
+ * background/color — is unchanged from before this prop existed.
13
+ */
14
+ export function pillToneClass(tone) {
15
+ return typeof tone === 'string' ? `tone-${tone}` : '';
16
+ }
@@ -3,9 +3,23 @@ export type PillProperties = MandatoryPillProperties & OptionalPillProperties &
3
3
  export type MandatoryPillProperties = {
4
4
  text: string;
5
5
  };
6
+ /**
7
+ * Semantic tone for a status/category chip. Each tone maps to a
8
+ * `--pill-tone-{tone}-background` / `--pill-tone-{tone}-color` pair (documented, with a built-in
9
+ * default) instead of a hand-rolled `tone-*` class repeated at every call site. An explicit
10
+ * `--pill-background` / `--pill-color` (set directly or via `classes`) always wins over the tone
11
+ * default — the same precedence Button's `variant` already uses relative to `--button-color`.
12
+ */
13
+ export type PillTone = 'accent' | 'ok' | 'warn' | 'danger' | 'muted';
6
14
  export type OptionalPillProperties = {
7
15
  dismissible?: boolean;
8
16
  disabled?: boolean;
17
+ /**
18
+ * Semantic tone applied via themeable `--pill-tone-{tone}-background` / `-color` CSS
19
+ * variables. Unset by default, which renders exactly as before — no tone class, no CSS
20
+ * variable set. See `PillTone` for the mapping and override precedence.
21
+ */
22
+ tone?: PillTone;
9
23
  testId?: string;
10
24
  title?: string;
11
25
  dismissIcon?: Snippet;
@@ -9,6 +9,7 @@
9
9
  disabled = false,
10
10
  showValue = false,
11
11
  labelFormatter,
12
+ ariaLabel,
12
13
  testId,
13
14
  onchange,
14
15
  oninput,
@@ -42,6 +43,7 @@
42
43
  {step}
43
44
  {value}
44
45
  {disabled}
46
+ aria-label={typeof ariaLabel === 'string' ? ariaLabel : null}
45
47
  data-pw={typeof testId === 'string' ? testId : null}
46
48
  testID={typeof testId === 'string' ? testId : null}
47
49
  oninput={handleInput}
@@ -9,6 +9,11 @@ export type OptionalSliderProperties = {
9
9
  disabled?: boolean;
10
10
  showValue?: boolean;
11
11
  labelFormatter?: (value: number) => string;
12
+ /**
13
+ * Names the range input for assistive tech. A bare slider announces only its value,
14
+ * so a control whose purpose is not carried by adjacent visible text needs this.
15
+ */
16
+ ariaLabel?: string;
12
17
  testId?: string;
13
18
  classes?: string;
14
19
  };
@@ -0,0 +1,6 @@
1
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M9 4v3.5A1.5 1.5 0 0 1 7.5 9H4" />
3
+ <path d="M20 9h-3.5A1.5 1.5 0 0 1 15 7.5V4" />
4
+ <path d="M15 20v-3.5a1.5 1.5 0 0 1 1.5-1.5H20" />
5
+ <path d="M4 15h3.5A1.5 1.5 0 0 1 9 16.5V20" />
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M4 9V5.5A1.5 1.5 0 0 1 5.5 4H9" />
3
+ <path d="M15 4h3.5A1.5 1.5 0 0 1 20 5.5V9" />
4
+ <path d="M20 15v3.5a1.5 1.5 0 0 1-1.5 1.5H15" />
5
+ <path d="M9 20H5.5A1.5 1.5 0 0 1 4 18.5V15" />
6
+ </svg>