@juspay/svelte-ui-components 4.50.0 → 4.52.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/Animations/tokenizedFly.js +9 -2
- package/dist/AreaChart/AreaChart.svelte +41 -13
- package/dist/AreaChart/properties.d.ts +12 -0
- package/dist/AttachmentChipRow/AttachmentChipRow.svelte +7 -6
- package/dist/BarChart/BarChart.svelte +54 -6
- package/dist/BarChart/properties.d.ts +12 -0
- package/dist/Calendar/Calendar.svelte +124 -51
- package/dist/Card/Card.svelte +5 -0
- package/dist/ChatSuggestions/ChatSuggestions.svelte +1 -0
- package/dist/CheckListItem/CheckListItem.svelte +27 -5
- package/dist/ColorPicker/ColorPicker.svelte +40 -9
- package/dist/ColorPicker/properties.d.ts +8 -0
- package/dist/Combobox/Combobox.svelte +34 -2
- package/dist/CommandMenu/CommandMenu.svelte +35 -8
- package/dist/CommandMenu/properties.d.ts +6 -0
- package/dist/DateRangePicker/DateRangePicker.svelte +5 -5
- package/dist/DualAxisBarChart/DualAxisBarChart.svelte +41 -7
- package/dist/DualAxisBarChart/properties.d.ts +4 -0
- package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +104 -10
- package/dist/FileDropzoneTrigger/properties.d.ts +14 -4
- package/dist/FileInput/FileInput.svelte +38 -9
- package/dist/FileInput/context.d.ts +20 -0
- package/dist/FileInput/context.js +8 -0
- package/dist/FileInput/properties.d.ts +25 -1
- package/dist/FunnelChart/FunnelChart.svelte +20 -1
- package/dist/FunnelChart/properties.d.ts +4 -0
- package/dist/Gallery/Gallery.svelte +72 -27
- package/dist/HITL/HITL.svelte +12 -3
- package/dist/LineChart/LineChart.svelte +105 -33
- package/dist/LineChart/properties.d.ts +12 -0
- package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
- package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
- package/dist/MediaPlayer/properties.d.ts +31 -0
- package/dist/Menu/Menu.svelte +6 -4
- package/dist/Modal/Modal.svelte +15 -12
- package/dist/Modal/properties.d.ts +29 -1
- package/dist/Pagination/Pagination.svelte +1 -0
- package/dist/PieChart/PieChart.svelte +51 -29
- package/dist/PieChart/properties.d.ts +4 -0
- package/dist/Pill/Pill.svelte +5 -0
- package/dist/Radio/Radio.svelte +11 -2
- package/dist/Radio/properties.d.ts +7 -2
- package/dist/SankeyChart/SankeyChart.svelte +339 -211
- package/dist/SankeyChart/properties.d.ts +4 -0
- package/dist/Scroller/Scroller.svelte +247 -4
- package/dist/Scroller/properties.d.ts +8 -0
- package/dist/Select/Select.svelte +43 -5
- package/dist/Select/properties.d.ts +17 -1
- package/dist/Sheet/Sheet.svelte +31 -4
- package/dist/Sheet/properties.d.ts +6 -0
- package/dist/SplitInput/SplitInput.svelte +17 -0
- package/dist/SplitInput/field-name.d.ts +29 -0
- package/dist/SplitInput/field-name.js +50 -0
- package/dist/SplitInput/properties.d.ts +18 -4
- package/dist/Status/Status.svelte +24 -12
- package/dist/Status/properties.d.ts +11 -0
- package/dist/Table/BuiltinCell.svelte +13 -5
- package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
- package/dist/Table/Table.svelte +78 -20
- package/dist/Table/a11y.d.ts +7 -0
- package/dist/Table/a11y.js +71 -0
- package/dist/Table/cellData.js +3 -0
- package/dist/Table/properties.d.ts +13 -5
- package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
- package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
- package/dist/Toast/Toast.svelte +40 -4
- package/dist/Toggle/Toggle.svelte +2 -0
- package/dist/Toggle/properties.d.ts +2 -0
- package/dist/_chart/Axis.svelte +10 -0
- package/dist/_chart/ChartContainer.svelte +32 -1
- package/dist/_chart/ChartTooltip.svelte +18 -3
- package/dist/_chart/LabelPlate.svelte +114 -0
- package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
- package/dist/_chart/a11y.d.ts +29 -0
- package/dist/_chart/a11y.js +44 -0
- package/dist/_chart/measure.d.ts +2 -0
- package/dist/_chart/measure.js +4 -0
- package/dist/_chart/tooltipPosition.d.ts +6 -0
- package/dist/_chart/tooltipPosition.js +30 -2
- package/dist/_chart/types.d.ts +22 -0
- package/dist/_interaction/focusable.d.ts +47 -0
- package/dist/_interaction/focusable.js +139 -0
- package/dist/assets/captions-on.svg +3 -0
- package/dist/assets/captions.svg +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/styles/theme-dark.css +6 -1
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +18 -2
- package/dist/utils.js +73 -28
- package/dist-codemod/codemod/migrate-audit.js +9 -7
- package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
- package/dist-codemod/codemod/migration-assets-layout.js +61 -0
- package/dist-wc/index.js +5653 -4148
- package/package.json +4 -2
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { ChartHighlightAPI } from '../_chart/highlight';
|
|
9
9
|
import { onMount } from 'svelte';
|
|
10
10
|
import ChartContainer from '../_chart/ChartContainer.svelte';
|
|
11
|
+
import LabelPlate from '../_chart/LabelPlate.svelte';
|
|
11
12
|
import Axis from '../_chart/Axis.svelte';
|
|
12
13
|
import ChartTooltip from '../_chart/ChartTooltip.svelte';
|
|
13
14
|
import Legend from '../_chart/Legend.svelte';
|
|
@@ -19,16 +20,11 @@
|
|
|
19
20
|
import { measureText } from '../_chart/measure';
|
|
20
21
|
import { resolvePointLabels } from '../_chart/labels';
|
|
21
22
|
import { formatSeriesAggregate } from '../_chart/aggregate';
|
|
23
|
+
import { defaultChartName, resolveChartName } from '../_chart/a11y';
|
|
22
24
|
import type { LegendItem, Point, TooltipAnchor } from '../_chart/types';
|
|
23
25
|
import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
|
|
24
26
|
import { SvelteSet } from 'svelte/reactivity';
|
|
25
27
|
|
|
26
|
-
// Per-instance ID for SVG gradient <linearGradient id> references. Initialised
|
|
27
|
-
// inside onMount so the value is only ever generated on the client — avoids an
|
|
28
|
-
// SSR hydration mismatch that would occur if Math.random() ran on both server
|
|
29
|
-
// and client and produced different strings.
|
|
30
|
-
let uid = $state('');
|
|
31
|
-
|
|
32
28
|
// ── Props ──────────────────────────────────────────────────────
|
|
33
29
|
|
|
34
30
|
let {
|
|
@@ -67,10 +63,21 @@
|
|
|
67
63
|
onchartready,
|
|
68
64
|
onpointclick,
|
|
69
65
|
onpointhover,
|
|
66
|
+
ariaLabel,
|
|
67
|
+
ariaDescription,
|
|
70
68
|
testId,
|
|
71
69
|
classes
|
|
72
70
|
}: LineChartProperties = $props();
|
|
73
71
|
|
|
72
|
+
// Per-instance id for the SVG <linearGradient id> references and the live
|
|
73
|
+
// status region. `$props.id()` is unique per component instance and identical
|
|
74
|
+
// on server and client, so two charts on one page never resolve each other's
|
|
75
|
+
// `url(#gradient)` and hydration never sees a different id than the server
|
|
76
|
+
// wrote. It replaces a Math.random() prefix that was only filled in at
|
|
77
|
+
// mount: until then every chart's gradient id collapsed to the same
|
|
78
|
+
// `line-grad--0`, and afterwards it was collision-prone by construction.
|
|
79
|
+
const instanceId = $props.id();
|
|
80
|
+
|
|
74
81
|
// ── State ──────────────────────────────────────────────────────
|
|
75
82
|
|
|
76
83
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
@@ -120,8 +127,6 @@
|
|
|
120
127
|
);
|
|
121
128
|
|
|
122
129
|
onMount(() => {
|
|
123
|
-
uid = Math.random().toString(36).slice(2, 9);
|
|
124
|
-
|
|
125
130
|
const api: ChartHighlightAPI = {
|
|
126
131
|
type: 'line-chart',
|
|
127
132
|
highlight: (index) => {
|
|
@@ -264,6 +269,29 @@
|
|
|
264
269
|
}))
|
|
265
270
|
);
|
|
266
271
|
|
|
272
|
+
// ── Chart-level accessible name ────────────────────────────────
|
|
273
|
+
|
|
274
|
+
// The drawing's own name: the consumer's `ariaLabel`, else one derived from
|
|
275
|
+
// what the chart plots (see `defaultChartName`).
|
|
276
|
+
let chartName = $derived(
|
|
277
|
+
resolveChartName(
|
|
278
|
+
ariaLabel,
|
|
279
|
+
defaultChartName('line', { yAxisLabel, seriesNames: series.map((s) => s.name) })
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Every finite point of a visible series is a focusable role="button" mark
|
|
284
|
+
// (the `.focus-target` circles below), so the drawing is interactive and must
|
|
285
|
+
// not be exposed as an image. With nothing to operate -- every series hidden
|
|
286
|
+
// through the legend, or no finite point -- it is a plain named image.
|
|
287
|
+
let hasMarks = $derived(
|
|
288
|
+
lines.some(
|
|
289
|
+
(line) =>
|
|
290
|
+
!line.hidden &&
|
|
291
|
+
line.points.some((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
|
292
|
+
)
|
|
293
|
+
);
|
|
294
|
+
|
|
267
295
|
// ── Cross-series alignment ──────────────────────────────
|
|
268
296
|
|
|
269
297
|
// The ONE alignment contract for tooltip/hit-testing (and, via
|
|
@@ -400,7 +428,6 @@
|
|
|
400
428
|
// ── Keyboard access & live status (family-wide contract; see
|
|
401
429
|
// docs/PieChart.md#keyboard-access) ─────────────────────────
|
|
402
430
|
|
|
403
|
-
const instanceId = $props.id();
|
|
404
431
|
const statusId = `line-status-${instanceId}`;
|
|
405
432
|
|
|
406
433
|
// "Active" datum for announcement purposes, in the SAME precedence order
|
|
@@ -434,7 +461,9 @@
|
|
|
434
461
|
return null;
|
|
435
462
|
});
|
|
436
463
|
|
|
437
|
-
|
|
464
|
+
// What the active datum reads as, whatever drove it. `statusText` below is
|
|
465
|
+
// what the live region actually carries.
|
|
466
|
+
let activeStatusText = $derived.by(() => {
|
|
438
467
|
const active = activeDatum;
|
|
439
468
|
if (active === null) {
|
|
440
469
|
return '';
|
|
@@ -465,6 +494,22 @@
|
|
|
465
494
|
: `${name}: ${value}`;
|
|
466
495
|
});
|
|
467
496
|
|
|
497
|
+
// A keyboard-focused mark is already spoken as its own accessible name when it
|
|
498
|
+
// takes focus. Where the region would only repeat that name verbatim (a single
|
|
499
|
+
// series, or a tooltip that is not shared) it adds nothing but a second
|
|
500
|
+
// announcement of the same point, so it stays empty. It still speaks whenever
|
|
501
|
+
// it adds something -- the other series at a shared x -- and for every path
|
|
502
|
+
// that has no focus event to announce it: pointer hover, `highlightedIndex`
|
|
503
|
+
// and the `ChartHighlightAPI`.
|
|
504
|
+
let statusText = $derived.by(() => {
|
|
505
|
+
const text = activeStatusText;
|
|
506
|
+
if (text === '' || focused === null) {
|
|
507
|
+
return text;
|
|
508
|
+
}
|
|
509
|
+
const entry = joinedByX.get(focused.x)?.values[focused.si];
|
|
510
|
+
return entry && text === focusAriaLabel(focused.si, entry.index, entry.point.x) ? '' : text;
|
|
511
|
+
});
|
|
512
|
+
|
|
468
513
|
// ── Point labels ───────────────────────────────────────────────
|
|
469
514
|
|
|
470
515
|
let pointLabelPlacements = $derived.by(() => {
|
|
@@ -479,7 +524,11 @@
|
|
|
479
524
|
: resolvePointLabels({
|
|
480
525
|
points: line.points,
|
|
481
526
|
labels: series[si].data.map((d) => measureText(formatNumber(d.y), font)),
|
|
482
|
-
plot
|
|
527
|
+
plot,
|
|
528
|
+
// Leave room for the 6px keyboard target, its native focus outline
|
|
529
|
+
// and the label's padded plate. Firefox paints the outline above
|
|
530
|
+
// SVG siblings, so paint order alone cannot protect nearby text.
|
|
531
|
+
gap: 16
|
|
483
532
|
})
|
|
484
533
|
);
|
|
485
534
|
});
|
|
@@ -754,7 +803,8 @@
|
|
|
754
803
|
{:else}
|
|
755
804
|
<!-- Mirrors the tooltip's own text for every activation path (pointer,
|
|
756
805
|
keyboard focus, declarative highlightedIndex, imperative
|
|
757
|
-
ChartHighlightAPI) --
|
|
806
|
+
ChartHighlightAPI) -- except that a focused mark whose own accessible
|
|
807
|
+
name already says it is not repeated. See statusText. -->
|
|
758
808
|
<div class="sr-only" role="status" aria-live="polite" id={statusId} data-pw="line-status">
|
|
759
809
|
{statusText}
|
|
760
810
|
</div>
|
|
@@ -774,13 +824,16 @@
|
|
|
774
824
|
{aspectRatio}
|
|
775
825
|
{minHeight}
|
|
776
826
|
{maxHeight}
|
|
827
|
+
ariaLabel={chartName}
|
|
828
|
+
{ariaDescription}
|
|
829
|
+
interactive={hasMarks}
|
|
777
830
|
>
|
|
778
831
|
{#if gradientFill || showArea}
|
|
779
832
|
<defs>
|
|
780
833
|
{#each lines as line, si (si)}
|
|
781
834
|
{#if gradientFill}
|
|
782
835
|
<linearGradient
|
|
783
|
-
id="line-grad-{
|
|
836
|
+
id="line-grad-{instanceId}-{si}"
|
|
784
837
|
x1="0"
|
|
785
838
|
y1="0"
|
|
786
839
|
x2="0"
|
|
@@ -804,7 +857,7 @@
|
|
|
804
857
|
{/if}
|
|
805
858
|
{#if showArea}
|
|
806
859
|
<linearGradient
|
|
807
|
-
id="line-area-{
|
|
860
|
+
id="line-area-{instanceId}-{si}"
|
|
808
861
|
x1="0"
|
|
809
862
|
y1="0"
|
|
810
863
|
x2="0"
|
|
@@ -874,14 +927,14 @@
|
|
|
874
927
|
class="line-area-fill"
|
|
875
928
|
class:dimmed={isLineDimmed(si)}
|
|
876
929
|
d={line.areaD}
|
|
877
|
-
fill="url(#line-area-{
|
|
930
|
+
fill="url(#line-area-{instanceId}-{si})"
|
|
878
931
|
/>
|
|
879
932
|
{:else if gradientFill}
|
|
880
933
|
<path
|
|
881
934
|
class="line-area-fill"
|
|
882
935
|
class:dimmed={pointerOrFocused !== null && pointerOrFocused.si !== si}
|
|
883
936
|
d={line.areaD}
|
|
884
|
-
fill="url(#line-grad-{
|
|
937
|
+
fill="url(#line-grad-{instanceId}-{si})"
|
|
885
938
|
/>
|
|
886
939
|
{/if}
|
|
887
940
|
<path
|
|
@@ -893,21 +946,6 @@
|
|
|
893
946
|
stroke-dasharray={line.dash}
|
|
894
947
|
fill="none"
|
|
895
948
|
/>
|
|
896
|
-
{#if showValues && pointLabelPlacements[si]}
|
|
897
|
-
{#each line.points as _point, pi (pi)}
|
|
898
|
-
{@const pl = pointLabelPlacements[si][pi]}
|
|
899
|
-
{#if pl?.visible && Number.isFinite(pl.x) && Number.isFinite(pl.y)}
|
|
900
|
-
<text
|
|
901
|
-
class="point-value"
|
|
902
|
-
x={pl.x}
|
|
903
|
-
y={pl.y}
|
|
904
|
-
text-anchor="middle"
|
|
905
|
-
dominant-baseline={pl.dominantBaseline}
|
|
906
|
-
>{formatNumber(series[si].data[pi].y)}</text
|
|
907
|
-
>
|
|
908
|
-
{/if}
|
|
909
|
-
{/each}
|
|
910
|
-
{/if}
|
|
911
949
|
{/if}
|
|
912
950
|
{/each}
|
|
913
951
|
|
|
@@ -974,7 +1012,15 @@
|
|
|
974
1012
|
`pointer-events: none` does not block this: it suppresses
|
|
975
1013
|
hit-testing for real pointer input -- which is what keeps
|
|
976
1014
|
mouse hover flowing to the overlay underneath -- while a
|
|
977
|
-
dispatched click still fires a listener on the element.
|
|
1015
|
+
dispatched click still fires a listener on the element.
|
|
1016
|
+
|
|
1017
|
+
No `aria-describedby` pointing at the status region. The
|
|
1018
|
+
region already announces what the name does not as it
|
|
1019
|
+
updates, so describing the point by it as well spoke the
|
|
1020
|
+
same text a second time; and because the region is
|
|
1021
|
+
shared, every UNFOCUSED point was "described" by whichever
|
|
1022
|
+
point was announced last. The point is spoken as its own
|
|
1023
|
+
name, and the region adds only what that name lacks. -->
|
|
978
1024
|
<circle
|
|
979
1025
|
class="focus-target"
|
|
980
1026
|
cx={point.x}
|
|
@@ -983,7 +1029,6 @@
|
|
|
983
1029
|
tabindex="0"
|
|
984
1030
|
role="button"
|
|
985
1031
|
aria-label={focusAriaLabel(si, pi, raw.x)}
|
|
986
|
-
aria-describedby={statusId}
|
|
987
1032
|
onfocus={() => handleMarkFocus(si, pi)}
|
|
988
1033
|
onblur={handleBlur}
|
|
989
1034
|
onkeydown={handleMarkKeydown}
|
|
@@ -1019,6 +1064,33 @@
|
|
|
1019
1064
|
onpointerleave={handlePointerLeave}
|
|
1020
1065
|
onclick={handleClick}
|
|
1021
1066
|
/>
|
|
1067
|
+
<!-- Labels paint last so every glyph keeps its opaque backdrop across
|
|
1068
|
+
line crossings, highlight markers and hover guides. -->
|
|
1069
|
+
{#each lines as line, si (si)}
|
|
1070
|
+
{#if !line.hidden}
|
|
1071
|
+
{#if showValues && pointLabelPlacements[si]}
|
|
1072
|
+
{#each line.points as _point, pi (pi)}
|
|
1073
|
+
{@const pl = pointLabelPlacements[si][pi]}
|
|
1074
|
+
{#if pl?.visible && Number.isFinite(pl.x) && Number.isFinite(pl.y)}
|
|
1075
|
+
<!-- Repeats the point's own accessible name, which is what
|
|
1076
|
+
assistive technology reads; exposing it again would announce
|
|
1077
|
+
every value twice. -->
|
|
1078
|
+
<LabelPlate>
|
|
1079
|
+
<text
|
|
1080
|
+
class="point-value"
|
|
1081
|
+
aria-hidden="true"
|
|
1082
|
+
x={pl.x}
|
|
1083
|
+
y={pl.y}
|
|
1084
|
+
text-anchor="middle"
|
|
1085
|
+
dominant-baseline={pl.dominantBaseline}
|
|
1086
|
+
>{formatNumber(series[si].data[pi].y)}</text
|
|
1087
|
+
>
|
|
1088
|
+
</LabelPlate>
|
|
1089
|
+
{/if}
|
|
1090
|
+
{/each}
|
|
1091
|
+
{/if}
|
|
1092
|
+
{/if}
|
|
1093
|
+
{/each}
|
|
1022
1094
|
</g>
|
|
1023
1095
|
</ChartContainer>
|
|
1024
1096
|
|
|
@@ -139,6 +139,18 @@ export type OptionalLineChartProperties = {
|
|
|
139
139
|
hideLegendBelow?: number;
|
|
140
140
|
/** Render the tooltip into document.body so scroll/overflow ancestors never clip it. */
|
|
141
141
|
tooltipPortal?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Accessible name of the chart (`aria-label` on its `<svg>`). Say what the chart
|
|
144
|
+
* shows, e.g. `"Monthly revenue, January to June"`. When omitted a name is
|
|
145
|
+
* derived from the chart's own data (`"{yAxisLabel} line chart"`, else the series names, else `"Line chart"`), so an unlabelled chart is still
|
|
146
|
+
* identifiable -- but a name written for the page is almost always better.
|
|
147
|
+
*/
|
|
148
|
+
ariaLabel?: string;
|
|
149
|
+
/**
|
|
150
|
+
* Optional longer description (trend, range, the takeaway) announced after the
|
|
151
|
+
* name. Rendered as an SVG `<desc>` referenced by `aria-describedby`.
|
|
152
|
+
*/
|
|
153
|
+
ariaDescription?: string;
|
|
142
154
|
/** Value for the data-pw attribute on the chart container. */
|
|
143
155
|
testId?: string;
|
|
144
156
|
/** CSS class string applied to the top-level element. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
2
3
|
import Button from '../Button/Button.svelte';
|
|
3
4
|
import Img from '../Img/Img.svelte';
|
|
4
5
|
import playSvg from '../assets/play.svg?raw';
|
|
@@ -7,6 +8,8 @@
|
|
|
7
8
|
import muteSvg from '../assets/mute.svg?raw';
|
|
8
9
|
import fullscreenSvg from '../assets/fullscreen.svg?raw';
|
|
9
10
|
import exitFullscreenSvg from '../assets/exit-fullscreen.svg?raw';
|
|
11
|
+
import captionsSvg from '../assets/captions.svg?raw';
|
|
12
|
+
import captionsOnSvg from '../assets/captions-on.svg?raw';
|
|
10
13
|
import Slider from '../Slider/Slider.svelte';
|
|
11
14
|
import type { MediaPlayerProperties } from './properties';
|
|
12
15
|
|
|
@@ -27,6 +30,10 @@
|
|
|
27
30
|
captionsSrc,
|
|
28
31
|
captionsLabel,
|
|
29
32
|
captionsSrcLang,
|
|
33
|
+
captionsButton = false,
|
|
34
|
+
captionsVisible = $bindable(false),
|
|
35
|
+
captionsIcon,
|
|
36
|
+
captionsOnIcon,
|
|
30
37
|
seekBar = false,
|
|
31
38
|
timeDisplay = false,
|
|
32
39
|
fullscreenButton = false,
|
|
@@ -37,6 +44,7 @@
|
|
|
37
44
|
onplay,
|
|
38
45
|
onpause,
|
|
39
46
|
onvolumechange,
|
|
47
|
+
oncaptionschange,
|
|
40
48
|
onseek,
|
|
41
49
|
ontimeupdate,
|
|
42
50
|
onfullscreenchange,
|
|
@@ -118,6 +126,82 @@
|
|
|
118
126
|
onvolumechange?.(muted);
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
// The `<track>` is optional, so the element that owns the TextTrack is bound rather than
|
|
130
|
+
// looked up: it is null exactly when no captions were supplied.
|
|
131
|
+
let captionTrack: HTMLTrackElement | null = $state(null);
|
|
132
|
+
const hasCaptions = $derived(typeof captionsSrc === 'string' && captionsSrc.length > 0);
|
|
133
|
+
|
|
134
|
+
function toggleCaptions(): void {
|
|
135
|
+
captionsVisible = !captionsVisible;
|
|
136
|
+
oncaptionschange?.(captionsVisible);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The track -> `captionsVisible`. Under native `controls` the browser's own menu changes
|
|
141
|
+
* `mode` without going through this component, and a browser or OS preference for
|
|
142
|
+
* captions can turn a track on by itself; the list's `change` event is how either reaches
|
|
143
|
+
* the bound value (it fires for programmatic writes too, which land on the early return).
|
|
144
|
+
* `oncaptionschange` fires from here only for a change the component did not just make
|
|
145
|
+
* itself, so a host's own write is never echoed back as a viewer's choice.
|
|
146
|
+
*
|
|
147
|
+
* The one-time read at the top covers a track already `showing` before this listener is
|
|
148
|
+
* attached -- the event does not replay. It runs untracked: reading `captionsVisible`
|
|
149
|
+
* here would re-run this effect on every host write and flip a pending hide back to
|
|
150
|
+
* `true` before the effect below has applied it.
|
|
151
|
+
*/
|
|
152
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
153
|
+
$effect(() => {
|
|
154
|
+
const textTrack = captionTrack?.track;
|
|
155
|
+
const trackList = videoPlayer?.textTracks;
|
|
156
|
+
if (!textTrack || !trackList) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// An arrow, not a declaration: only a `const` keeps the narrowing of `textTrack` above.
|
|
160
|
+
const mirrorTrackMode = (): void => {
|
|
161
|
+
const showing = textTrack.mode === 'showing';
|
|
162
|
+
if (showing === captionsVisible) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
captionsVisible = showing;
|
|
166
|
+
oncaptionschange?.(showing);
|
|
167
|
+
};
|
|
168
|
+
untrack(() => {
|
|
169
|
+
if (textTrack.mode === 'showing') {
|
|
170
|
+
mirrorTrackMode();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
trackList.addEventListener('change', mirrorTrackMode);
|
|
174
|
+
return () => trackList.removeEventListener('change', mirrorTrackMode);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `captionsVisible` -> the track. A `<track>` without `default` starts `disabled`, which
|
|
179
|
+
* is why the shipped example had captions that nothing could turn on. Writing `showing`
|
|
180
|
+
* is what makes the browser fetch the VTT and paint cues; `hidden` keeps the cues loaded
|
|
181
|
+
* but unpainted, so showing them again is instant.
|
|
182
|
+
*
|
|
183
|
+
* Declared AFTER the effect that mirrors the track into `captionsVisible`, deliberately:
|
|
184
|
+
* effects run in declaration order, and a track the browser already chose to show (an
|
|
185
|
+
* accessibility preference for captions) has to be adopted by that one first, or this one would
|
|
186
|
+
* read the default `false` and hide it.
|
|
187
|
+
*
|
|
188
|
+
* Skipped while the two already agree. That is what leaves a `disabled` track alone for
|
|
189
|
+
* a player nobody asked to show captions on, rather than rewriting it to `hidden` for no
|
|
190
|
+
* reader's benefit, and it stops this from fighting the `change` listener above.
|
|
191
|
+
*/
|
|
192
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
193
|
+
$effect(() => {
|
|
194
|
+
const textTrack = captionTrack?.track;
|
|
195
|
+
if (!textTrack) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const wanted = captionsVisible;
|
|
199
|
+
if (wanted === (textTrack.mode === 'showing')) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
textTrack.mode = wanted ? 'showing' : 'hidden';
|
|
203
|
+
});
|
|
204
|
+
|
|
121
205
|
function handlePlay(event: Event): void {
|
|
122
206
|
playing = true;
|
|
123
207
|
onplay?.(event);
|
|
@@ -291,6 +375,16 @@
|
|
|
291
375
|
<Img {src} {alt} {fallback} />
|
|
292
376
|
</span>
|
|
293
377
|
{:else}
|
|
378
|
+
<!--
|
|
379
|
+
The caption track is caller-supplied (`captionsSrc`) and rendered only when it is. The
|
|
380
|
+
compiler only counts a `<track>` that is a static, unconditional child of the
|
|
381
|
+
`<video>`, so this warning is a false positive for the conditional one below. The
|
|
382
|
+
alternative it pushes toward -- an always-present `<track>` with no `src` -- would put
|
|
383
|
+
an empty, non-functional entry in every player's native captions menu, which is
|
|
384
|
+
exactly what the optional contract exists to avoid. Scoped to this one element and
|
|
385
|
+
this one rule; nothing else in the file is silenced.
|
|
386
|
+
-->
|
|
387
|
+
<!-- svelte-ignore a11y_media_has_caption -->
|
|
294
388
|
<video
|
|
295
389
|
bind:this={videoPlayer}
|
|
296
390
|
bind:muted
|
|
@@ -311,8 +405,9 @@
|
|
|
311
405
|
tabindex={controls ? null : 0}
|
|
312
406
|
aria-label={controls ? null : playing ? 'Pause video' : 'Play video'}
|
|
313
407
|
>
|
|
314
|
-
{#if
|
|
408
|
+
{#if hasCaptions}
|
|
315
409
|
<track
|
|
410
|
+
bind:this={captionTrack}
|
|
316
411
|
kind="captions"
|
|
317
412
|
src={captionsSrc}
|
|
318
413
|
label={typeof captionsLabel === 'string' ? captionsLabel : null}
|
|
@@ -379,6 +474,38 @@
|
|
|
379
474
|
{/if}
|
|
380
475
|
</Button>
|
|
381
476
|
</div>
|
|
477
|
+
{#if captionsButton && hasCaptions}
|
|
478
|
+
<div
|
|
479
|
+
class="control bottom-control"
|
|
480
|
+
data-pw={typeof testId === 'string' ? `${testId}-captions` : null}
|
|
481
|
+
>
|
|
482
|
+
<!-- A native button rather than `Button`: the pressed state is the whole
|
|
483
|
+
point of this control and `Button` has no `aria-pressed`. The name stays
|
|
484
|
+
"Captions" and `aria-pressed` carries on/off, so a screen reader hears
|
|
485
|
+
"Captions, toggle button, pressed" rather than a name that flips under it. -->
|
|
486
|
+
<button
|
|
487
|
+
type="button"
|
|
488
|
+
class="captions-toggle"
|
|
489
|
+
aria-label="Captions"
|
|
490
|
+
aria-pressed={captionsVisible}
|
|
491
|
+
onclick={toggleCaptions}
|
|
492
|
+
>
|
|
493
|
+
{#if captionsVisible}
|
|
494
|
+
{#if typeof captionsOnIcon === 'function'}
|
|
495
|
+
{@render captionsOnIcon()}
|
|
496
|
+
{:else}
|
|
497
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
498
|
+
{@html captionsOnSvg}
|
|
499
|
+
{/if}
|
|
500
|
+
{:else if typeof captionsIcon === 'function'}
|
|
501
|
+
{@render captionsIcon()}
|
|
502
|
+
{:else}
|
|
503
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
504
|
+
{@html captionsSvg}
|
|
505
|
+
{/if}
|
|
506
|
+
</button>
|
|
507
|
+
</div>
|
|
508
|
+
{/if}
|
|
382
509
|
{#if fullscreenButton}
|
|
383
510
|
<div
|
|
384
511
|
class="control bottom-control"
|
|
@@ -445,6 +572,8 @@
|
|
|
445
572
|
}
|
|
446
573
|
|
|
447
574
|
.overlay {
|
|
575
|
+
/* Decorative overlay space leaves the native video pointer route operative. */
|
|
576
|
+
pointer-events: none;
|
|
448
577
|
position: absolute;
|
|
449
578
|
inset: 0;
|
|
450
579
|
display: flex;
|
|
@@ -471,13 +600,17 @@
|
|
|
471
600
|
/* :focus-within alongside :hover so the overlay controls (play/pause, mute) are
|
|
472
601
|
reachable for keyboard users too -- :hover alone left them visibility:hidden (and
|
|
473
602
|
so out of the tab order) for anyone not using a mouse. */
|
|
474
|
-
.
|
|
603
|
+
.media-player:hover .overlay,
|
|
475
604
|
.overlay:focus-within {
|
|
476
605
|
background-color: var(--media-player-overlay-hover-color, #0000004d);
|
|
477
606
|
--center-controls-visibility: visible;
|
|
478
607
|
--bottom-controls-visibility: visible;
|
|
479
608
|
}
|
|
480
609
|
|
|
610
|
+
.control {
|
|
611
|
+
pointer-events: auto;
|
|
612
|
+
}
|
|
613
|
+
|
|
481
614
|
.center-controls {
|
|
482
615
|
display: flex;
|
|
483
616
|
flex: 1;
|
|
@@ -491,6 +624,9 @@
|
|
|
491
624
|
width: 100%;
|
|
492
625
|
height: fit-content;
|
|
493
626
|
justify-content: var(--media-player-bottom-controls-justify, flex-end);
|
|
627
|
+
/* Only visible once a second control joins mute (captions); a lone control has no
|
|
628
|
+
neighbour to be spaced from, so a player without one renders exactly as before. */
|
|
629
|
+
gap: var(--media-player-bottom-controls-gap, 12px);
|
|
494
630
|
box-sizing: border-box;
|
|
495
631
|
padding: var(--media-player-bottom-controls-padding, 12px);
|
|
496
632
|
visibility: var(--bottom-controls-visibility);
|
|
@@ -561,6 +697,40 @@
|
|
|
561
697
|
--button-height: var(--media-player-bottom-control-size, 24px);
|
|
562
698
|
}
|
|
563
699
|
|
|
700
|
+
/* The captions toggle is a native button, so it reads the same --media-player-control-*
|
|
701
|
+
hooks `Button` is handed above and sits in the same wrapper as its neighbours. */
|
|
702
|
+
.captions-toggle {
|
|
703
|
+
display: flex;
|
|
704
|
+
align-items: center;
|
|
705
|
+
justify-content: center;
|
|
706
|
+
box-sizing: border-box;
|
|
707
|
+
width: var(--media-player-bottom-control-size, 24px);
|
|
708
|
+
height: var(--media-player-bottom-control-size, 24px);
|
|
709
|
+
margin: 0;
|
|
710
|
+
padding: var(--media-player-control-padding, 0px);
|
|
711
|
+
border: var(--media-player-control-border, none);
|
|
712
|
+
border-radius: var(--media-player-control-border-radius, 50%);
|
|
713
|
+
background-color: var(--media-player-control-background-color, transparent);
|
|
714
|
+
color: var(--media-player-control-color, #ffffff);
|
|
715
|
+
cursor: pointer;
|
|
716
|
+
/* Resolves exactly as the sibling `Button`s do (their `.button-el` sets the same
|
|
717
|
+
fallback), so the toggle is revealed and focusable together with them. */
|
|
718
|
+
visibility: var(--button-visibility, visible);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.captions-toggle:hover {
|
|
722
|
+
background-color: var(
|
|
723
|
+
--media-player-control-hover-background-color,
|
|
724
|
+
var(--media-player-control-background-color, transparent)
|
|
725
|
+
);
|
|
726
|
+
color: var(--media-player-control-hover-color, var(--media-player-control-color, #ffffff));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.captions-toggle:focus-visible {
|
|
730
|
+
outline: var(--media-player-control-focus-outline, 2px solid currentColor);
|
|
731
|
+
outline-offset: var(--media-player-control-focus-outline-offset, 2px);
|
|
732
|
+
}
|
|
733
|
+
|
|
564
734
|
.control :global(svg),
|
|
565
735
|
.control :global(img) {
|
|
566
736
|
height: var(--media-player-control-icon-size, 100%);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { MediaPlayerProperties } from './properties';
|
|
2
|
-
declare const MediaPlayer: import("svelte").Component<MediaPlayerProperties, {}, "muted" | "playing" | "duration" | "currentTime">;
|
|
2
|
+
declare const MediaPlayer: import("svelte").Component<MediaPlayerProperties, {}, "muted" | "playing" | "duration" | "captionsVisible" | "currentTime">;
|
|
3
3
|
type MediaPlayer = ReturnType<typeof MediaPlayer>;
|
|
4
4
|
export default MediaPlayer;
|
|
@@ -24,6 +24,31 @@ 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 captions toggle in the bottom controls: a real `<button>` named "Captions"
|
|
29
|
+
* whose `aria-pressed` carries the on/off state. Video only, and only when `captionsSrc`
|
|
30
|
+
* is supplied -- a toggle with no track behind it would be a dead control. Off by
|
|
31
|
+
* default, so a player that showed only play and mute keeps showing only play and mute.
|
|
32
|
+
*
|
|
33
|
+
* It exists because the custom overlay replaces the browser's own controls, and with
|
|
34
|
+
* them the browser's captions menu: without it, a track the page supplied had no
|
|
35
|
+
* viewer-operable way to be shown. With native `controls` the browser's menu does that
|
|
36
|
+
* job and this toggle is not drawn.
|
|
37
|
+
*/
|
|
38
|
+
captionsButton?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Whether the captions track is showing. Bindable, both directions: the toggle, and the
|
|
41
|
+
* browser's own captions menu under native `controls`, report outward, and a host
|
|
42
|
+
* writing it shows or hides the track -- which is how a host builds its own control.
|
|
43
|
+
* `false` hides the track while leaving it loaded; it does not remove the `<track>`.
|
|
44
|
+
* A browser or OS preference for captions can turn it on at load, in which case the
|
|
45
|
+
* bound value follows. Only meaningful with `captionsSrc`.
|
|
46
|
+
*/
|
|
47
|
+
captionsVisible?: boolean;
|
|
48
|
+
/** Replaces the default icon on the captions toggle while captions are hidden. */
|
|
49
|
+
captionsIcon?: Snippet;
|
|
50
|
+
/** Replaces the default icon on the captions toggle while captions are showing. */
|
|
51
|
+
captionsOnIcon?: Snippet;
|
|
27
52
|
/**
|
|
28
53
|
* Render a seek bar in the bottom controls. Video only. The bar is a library
|
|
29
54
|
* `Slider` bound to the media's position, so dragging it scrubs and playback
|
|
@@ -67,6 +92,12 @@ export type MediaPlayerEventProperties = {
|
|
|
67
92
|
onplay?: (event: Event) => void;
|
|
68
93
|
onpause?: (event: Event) => void;
|
|
69
94
|
onvolumechange?: (muted: boolean) => void;
|
|
95
|
+
/**
|
|
96
|
+
* The viewer showed or hid the captions, through the toggle or the browser's own menu.
|
|
97
|
+
* Carries the state it is now in. Does not fire for a host's own `captionsVisible`
|
|
98
|
+
* write, the same split `onseek` keeps between a scrub and a restored position.
|
|
99
|
+
*/
|
|
100
|
+
oncaptionschange?: (visible: boolean) => void;
|
|
70
101
|
/**
|
|
71
102
|
* The viewer moved the seek bar. Carries the position seeked to, in seconds.
|
|
72
103
|
* Fires only for a deliberate scrub, not for playback advancing on its own —
|
package/dist/Menu/Menu.svelte
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import Img from '../Img/Img.svelte';
|
|
5
5
|
import { computeMenuDropdownPosition } from './dropdownPosition';
|
|
6
6
|
import { eventHitsInside, registerDismissible } from '../_interaction/dismissal';
|
|
7
|
+
import { findInteractive } from '../_interaction/focusable';
|
|
7
8
|
import { tokenizedFly } from '../Animations/tokenizedFly';
|
|
8
9
|
import type { MenuProperties, MenuItem, MenuPlacement } from './properties';
|
|
9
10
|
|
|
@@ -251,16 +252,17 @@
|
|
|
251
252
|
* Returns focus to whatever is actually focusable for this trigger. Under
|
|
252
253
|
* `interactiveTrigger` the wrapper carries no tabindex, so focusing it is a no-op and
|
|
253
254
|
* focus falls to <body> — the control the snippet rendered is the real target.
|
|
255
|
+
* The search follows `<slot>` projection and open shadow roots: through `<sui-menu>`
|
|
256
|
+
* that control is the consumer's slotted element, which `querySelector` on the
|
|
257
|
+
* wrapper cannot see.
|
|
254
258
|
*/
|
|
255
259
|
function focusTrigger() {
|
|
256
260
|
if (triggerEl === null) {
|
|
257
261
|
return;
|
|
258
262
|
}
|
|
259
263
|
if (interactiveTrigger) {
|
|
260
|
-
const control = triggerEl
|
|
261
|
-
|
|
262
|
-
);
|
|
263
|
-
if (control instanceof HTMLElement) {
|
|
264
|
+
const control = findInteractive(triggerEl);
|
|
265
|
+
if (control !== null) {
|
|
264
266
|
control.focus({ preventScroll: true });
|
|
265
267
|
return;
|
|
266
268
|
}
|