@juspay/svelte-ui-components 4.1.2 → 4.2.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.
- package/dist/MediaPlayer/MediaPlayer.svelte +287 -2
- package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
- package/dist/MediaPlayer/properties.d.ts +46 -0
- package/dist/Slider/Slider.svelte +2 -0
- package/dist/Slider/properties.d.ts +5 -0
- package/dist/assets/exit-fullscreen.svg +6 -0
- package/dist/assets/fullscreen.svg +6 -0
- package/dist-wc/index.js +537 -297
- package/package.json +1 -1
|
@@ -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
|
|
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
|
};
|
|
@@ -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>
|