@juspay/svelte-ui-components 4.51.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.
Files changed (95) hide show
  1. package/dist/Animations/tokenizedFly.js +9 -2
  2. package/dist/AreaChart/AreaChart.svelte +41 -13
  3. package/dist/AreaChart/properties.d.ts +12 -0
  4. package/dist/AttachmentChipRow/AttachmentChipRow.svelte +7 -6
  5. package/dist/BarChart/BarChart.svelte +54 -6
  6. package/dist/BarChart/properties.d.ts +12 -0
  7. package/dist/Calendar/Calendar.svelte +124 -51
  8. package/dist/Card/Card.svelte +5 -0
  9. package/dist/ChatSuggestions/ChatSuggestions.svelte +1 -0
  10. package/dist/CheckListItem/CheckListItem.svelte +27 -5
  11. package/dist/ColorPicker/ColorPicker.svelte +40 -9
  12. package/dist/ColorPicker/properties.d.ts +8 -0
  13. package/dist/Combobox/Combobox.svelte +34 -2
  14. package/dist/CommandMenu/CommandMenu.svelte +35 -8
  15. package/dist/CommandMenu/properties.d.ts +6 -0
  16. package/dist/DateRangePicker/DateRangePicker.svelte +5 -5
  17. package/dist/DualAxisBarChart/DualAxisBarChart.svelte +41 -7
  18. package/dist/DualAxisBarChart/properties.d.ts +4 -0
  19. package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +104 -10
  20. package/dist/FileDropzoneTrigger/properties.d.ts +14 -4
  21. package/dist/FileInput/FileInput.svelte +38 -9
  22. package/dist/FileInput/context.d.ts +20 -0
  23. package/dist/FileInput/context.js +8 -0
  24. package/dist/FileInput/properties.d.ts +25 -1
  25. package/dist/FunnelChart/FunnelChart.svelte +20 -1
  26. package/dist/FunnelChart/properties.d.ts +4 -0
  27. package/dist/Gallery/Gallery.svelte +72 -27
  28. package/dist/HITL/HITL.svelte +12 -3
  29. package/dist/LineChart/LineChart.svelte +105 -33
  30. package/dist/LineChart/properties.d.ts +12 -0
  31. package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
  32. package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
  33. package/dist/MediaPlayer/properties.d.ts +31 -0
  34. package/dist/Menu/Menu.svelte +6 -4
  35. package/dist/Modal/Modal.svelte +15 -12
  36. package/dist/Modal/properties.d.ts +29 -1
  37. package/dist/Pagination/Pagination.svelte +1 -0
  38. package/dist/PieChart/PieChart.svelte +51 -29
  39. package/dist/PieChart/properties.d.ts +4 -0
  40. package/dist/Pill/Pill.svelte +5 -0
  41. package/dist/Radio/Radio.svelte +11 -2
  42. package/dist/Radio/properties.d.ts +7 -2
  43. package/dist/SankeyChart/SankeyChart.svelte +339 -211
  44. package/dist/SankeyChart/properties.d.ts +4 -0
  45. package/dist/Scroller/Scroller.svelte +236 -4
  46. package/dist/Scroller/properties.d.ts +8 -0
  47. package/dist/Select/Select.svelte +43 -5
  48. package/dist/Select/properties.d.ts +17 -1
  49. package/dist/Sheet/Sheet.svelte +31 -4
  50. package/dist/Sheet/properties.d.ts +6 -0
  51. package/dist/SplitInput/SplitInput.svelte +17 -0
  52. package/dist/SplitInput/field-name.d.ts +29 -0
  53. package/dist/SplitInput/field-name.js +50 -0
  54. package/dist/SplitInput/properties.d.ts +18 -4
  55. package/dist/Status/Status.svelte +24 -12
  56. package/dist/Status/properties.d.ts +11 -0
  57. package/dist/Table/BuiltinCell.svelte +13 -5
  58. package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
  59. package/dist/Table/Table.svelte +78 -20
  60. package/dist/Table/a11y.d.ts +7 -0
  61. package/dist/Table/a11y.js +71 -0
  62. package/dist/Table/cellData.js +3 -0
  63. package/dist/Table/properties.d.ts +13 -5
  64. package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
  65. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
  66. package/dist/Toast/Toast.svelte +40 -4
  67. package/dist/Toggle/Toggle.svelte +2 -0
  68. package/dist/Toggle/properties.d.ts +2 -0
  69. package/dist/_chart/Axis.svelte +10 -0
  70. package/dist/_chart/ChartContainer.svelte +32 -1
  71. package/dist/_chart/ChartTooltip.svelte +18 -3
  72. package/dist/_chart/LabelPlate.svelte +114 -0
  73. package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
  74. package/dist/_chart/a11y.d.ts +29 -0
  75. package/dist/_chart/a11y.js +44 -0
  76. package/dist/_chart/measure.d.ts +2 -0
  77. package/dist/_chart/measure.js +4 -0
  78. package/dist/_chart/tooltipPosition.d.ts +6 -0
  79. package/dist/_chart/tooltipPosition.js +30 -2
  80. package/dist/_chart/types.d.ts +22 -0
  81. package/dist/_interaction/focusable.d.ts +47 -0
  82. package/dist/_interaction/focusable.js +139 -0
  83. package/dist/assets/captions-on.svg +3 -0
  84. package/dist/assets/captions.svg +5 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/styles/theme-dark.css +6 -1
  88. package/dist/types.d.ts +2 -0
  89. package/dist/utils.d.ts +18 -2
  90. package/dist/utils.js +73 -28
  91. package/dist-codemod/codemod/migrate-audit.js +9 -7
  92. package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
  93. package/dist-codemod/codemod/migration-assets-layout.js +61 -0
  94. package/dist-wc/index.js +5653 -4148
  95. 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
- let statusText = $derived.by(() => {
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) -- see statusText. -->
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-{uid}-{si}"
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-{uid}-{si}"
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-{uid}-{si})"
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-{uid}-{si})"
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 typeof captionsSrc === 'string' && captionsSrc.length > 0}
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
- .overlay:hover,
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 —
@@ -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.querySelector(
261
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
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
  }