@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
@@ -137,8 +137,15 @@ export function tokenizedFly(node, params) {
137
137
  // above resolved to. 0.001 rather than a hard 0: still visually instant, but
138
138
  // keeps this a real (non-zero-duration) transition for Svelte's own
139
139
  // lifecycle/event handling instead of a degenerate edge case.
140
- if (reducedMotion.current) {
140
+ // MediaQueryList change events are asynchronous. A freshly opened transition
141
+ // must use the actual preference now, including a mounted preference flip.
142
+ const reduce = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
143
+ ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
144
+ : reducedMotion.current;
145
+ if (reduce) {
141
146
  duration = 0.001;
147
+ x = 0;
148
+ y = 0;
142
149
  }
143
150
  const targetOpacity = +styles.opacity;
144
151
  const transform = styles.transform === 'none' ? '' : styles.transform;
@@ -147,7 +154,7 @@ export function tokenizedFly(node, params) {
147
154
  easing,
148
155
  css: (t) => `
149
156
  transform: ${transform} translate(${(1 - t) * x}px, ${(1 - t) * y}px);
150
- opacity: ${targetOpacity * t};
157
+ opacity: ${reduce ? targetOpacity : targetOpacity * t};
151
158
  `
152
159
  };
153
160
  }
@@ -4,7 +4,6 @@
4
4
  AreaChartProperties,
5
5
  AreaChartTooltipContext
6
6
  } from './properties';
7
- import { onMount } from 'svelte';
8
7
  import ChartContainer from '../_chart/ChartContainer.svelte';
9
8
  import Axis from '../_chart/Axis.svelte';
10
9
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
@@ -18,17 +17,12 @@
18
17
  import { resolvePointLabels } from '../_chart/labels';
19
18
  import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
20
19
  import { formatSeriesAggregate } from '../_chart/aggregate';
20
+ import { defaultChartName, resolveChartName } from '../_chart/a11y';
21
21
  import type { LegendItem, Point, TooltipAnchor } from '../_chart/types';
22
22
  import { DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
23
23
 
24
24
  // ── Props ──────────────────────────────────────────────────────
25
25
 
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
26
  let {
33
27
  series,
34
28
  curve = 'monotone',
@@ -58,10 +52,20 @@
58
52
  tooltipPortal = false,
59
53
  onpointhover,
60
54
  onpointclick,
55
+ ariaLabel,
56
+ ariaDescription,
61
57
  testId,
62
58
  classes
63
59
  }: AreaChartProperties = $props();
64
60
 
61
+ // Per-instance id for the SVG <linearGradient id> references. `$props.id()` is
62
+ // unique per component instance and identical on server and client, so two
63
+ // charts on one page never resolve each other's `url(#gradient)` and hydration
64
+ // never sees a different id than the server wrote. It replaces a Math.random()
65
+ // prefix that was only filled in at mount (until then every chart's gradient id
66
+ // collapsed to the same `area-grad--0`) and was collision-prone by construction.
67
+ const instanceId = $props.id();
68
+
65
69
  // ── State ──────────────────────────────────────────────────────
66
70
 
67
71
  let containerEl: HTMLDivElement | null = $state(null);
@@ -84,10 +88,6 @@
84
88
  let mouseX = $state(0);
85
89
  let mouseY = $state(0);
86
90
 
87
- onMount(() => {
88
- uid = Math.random().toString(36).slice(2, 9);
89
- });
90
-
91
91
  // ── Layout ─────────────────────────────────────────────────────
92
92
 
93
93
  let isStacked = $derived(stacked || stackNormalize);
@@ -246,6 +246,27 @@
246
246
  }))
247
247
  );
248
248
 
249
+ // ── Chart-level accessible name ────────────────────────────────
250
+
251
+ // The drawing's own name: the consumer's `ariaLabel`, else one derived from
252
+ // what the chart plots (see `defaultChartName`).
253
+ let chartName = $derived(
254
+ resolveChartName(
255
+ ariaLabel,
256
+ defaultChartName('area', { yAxisLabel, seriesNames: series.map((s) => s.name) })
257
+ )
258
+ );
259
+
260
+ // Every finite point is a focusable role="button" mark (the `.focus-target`
261
+ // circles below), so a drawing with any is interactive and must not be exposed
262
+ // as an image. With no finite point there is nothing to operate and it is a
263
+ // plain named image.
264
+ let hasMarks = $derived(
265
+ areas.some((area) =>
266
+ area.points.some((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
267
+ )
268
+ );
269
+
249
270
  // ── Tooltip ────────────────────────────────────────────────────
250
271
 
251
272
  // Precedence: keyboard focus wins over pointer hover -- see the comment on
@@ -594,6 +615,9 @@
594
615
  {aspectRatio}
595
616
  {minHeight}
596
617
  {maxHeight}
618
+ ariaLabel={chartName}
619
+ {ariaDescription}
620
+ interactive={hasMarks}
597
621
  >
598
622
  {#if gradientFill}
599
623
  <!-- <defs> must be a direct child of <svg> (SVG root), not inside a transformed <g>.
@@ -602,7 +626,7 @@
602
626
  <defs>
603
627
  {#each areas as area, si (si)}
604
628
  <linearGradient
605
- id="area-grad-{uid}-{si}"
629
+ id="area-grad-{instanceId}-{si}"
606
630
  x1="0"
607
631
  y1="0"
608
632
  x2="0"
@@ -658,7 +682,7 @@
658
682
  class="area-fill"
659
683
  class:dimmed={pointerOrFocused !== null && pointerOrFocused.si !== si}
660
684
  d={area.areaD}
661
- fill={gradientFill ? `url(#area-grad-${uid}-${si})` : area.color}
685
+ fill={gradientFill ? `url(#area-grad-${instanceId}-${si})` : area.color}
662
686
  fill-opacity={gradientFill
663
687
  ? 1
664
688
  : pointerOrFocused?.si === si
@@ -708,8 +732,12 @@
708
732
  {#each area.points as _point, pi (pi)}
709
733
  {@const pl = pointLabelPlacements[si][pi]}
710
734
  {#if pl?.visible && Number.isFinite(pl.x) && Number.isFinite(pl.y)}
735
+ <!-- Repeats the point's own accessible name, which is what
736
+ assistive technology reads; exposing it again would announce
737
+ every value twice. -->
711
738
  <text
712
739
  class="point-value"
740
+ aria-hidden="true"
713
741
  x={pl.x}
714
742
  y={pl.y}
715
743
  text-anchor="middle"
@@ -67,6 +67,18 @@ export type OptionalAreaChartProperties = {
67
67
  empty?: Snippet;
68
68
  /** Render the tooltip into document.body so scroll/overflow ancestors never clip it. */
69
69
  tooltipPortal?: boolean;
70
+ /**
71
+ * Accessible name of the chart (`aria-label` on its `<svg>`). Say what the chart
72
+ * shows, e.g. `"Monthly revenue, January to June"`. When omitted a name is
73
+ * derived from the chart's own data (`"{yAxisLabel} area chart"`, else the series names, else `"Area chart"`), so an unlabelled chart is still
74
+ * identifiable -- but a name written for the page is almost always better.
75
+ */
76
+ ariaLabel?: string;
77
+ /**
78
+ * Optional longer description (trend, range, the takeaway) announced after the
79
+ * name. Rendered as an SVG `<desc>` referenced by `aria-describedby`.
80
+ */
81
+ ariaDescription?: string;
70
82
  testId?: string;
71
83
  classes?: string;
72
84
  };
@@ -33,13 +33,14 @@
33
33
  {#if images.length > 0 || videos.length > 0 || files.length > 0}
34
34
  <Scroller
35
35
  direction="horizontal"
36
+ ariaLabel="Pending attachments"
36
37
  showArrows={false}
37
38
  hideScrollbar={false}
38
39
  showGradient={false}
39
40
  classes={`attachment-chip-row ${classes ?? ''}`}
40
41
  {testId}
41
42
  >
42
- {#each images as image (image.id)}
43
+ {#each images as image, index (image.id)}
43
44
  <div
44
45
  class="chip"
45
46
  data-pw={typeof testId === 'string' ? `${testId}-image-${image.id}` : null}
@@ -67,7 +68,7 @@
67
68
  <Button
68
69
  variant="secondary"
69
70
  iconOnly={true}
70
- ariaLabel="Remove image"
71
+ ariaLabel={`Remove ${image.filename?.trim() || `image ${index + 1} of ${images.length}`}`}
71
72
  testId={testId && `${testId}-remove-image-${image.id}`}
72
73
  onclick={() => onremoveimage(image.id)}
73
74
  >
@@ -90,7 +91,7 @@
90
91
  {/if}
91
92
  </div>
92
93
  {/each}
93
- {#each videos as video (video.id)}
94
+ {#each videos as video, index (video.id)}
94
95
  <div
95
96
  class="chip"
96
97
  data-pw={typeof testId === 'string' ? `${testId}-video-${video.id}` : null}
@@ -125,7 +126,7 @@
125
126
  <Button
126
127
  variant="secondary"
127
128
  iconOnly={true}
128
- ariaLabel="Remove video"
129
+ ariaLabel={`Remove ${video.filename?.trim() || `video ${index + 1} of ${videos.length}`}`}
129
130
  testId={testId && `${testId}-remove-video-${video.id}`}
130
131
  onclick={() => onremovevideo(video.id)}
131
132
  >
@@ -148,7 +149,7 @@
148
149
  {/if}
149
150
  </div>
150
151
  {/each}
151
- {#each files as file (file.id)}
152
+ {#each files as file, index (file.id)}
152
153
  <div
153
154
  class="chip"
154
155
  data-pw={typeof testId === 'string' ? `${testId}-file-${file.id}` : null}
@@ -190,7 +191,7 @@
190
191
  <Button
191
192
  variant="secondary"
192
193
  iconOnly={true}
193
- ariaLabel="Remove file"
194
+ ariaLabel={`Remove ${file.filename.trim() || `file ${index + 1} of ${files.length}`}`}
194
195
  testId={testId && `${testId}-remove-file-${file.id}`}
195
196
  onclick={() => onremovefile(file.id)}
196
197
  >
@@ -30,6 +30,7 @@
30
30
  dropOverlapping
31
31
  } from '../_chart/labels';
32
32
  import { formatSeriesAggregate } from '../_chart/aggregate';
33
+ import { defaultChartName, resolveChartName } from '../_chart/a11y';
33
34
  import type { LegendItem, BarRect } from '../_chart/types';
34
35
  import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
35
36
  import type { TooltipAnchor } from '../_chart/types';
@@ -37,10 +38,12 @@
37
38
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
38
39
 
39
40
  // ── Per-instance uid prefix for <defs> ids (A1-3) ─────────────
40
- // Derived at module scope per the library uid pattern (e.g. AreaChart
41
- // feat/linechart-gradient line 17) to prevent id collision across multiple
42
- // BarChart instances on the same page.
43
- const uid = Math.random().toString(36).slice(2, 9);
41
+ // `$props.id()` is unique per component instance and identical on server and
42
+ // client, so two BarCharts on one page can never resolve each other's
43
+ // `url(#pattern)` / `url(#gradient)` references, and hydration never sees a
44
+ // different id than the one the server wrote. (A `Math.random()` prefix was
45
+ // collision-prone by construction and differed between SSR and the client.)
46
+ const uid = $props.id();
44
47
 
45
48
  /** Returns the plain CSS fallback color for any BarFill (used for legend, aria-label). */
46
49
  function fallbackColor(fill: BarFill, index: number): string {
@@ -94,6 +97,8 @@
94
97
  tooltipPortal = false,
95
98
  onbarclick,
96
99
  onbarhover,
100
+ ariaLabel,
101
+ ariaDescription,
97
102
  testId,
98
103
  classes
99
104
  }: BarChartProperties = $props();
@@ -635,6 +640,33 @@
635
640
 
636
641
  let isEmpty = $derived(resolvedSeries.every((s) => s.data.length === 0) || labels.length === 0);
637
642
 
643
+ // ── Chart-level accessible name ────────────────────────────────
644
+
645
+ /**
646
+ * The drawing's accessible name: the consumer's `ariaLabel`, else one derived
647
+ * from what the chart plots (see `defaultChartName`). It lives on the <svg>
648
+ * itself. It used to sit only on a wrapping `role="region"` that was present
649
+ * in every chart, leaving the image inside unnamed -- and giving every chart
650
+ * on a page the same landmark name.
651
+ */
652
+ let chartName = $derived(
653
+ resolveChartName(
654
+ ariaLabel,
655
+ defaultChartName('bar', {
656
+ yAxisLabel,
657
+ seriesNames: isMulti ? rawSeries.map((s) => s.name) : []
658
+ })
659
+ )
660
+ );
661
+
662
+ /**
663
+ * Bars are focusable `role="button"` marks, so a chart that draws them is
664
+ * interactive and must not be exposed as an image. With `hideBarGraphics` (a
665
+ * legend/label-only mode) or no bars there is nothing to operate, and the
666
+ * drawing is a plain named image.
667
+ */
668
+ let hasMarks = $derived(!hideBarGraphics && bars.length > 0);
669
+
638
670
  // ── Scroll geometry ────────────────────────────────────────────
639
671
 
640
672
  let minScrollWidth = $derived(
@@ -929,11 +961,20 @@
929
961
  {/if}
930
962
 
931
963
  <div class="chart-plot" bind:this={plotEl}>
964
+ <!--
965
+ A region landmark only when this wrapper really is a focusable scroll
966
+ container (axe: scrollable-region-focusable needs a focusable, named
967
+ container). It used to be a region in every chart, which put N identically
968
+ named landmarks on a page (axe: landmark-unique) and named the wrapper
969
+ while the chart image inside stayed unnamed. Its label deliberately
970
+ differs from the chart's own name so tabbing from the scroller into the
971
+ chart does not announce the same words twice.
972
+ -->
932
973
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
933
974
  <div
934
975
  class="chart-scroll-area"
935
- role="region"
936
- aria-label={yAxisLabel ? `${yAxisLabel} bar chart` : 'Bar chart'}
976
+ role={scrollable ? 'region' : null}
977
+ aria-label={scrollable ? `${chartName}, scrollable` : null}
937
978
  tabindex={scrollable ? 0 : null}
938
979
  style={scrollable
939
980
  ? `overflow-x: auto; -webkit-overflow-scrolling: touch; height: var(--barchart-scroll-area-height, auto);`
@@ -946,6 +987,9 @@
946
987
  {aspectRatio}
947
988
  {maxHeight}
948
989
  {minHeight}
990
+ ariaLabel={chartName}
991
+ {ariaDescription}
992
+ interactive={hasMarks}
949
993
  >
950
994
  <!-- A1-2 / A1-3: SVG <defs> for pattern and gradient fills -->
951
995
  {#if defsEntries.length > 0}
@@ -1112,8 +1156,12 @@
1112
1156
  {/each}
1113
1157
  {#each barLabels as bl, i (i)}
1114
1158
  {#if bl.visible}
1159
+ <!-- Repeats the bar's own accessible name ("Jan: 4.2K"), which is
1160
+ what assistive technology reads; exposing it again would
1161
+ announce every value twice. -->
1115
1162
  <text
1116
1163
  class="bar-value"
1164
+ aria-hidden="true"
1117
1165
  class:bar-value-inside={bl.p.placement === 'inside'}
1118
1166
  data-inside={bl.p.placement === 'inside' ? 'true' : 'false'}
1119
1167
  x={bl.p.x}
@@ -239,6 +239,18 @@ export type OptionalBarChartProperties = {
239
239
  hideLegendBelow?: number;
240
240
  /** Render the tooltip into document.body (position:fixed) so scroll/overflow ancestors never clip it. */
241
241
  tooltipPortal?: boolean;
242
+ /**
243
+ * Accessible name of the chart (`aria-label` on its `<svg>`). Say what the chart
244
+ * shows, e.g. `"Monthly revenue, January to June"`. When omitted a name is
245
+ * derived from the chart's own data (`"{yAxisLabel} bar chart"`, else the series names, else `"Bar chart"`), so an unlabelled chart is still
246
+ * identifiable -- but a name written for the page is almost always better.
247
+ */
248
+ ariaLabel?: string;
249
+ /**
250
+ * Optional longer description (trend, range, the takeaway) announced after the
251
+ * name. Rendered as an SVG `<desc>` referenced by `aria-describedby`.
252
+ */
253
+ ariaDescription?: string;
242
254
  testId?: string;
243
255
  classes?: string;
244
256
  };
@@ -26,6 +26,25 @@
26
26
  initialMonth = null
27
27
  }: CalendarProperties = $props();
28
28
 
29
+ type DayInfo = {
30
+ date: Date;
31
+ day: number;
32
+ isCurrentMonth: boolean;
33
+ isToday: boolean;
34
+ isDisabled: boolean;
35
+ isSelected: boolean;
36
+ isRangeStart: boolean;
37
+ isRangeEnd: boolean;
38
+ isInRange: boolean;
39
+ };
40
+
41
+ // The grid is named by the visible month heading (aria-labelledby), and the heading
42
+ // announces month changes. Per-instance, because two calendars on one page (the
43
+ // DateRangePicker mounts two) would otherwise resolve to whichever matching id
44
+ // comes first in the root.
45
+ const uid = $props.id();
46
+ const headerId = `calendar-heading-${uid}`;
47
+
29
48
  const now = new SvelteDate();
30
49
  // Intentionally read initialMonth once (untracked) — it seeds the display month at mount
31
50
  // and subsequent prop changes should not navigate the calendar (user navigates via the buttons).
@@ -48,15 +67,28 @@
48
67
  }
49
68
 
50
69
  const dayNameFormatter = $derived(new Intl.DateTimeFormat(locale, { weekday: 'short' }));
70
+ const fullDayNameFormatter = $derived(new Intl.DateTimeFormat(locale, { weekday: 'long' }));
71
+
72
+ // `toLocaleDateString()` alone gives "6/15/2024" -- ambiguous between day-first and
73
+ // month-first locales and with no weekday or month name for a screen reader to
74
+ // anchor on. The long form names the weekday, month, day and year.
75
+ const dateLabelFormatter = $derived(
76
+ new Intl.DateTimeFormat(locale, {
77
+ weekday: 'long',
78
+ year: 'numeric',
79
+ month: 'long',
80
+ day: 'numeric'
81
+ })
82
+ );
51
83
 
52
84
  const monthYearFormatter = $derived(
53
85
  new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
54
86
  );
55
87
 
56
- let dayNames: string[] = $derived(
88
+ let dayNames: { short: string; full: string }[] = $derived(
57
89
  Array.from({ length: 7 }, (_, i) => {
58
90
  const d = new SvelteDate(2024, 0, 7 + weekStartsOn + i);
59
- return dayNameFormatter.format(d);
91
+ return { short: dayNameFormatter.format(d), full: fullDayNameFormatter.format(d) };
60
92
  })
61
93
  );
62
94
 
@@ -80,18 +112,6 @@
80
112
  const normStart = rangeStart !== null ? normalizeDate(rangeStart) : null;
81
113
  const normEnd = rangeEnd !== null ? normalizeDate(rangeEnd) : null;
82
114
 
83
- type DayInfo = {
84
- date: Date;
85
- day: number;
86
- isCurrentMonth: boolean;
87
- isToday: boolean;
88
- isDisabled: boolean;
89
- isSelected: boolean;
90
- isRangeStart: boolean;
91
- isRangeEnd: boolean;
92
- isInRange: boolean;
93
- };
94
-
95
115
  function fillerDay(year: number, month: number, day: number): DayInfo {
96
116
  const date = new SvelteDate(year, month, day);
97
117
  date.setHours(0, 0, 0, 0);
@@ -148,6 +168,15 @@
148
168
  return days;
149
169
  });
150
170
 
171
+ // ARIA requires gridcells to sit in rows. Chunking the flat day list keeps the
172
+ // arithmetic above untouched; the leading and trailing filler days already pad the
173
+ // list to a multiple of seven, so every row is full.
174
+ const weeks: DayInfo[][] = $derived(
175
+ Array.from({ length: calendarDays.length / 7 }, (_, row) =>
176
+ calendarDays.slice(row * 7, row * 7 + 7)
177
+ )
178
+ );
179
+
151
180
  // A calendar grid is one tab stop, the same roving-tabindex pattern Tabs uses: the
152
181
  // day being actively navigated while inside the grid, else the selected day, else
153
182
  // today if it's in view and enabled, else the first enabled day -- so a month with
@@ -362,7 +391,7 @@
362
391
  {/if}
363
392
  </Button>
364
393
  </div>
365
- <span class="header-label">{headerLabel}</span>
394
+ <span class="header-label" id={headerId} aria-live="polite">{headerLabel}</span>
366
395
  <div class="nav-button nav-next">
367
396
  <Button
368
397
  onclick={() => navigateMonth(1)}
@@ -379,41 +408,75 @@
379
408
  </div>
380
409
  </div>
381
410
 
382
- <div class="day-names">
383
- {#each dayNames as name (name)}
384
- <div class="day-name">{name}</div>
385
- {/each}
386
- </div>
387
-
411
+ <!--
412
+ A valid ARIA grid is grid > row > (columnheader | gridcell). The weekday names live
413
+ inside the grid as the first row, and every date sits in a gridcell inside a week row.
414
+ The rows are real boxes (display: grid), never display: contents, which browsers have
415
+ dropped from the accessibility tree before. Each gridcell wraps the date button
416
+ rather than replacing it: a native <button> keeps its disabled and click behaviour,
417
+ and aria-selected / aria-disabled belong on the cell because a button supports
418
+ neither aria-selected nor a cell-level disabled state.
419
+ -->
388
420
  <div
389
421
  class="grid"
390
422
  bind:this={gridRef}
391
423
  tabindex={tabStopDay === null ? 0 : -1}
392
424
  role="grid"
425
+ aria-labelledby={headerId}
426
+ aria-multiselectable={mode === 'range' ? 'true' : null}
393
427
  onkeydown={handleKeyDown}
394
428
  >
395
- {#each calendarDays as dayInfo (dayInfo.date.getTime())}
396
- {#if dayInfo.isCurrentMonth}
397
- <button
398
- type="button"
399
- class="cell"
400
- class:today={dayInfo.isToday}
401
- class:selected={dayInfo.isSelected}
402
- class:range-start={dayInfo.isRangeStart}
403
- class:range-end={dayInfo.isRangeEnd}
404
- class:in-range={dayInfo.isInRange}
405
- class:disabled={dayInfo.isDisabled}
406
- data-day={dayInfo.day}
407
- disabled={dayInfo.isDisabled}
408
- tabindex={tabStopDay === dayInfo.day ? 0 : -1}
409
- aria-label={dayInfo.date.toLocaleDateString(locale)}
410
- onclick={() => selectDate(dayInfo.date)}
411
- >
412
- {dayInfo.day}
413
- </button>
414
- {:else}
415
- <span class="cell outside-month">{dayInfo.day}</span>
416
- {/if}
429
+ <div class="row day-names" role="row">
430
+ {#each dayNames as name, i (i)}
431
+ <div class="day-name" role="columnheader" aria-label={name.full}>{name.short}</div>
432
+ {/each}
433
+ </div>
434
+ {#each weeks as week (week[0].date.getTime())}
435
+ <div class="row week" role="row">
436
+ {#each week as dayInfo (dayInfo.date.getTime())}
437
+ {#if dayInfo.isCurrentMonth}
438
+ <div
439
+ class="cell-slot"
440
+ role="gridcell"
441
+ aria-selected={dayInfo.isSelected ||
442
+ dayInfo.isRangeStart ||
443
+ dayInfo.isRangeEnd ||
444
+ dayInfo.isInRange
445
+ ? 'true'
446
+ : 'false'}
447
+ aria-disabled={dayInfo.isDisabled ? 'true' : null}
448
+ >
449
+ <button
450
+ type="button"
451
+ class="cell"
452
+ class:today={dayInfo.isToday}
453
+ class:selected={dayInfo.isSelected}
454
+ class:range-start={dayInfo.isRangeStart}
455
+ class:range-end={dayInfo.isRangeEnd}
456
+ class:in-range={dayInfo.isInRange}
457
+ class:disabled={dayInfo.isDisabled}
458
+ data-day={dayInfo.day}
459
+ disabled={dayInfo.isDisabled}
460
+ tabindex={tabStopDay === dayInfo.day ? 0 : -1}
461
+ aria-label={dateLabelFormatter.format(dayInfo.date)}
462
+ aria-current={dayInfo.isToday ? 'date' : null}
463
+ onclick={() => selectDate(dayInfo.date)}
464
+ >
465
+ {dayInfo.day}
466
+ </button>
467
+ </div>
468
+ {:else}
469
+ <div
470
+ class="cell-slot"
471
+ role="gridcell"
472
+ aria-disabled="true"
473
+ aria-label={dateLabelFormatter.format(dayInfo.date)}
474
+ >
475
+ <span class="cell outside-month">{dayInfo.day}</span>
476
+ </div>
477
+ {/if}
478
+ {/each}
479
+ </div>
417
480
  {/each}
418
481
  </div>
419
482
  </div>
@@ -463,17 +526,27 @@
463
526
  /* Size the columns to the cell, not 1fr, and centre the whole grid. With 1fr
464
527
  columns the fixed-width cells sat centred inside wider tracks, leaving
465
528
  horizontal gaps between days — which broke the range highlight into
466
- disconnected boxes instead of one continuous pill. The day-name header must
467
- use the same track sizing so it stays aligned with the day grid below.
529
+ disconnected boxes instead of one continuous pill. The weekday-name row uses
530
+ the same track sizing as the week rows so it stays aligned with the days below.
531
+ Each row is its own grid (a real box, so the role="row" semantics survive); the
532
+ .grid wrapper just stacks them.
468
533
 
469
534
  A host that wants the grid to fill a container sets both
470
535
  --calendar-grid-columns and --calendar-cell-width: fluid columns with the
471
536
  default fixed-width cells bring those gaps back, so the cells must be told
472
537
  to fill their tracks too. */
473
- .day-names {
538
+ .grid {
539
+ display: flex;
540
+ flex-direction: column;
541
+ }
542
+
543
+ .row {
474
544
  display: grid;
475
545
  grid-template-columns: var(--calendar-grid-columns, repeat(7, var(--calendar-cell-size, 36px)));
476
546
  justify-content: center;
547
+ }
548
+
549
+ .day-names {
477
550
  text-align: center;
478
551
  }
479
552
 
@@ -484,11 +557,11 @@
484
557
  padding: var(--calendar-day-name-padding, 4px 0);
485
558
  }
486
559
 
487
- .grid {
488
- display: grid;
489
- grid-template-columns: var(--calendar-grid-columns, repeat(7, var(--calendar-cell-size, 36px)));
490
- justify-content: center;
491
- row-gap: 2px;
560
+ /* The gap between week rows. It was `row-gap: 2px` on the single day grid; with one
561
+ element per row it is a margin between consecutive weeks, which leaves the
562
+ weekday-name row flush against the first week exactly as before. */
563
+ .week + .week {
564
+ margin-top: 2px;
492
565
  }
493
566
 
494
567
  .cell {
@@ -38,6 +38,11 @@
38
38
  * once per mounted instance is what a consumer needs to act on, and it costs
39
39
  * nothing on subsequent updates.
40
40
  */
41
+ // Reading `attrs` once is the point of this block: it is a dev notice about what the consumer
42
+ // passed at mount, not state. Rendering reads `attrs` live in the spread on the root element
43
+ // below, so an `attrs` that changes afterwards is still applied; only the notice is not
44
+ // repeated. The ignore is on this one block, whose only reference is that read.
45
+ // svelte-ignore state_referenced_locally
41
46
  if (import.meta.env?.DEV === true) {
42
47
  for (const message of discardedAttrWarnings('Card', CARD_MANAGED_ATTRS, attrs)) {
43
48
  console.warn(message);
@@ -83,6 +83,7 @@
83
83
  {#if layout === 'scroll'}
84
84
  <Scroller
85
85
  {direction}
86
+ ariaLabel="Chat suggestions"
86
87
  showArrows={false}
87
88
  showGradient={false}
88
89
  hideScrollbar={true}