@sentropic/design-system-svelte 0.34.25 → 0.34.27

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.
@@ -25,6 +25,7 @@
25
25
  } from "./chartAnnotations.js";
26
26
  import { formatDataLabel, normalizeDataLabels, type DataLabelsProp } from "./chartDataLabels.js";
27
27
  import { keyForX, resolveActiveIndex } from "./chartCrosshair.js";
28
+ import { datapointAriaLabel, datapointNavAction, rovingTabIndex } from "./chartKeyboardNav.js";
28
29
 
29
30
  type AreaChartProps = {
30
31
  data: (number | AreaChartDatum)[];
@@ -61,6 +62,21 @@
61
62
  * keep the shared hover channel in sync.
62
63
  */
63
64
  onHoverKeyChange?: (key: string | null) => void;
65
+ /**
66
+ * FR-5 — keyboard navigation of the data points (roving tabindex). When `true`
67
+ * (or implied by wiring `onSelectKey`), a thin focusable overlay is rendered
68
+ * over the points: the chart owns ONE tab stop, ←/↑/→/↓ move the focus between
69
+ * points (data order), Home/End jump to the first/last, Enter/Space select the
70
+ * focused point (`onSelectKey`), Escape leaves the navigation. Each focused
71
+ * point announces its `x` + value. Absent ⇒ no overlay, rendering unchanged.
72
+ */
73
+ keyboardNav?: boolean;
74
+ /**
75
+ * Emitted when the user selects the focused point via Enter/Space (its key,
76
+ * `String(x)`), or `null` when the navigation is left via Escape. Wiring it
77
+ * also turns the keyboard navigation on.
78
+ */
79
+ onSelectKey?: (key: string | null) => void;
64
80
  class?: string;
65
81
  };
66
82
 
@@ -75,6 +91,8 @@
75
91
  dataLabels,
76
92
  hoverKey,
77
93
  onHoverKeyChange,
94
+ keyboardNav,
95
+ onSelectKey,
78
96
  class: className
79
97
  }: AreaChartProps = $props();
80
98
 
@@ -133,6 +151,9 @@
133
151
  ]);
134
152
 
135
153
  let hoveredIndex: number | null = $state(null);
154
+ // FR-5 — roving keyboard focus over the data points (separate from hover).
155
+ let focusedIndex: number = $state(-1);
156
+ let datapointRefs: Array<SVGRectElement | null> = [];
136
157
 
137
158
  const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
138
159
  const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
@@ -316,6 +337,35 @@
316
337
  // provided (resolved against `hoverKeys`), else the internal pointer index.
317
338
  const activeIndex = $derived(resolveActiveIndex(hoverKey, hoveredIndex, hoverKeys));
318
339
 
340
+ // --- Keyboard navigation (FR-5) ------------------------------------------
341
+ // Active when wired explicitly (`keyboardNav`) or implicitly (`onSelectKey`).
342
+ // Renders a focusable overlay (one transparent hit-rect per point) carrying a
343
+ // single roving tab stop. Arrow/Home/End move focus, Enter/Space select,
344
+ // Escape leaves. Focus also feeds the shared hover channel (FR-3 synergy).
345
+ const navEnabled = $derived((keyboardNav === true || onSelectKey !== undefined) && points.length > 0);
346
+ // Comfortable square hit area centred on each dot.
347
+ const NAV_HIT = 18;
348
+ function focusDatum(index: number) {
349
+ focusedIndex = index;
350
+ datapointRefs[index]?.focus();
351
+ emitHoverKey(index);
352
+ }
353
+ function handleDatapointKeyDown(event: KeyboardEvent, index: number) {
354
+ const action = datapointNavAction(event.key, index, points.length);
355
+ if (!action) return;
356
+ event.preventDefault();
357
+ if (action.kind === "move") {
358
+ focusDatum(action.index);
359
+ } else if (action.kind === "select") {
360
+ onSelectKey?.(hoverKeys[index] ?? null);
361
+ } else {
362
+ focusedIndex = -1;
363
+ emitHoverKey(null);
364
+ onSelectKey?.(null);
365
+ (event.currentTarget as SVGElement).blur();
366
+ }
367
+ }
368
+
319
369
  // Generates a unique gradient id to avoid conflicts when rendering multiple charts on the same page
320
370
  const gradientId = $derived.by(() => {
321
371
  return `st-areachart-gradient-${Math.random().toString(36).substring(2, 9)}`;
@@ -484,6 +534,42 @@
484
534
  </g>
485
535
  {/if}
486
536
  </svg>
537
+
538
+ <!-- Keyboard navigation overlay (FR-5) — a focusable, transparent hit layer
539
+ over the points. NOT aria-hidden: it is the accessible roving cursor.
540
+ Each rect announces its category + value; the focus ring is tokenised
541
+ via CSS. Absent unless keyboard nav is enabled. -->
542
+ {#if navEnabled}
543
+ <svg
544
+ class="st-areaChart__navLayer"
545
+ viewBox="0 0 {width} {height}"
546
+ preserveAspectRatio="xMidYMid meet"
547
+ width="100%"
548
+ height="100%"
549
+ role="group"
550
+ aria-label={`${label} — points de données`}
551
+ >
552
+ {#each points as p, i (p.index)}
553
+ <rect
554
+ bind:this={datapointRefs[i]}
555
+ class="st-areaChart__navDatum"
556
+ x={p.x - NAV_HIT / 2}
557
+ y={p.y - NAV_HIT / 2}
558
+ width={NAV_HIT}
559
+ height={NAV_HIT}
560
+ rx="3"
561
+ role="img"
562
+ tabindex={rovingTabIndex(i, focusedIndex, points.length)}
563
+ aria-label={datapointAriaLabel(p.datum.x, p.datum.y)}
564
+ onkeydown={(event) => handleDatapointKeyDown(event, i)}
565
+ onfocus={() => {
566
+ focusedIndex = i;
567
+ emitHoverKey(i);
568
+ }}
569
+ />
570
+ {/each}
571
+ </svg>
572
+ {/if}
487
573
  </div>
488
574
 
489
575
  <ChartDataList {label} items={dataValueItems} />
@@ -526,6 +612,7 @@
526
612
 
527
613
  .st-areaChart__visual {
528
614
  display: block;
615
+ position: relative;
529
616
  }
530
617
 
531
618
  .st-areaChart__grid {
@@ -40,6 +40,21 @@ type AreaChartProps = {
40
40
  * keep the shared hover channel in sync.
41
41
  */
42
42
  onHoverKeyChange?: (key: string | null) => void;
43
+ /**
44
+ * FR-5 — keyboard navigation of the data points (roving tabindex). When `true`
45
+ * (or implied by wiring `onSelectKey`), a thin focusable overlay is rendered
46
+ * over the points: the chart owns ONE tab stop, ←/↑/→/↓ move the focus between
47
+ * points (data order), Home/End jump to the first/last, Enter/Space select the
48
+ * focused point (`onSelectKey`), Escape leaves the navigation. Each focused
49
+ * point announces its `x` + value. Absent ⇒ no overlay, rendering unchanged.
50
+ */
51
+ keyboardNav?: boolean;
52
+ /**
53
+ * Emitted when the user selects the focused point via Enter/Space (its key,
54
+ * `String(x)`), or `null` when the navigation is left via Escape. Wiring it
55
+ * also turns the keyboard navigation on.
56
+ */
57
+ onSelectKey?: (key: string | null) => void;
43
58
  class?: string;
44
59
  };
45
60
  declare const AreaChart: import("svelte").Component<AreaChartProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"AreaChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AreaChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG;IAC3B,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;CACX,CAAC;AAIJ,OAAO,EAIH,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AACjC,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAI/F,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,CAAC,MAAM,GAAG,cAAc,CAAC,EAAE,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAkYJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"AreaChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AreaChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG;IAC3B,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;CACX,CAAC;AAIJ,OAAO,EAIH,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AACjC,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAK/F,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,CAAC,MAAM,GAAG,cAAc,CAAC,EAAE,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAibJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -58,6 +58,7 @@
58
58
  } from "./chartAnnotations.js";
59
59
  import { formatDataLabel, normalizeDataLabels, type DataLabelsProp } from "./chartDataLabels.js";
60
60
  import { resolveActiveIndex } from "./chartCrosshair.js";
61
+ import { datapointAriaLabel, datapointNavAction, rovingTabIndex } from "./chartKeyboardNav.js";
61
62
 
62
63
  type BarChartProps = {
63
64
  data: BarChartDatum[];
@@ -139,6 +140,21 @@
139
140
  * keep the shared hover channel in sync.
140
141
  */
141
142
  onHoverKeyChange?: (key: string | null) => void;
143
+ /**
144
+ * FR-5 — keyboard navigation of the data points (roving tabindex). When `true`
145
+ * (or implied by wiring `onSelectKey`), a thin focusable overlay is rendered
146
+ * over the bars: the chart owns ONE tab stop, ←/↑/→/↓ move the focus between
147
+ * bars (data order), Home/End jump to the first/last, Enter/Space select the
148
+ * focused bar (`onSelectKey`), Escape leaves the navigation. Each focused bar
149
+ * announces its `label` + value. Absent ⇒ no overlay, rendering unchanged.
150
+ */
151
+ keyboardNav?: boolean;
152
+ /**
153
+ * Emitted when the user selects the focused bar via Enter/Space (its `label`),
154
+ * or `null` when the navigation is left via Escape. Wiring it also turns the
155
+ * keyboard navigation on. Independent of `onSelect`/`selectedKeys`.
156
+ */
157
+ onSelectKey?: (key: string | null) => void;
142
158
  class?: string;
143
159
  };
144
160
 
@@ -161,6 +177,8 @@
161
177
  showLegend,
162
178
  hoverKey,
163
179
  onHoverKeyChange,
180
+ keyboardNav,
181
+ onSelectKey,
164
182
  class: className
165
183
  }: BarChartProps = $props();
166
184
 
@@ -643,6 +661,36 @@
643
661
  // provided (resolved against `hoverKeys`), else the internal pointer index.
644
662
  const activeIndex = $derived(resolveActiveIndex(hoverKey, hoveredIndex, hoverKeys));
645
663
 
664
+ // --- Keyboard navigation (FR-5) ------------------------------------------
665
+ // Active when wired explicitly (`keyboardNav`) or implicitly (`onSelectKey`).
666
+ // Renders a focusable overlay (one transparent hit-rect per bar) carrying a
667
+ // single roving tab stop. Arrow/Home/End move focus, Enter/Space select,
668
+ // Escape leaves. Focus also feeds the shared hover channel (FR-3 synergy).
669
+ let focusedIndex: number = $state(-1);
670
+ let datapointRefs: Array<SVGRectElement | null> = [];
671
+ const navEnabled = $derived((keyboardNav === true || onSelectKey !== undefined) && bars.length > 0);
672
+ function focusDatum(index: number) {
673
+ focusedIndex = index;
674
+ datapointRefs[index]?.focus();
675
+ emitHoverKey(index);
676
+ }
677
+ function handleDatapointKeyDown(event: KeyboardEvent, index: number) {
678
+ const action = datapointNavAction(event.key, index, bars.length);
679
+ if (!action) return;
680
+ event.preventDefault();
681
+ if (action.kind === "move") {
682
+ focusDatum(action.index);
683
+ } else if (action.kind === "select") {
684
+ onSelectKey?.(bars[index].datum.label);
685
+ } else {
686
+ // Escape — leave the navigation: clear focus + hover channel.
687
+ focusedIndex = -1;
688
+ emitHoverKey(null);
689
+ onSelectKey?.(null);
690
+ (event.currentTarget as SVGElement).blur();
691
+ }
692
+ }
693
+
646
694
  const classes = () => ["st-barChart", className].filter(Boolean).join(" ");
647
695
  </script>
648
696
 
@@ -882,6 +930,41 @@
882
930
  </g>
883
931
  {/if}
884
932
  </svg>
933
+
934
+ <!-- Keyboard navigation overlay (FR-5) — a focusable, transparent hit layer
935
+ over the bars. NOT aria-hidden: it is the accessible roving cursor. Each
936
+ rect announces its category + value; the focus ring is tokenised via CSS.
937
+ Absent unless keyboard nav is enabled. -->
938
+ {#if navEnabled}
939
+ <svg
940
+ class="st-barChart__navLayer"
941
+ viewBox="0 0 {width} {height}"
942
+ preserveAspectRatio="xMidYMid meet"
943
+ width="100%"
944
+ height="100%"
945
+ role="group"
946
+ aria-label={`${label} — points de données`}
947
+ >
948
+ {#each bars as bar, i (bar.datum.label)}
949
+ <rect
950
+ bind:this={datapointRefs[i]}
951
+ class="st-barChart__navDatum"
952
+ x={bar.x}
953
+ y={bar.y}
954
+ width={bar.width}
955
+ height={bar.height}
956
+ role="img"
957
+ tabindex={rovingTabIndex(i, focusedIndex, bars.length)}
958
+ aria-label={datapointAriaLabel(bar.datum.label, bar.datum.value)}
959
+ onkeydown={(event) => handleDatapointKeyDown(event, i)}
960
+ onfocus={() => {
961
+ focusedIndex = i;
962
+ emitHoverKey(i);
963
+ }}
964
+ />
965
+ {/each}
966
+ </svg>
967
+ {/if}
885
968
  </div>
886
969
 
887
970
  {#if interactive}
@@ -113,6 +113,21 @@ type BarChartProps = {
113
113
  * keep the shared hover channel in sync.
114
114
  */
115
115
  onHoverKeyChange?: (key: string | null) => void;
116
+ /**
117
+ * FR-5 — keyboard navigation of the data points (roving tabindex). When `true`
118
+ * (or implied by wiring `onSelectKey`), a thin focusable overlay is rendered
119
+ * over the bars: the chart owns ONE tab stop, ←/↑/→/↓ move the focus between
120
+ * bars (data order), Home/End jump to the first/last, Enter/Space select the
121
+ * focused bar (`onSelectKey`), Escape leaves the navigation. Each focused bar
122
+ * announces its `label` + value. Absent ⇒ no overlay, rendering unchanged.
123
+ */
124
+ keyboardNav?: boolean;
125
+ /**
126
+ * Emitted when the user selects the focused bar via Enter/Space (its `label`),
127
+ * or `null` when the navigation is left via Escape. Wiring it also turns the
128
+ * keyboard navigation on. Independent of `onSelect`/`selectedKeys`.
129
+ */
130
+ onSelectKey?: (key: string | null) => void;
116
131
  class?: string;
117
132
  };
118
133
  declare const BarChart: import("svelte").Component<BarChartProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"BarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/BarChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,YAAY,GACpB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;AAEpF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,IAAI,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,CAAC;AAI5C,OAAO,EAIH,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AACjC,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAI/F,KAAK,aAAa,GAAG;IACnB,IAAI,EAAE,aAAa,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,+DAA+D;IAC/D,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAC;IACtC,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IACpB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA8qBJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"BarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/BarChart.svelte.ts"],"names":[],"mappings":"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"}
@@ -33,6 +33,15 @@
33
33
  leftAxisLabel?: string;
34
34
  rightAxisLabel?: string;
35
35
  legend?: boolean;
36
+ /**
37
+ * Interactive legend (FR-4). Ids/labels of bar/line series hidden from the
38
+ * render (controlled by the parent; default = all visible). Hidden series
39
+ * are omitted and their legend item is shown "off" (`aria-pressed`).
40
+ * Undefined → legacy non-interactive legend, unless `onToggleSeries` is set.
41
+ */
42
+ hiddenSeries?: string[];
43
+ /** Emitted on click / Enter / Space on a legend item. */
44
+ onToggleSeries?: (seriesId: string) => void;
36
45
  width?: number;
37
46
  height?: number;
38
47
  label: string;
@@ -46,12 +55,18 @@
46
55
  leftAxisLabel,
47
56
  rightAxisLabel,
48
57
  legend = true,
58
+ hiddenSeries,
59
+ onToggleSeries,
49
60
  width = 480,
50
61
  height = 240,
51
62
  label,
52
63
  class: className
53
64
  }: ComboChartProps = $props();
54
65
 
66
+ // Interactive legend is active as soon as the parent wires either prop.
67
+ const legendInteractive = $derived(onToggleSeries !== undefined || hiddenSeries !== undefined);
68
+ const hiddenSet = $derived(new Set(hiddenSeries ?? []));
69
+
55
70
  const MARGIN = { top: 12, right: 52, bottom: 32, left: 52 };
56
71
 
57
72
  function niceTicks(min: number, max: number, target = 5): number[] {
@@ -92,8 +107,9 @@
92
107
  const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
93
108
 
94
109
  // Left axis (bars): include zero in the domain so bars rest on a baseline.
110
+ // Hidden series are excluded so the axis rescales to what is visible.
95
111
  const leftScale = $derived.by(() => {
96
- const values = bars.flatMap((s) => s.data);
112
+ const values = bars.filter((s) => !hiddenSet.has(s.label)).flatMap((s) => s.data);
97
113
  const minRaw = Math.min(0, ...(values.length ? values : [0]));
98
114
  const maxRaw = Math.max(0, ...(values.length ? values : [0]));
99
115
  const ticks = niceTicks(minRaw, maxRaw, 5);
@@ -102,7 +118,7 @@
102
118
 
103
119
  // Right axis (lines): padded domain like LineChart.
104
120
  const rightScale = $derived.by(() => {
105
- const values = lines.flatMap((s) => s.data);
121
+ const values = lines.filter((s) => !hiddenSet.has(s.label)).flatMap((s) => s.data);
106
122
  if (values.length === 0) {
107
123
  const ticks = niceTicks(0, 1, 5);
108
124
  return { ticks, domainMin: ticks[0], domainMax: ticks[ticks.length - 1] };
@@ -136,6 +152,7 @@
136
152
  const groupX = MARGIN.left + band * ci + (band - groupWidth) / 2;
137
153
  const segments = bars
138
154
  .map((series, si) => {
155
+ if (hiddenSet.has(series.label)) return null;
139
156
  const raw = series.data[ci];
140
157
  if (!isPresent(raw)) return null;
141
158
  const value = raw;
@@ -203,6 +220,7 @@
203
220
  path,
204
221
  points,
205
222
  seriesLabel: series.label,
223
+ hidden: hiddenSet.has(series.label),
206
224
  tone: series.tone ?? `category${((bars.length + li) % 8) + 1}`
207
225
  };
208
226
  });
@@ -240,12 +258,12 @@
240
258
  ]);
241
259
 
242
260
  const dataValueItems = $derived([
243
- ...bars.flatMap((s) =>
244
- categories.map((c, ci) => `${s.label}, ${c}: ${s.data[ci] ?? 0}`)
245
- ),
246
- ...lines.flatMap((s) =>
247
- categories.map((c, ci) => `${s.label}, ${c}: ${s.data[ci] ?? 0}`)
248
- )
261
+ ...bars
262
+ .filter((s) => !hiddenSet.has(s.label))
263
+ .flatMap((s) => categories.map((c, ci) => `${s.label}, ${c}: ${s.data[ci] ?? 0}`)),
264
+ ...lines
265
+ .filter((s) => !hiddenSet.has(s.label))
266
+ .flatMap((s) => categories.map((c, ci) => `${s.label}, ${c}: ${s.data[ci] ?? 0}`))
249
267
  ]);
250
268
 
251
269
  type Hover =
@@ -404,25 +422,27 @@
404
422
 
405
423
  <!-- lines -->
406
424
  {#each lineSeries as series, li (li)}
407
- <path
408
- class="st-comboChart__line st-comboChart__line--{series.tone}"
409
- d={series.path}
410
- fill="none"
411
- stroke-width="2"
412
- stroke-linecap="round"
413
- stroke-linejoin="round"
414
- />
415
- {#each series.points as p, pi (pi)}
416
- <circle
417
- class="st-comboChart__dot st-comboChart__dot--{series.tone}"
418
- cx={p.x}
419
- cy={p.y}
420
- r="4"
421
- data-chart-kind="line"
422
- data-chart-a={li}
423
- data-chart-b={pi}
425
+ {#if !series.hidden}
426
+ <path
427
+ class="st-comboChart__line st-comboChart__line--{series.tone}"
428
+ d={series.path}
429
+ fill="none"
430
+ stroke-width="2"
431
+ stroke-linecap="round"
432
+ stroke-linejoin="round"
424
433
  />
425
- {/each}
434
+ {#each series.points as p, pi (pi)}
435
+ <circle
436
+ class="st-comboChart__dot st-comboChart__dot--{series.tone}"
437
+ cx={p.x}
438
+ cy={p.y}
439
+ r="4"
440
+ data-chart-kind="line"
441
+ data-chart-a={li}
442
+ data-chart-b={pi}
443
+ />
444
+ {/each}
445
+ {/if}
426
446
  {/each}
427
447
  </svg>
428
448
  </div>
@@ -430,13 +450,28 @@
430
450
  <ChartDataList {label} items={dataValueItems} />
431
451
 
432
452
  {#if legend && legendItems.length > 0}
433
- <ul class="st-comboChart__legend" aria-hidden="true">
453
+ <ul class="st-comboChart__legend" aria-hidden={legendInteractive ? undefined : "true"}>
434
454
  {#each legendItems as item (item.key)}
435
- <li class="st-comboChart__legendItem">
436
- <span
437
- class="st-comboChart__legendSwatch st-comboChart__legendSwatch--{item.kind} st-comboChart__legendSwatch--{item.tone}"
438
- ></span>
439
- {item.label}
455
+ {@const off = hiddenSet.has(item.label)}
456
+ <li class="st-comboChart__legendItem" class:st-comboChart__legendItem--off={legendInteractive && off}>
457
+ {#if legendInteractive}
458
+ <button
459
+ type="button"
460
+ class="st-comboChart__legendButton"
461
+ aria-pressed={off}
462
+ onclick={() => onToggleSeries?.(item.label)}
463
+ >
464
+ <span
465
+ class="st-comboChart__legendSwatch st-comboChart__legendSwatch--{item.kind} st-comboChart__legendSwatch--{item.tone}"
466
+ ></span>
467
+ {item.label}
468
+ </button>
469
+ {:else}
470
+ <span
471
+ class="st-comboChart__legendSwatch st-comboChart__legendSwatch--{item.kind} st-comboChart__legendSwatch--{item.tone}"
472
+ ></span>
473
+ {item.label}
474
+ {/if}
440
475
  </li>
441
476
  {/each}
442
477
  </ul>
@@ -567,6 +602,28 @@
567
602
  gap: var(--st-spacing-1, 0.25rem);
568
603
  }
569
604
 
605
+ .st-comboChart__legendItem--off {
606
+ opacity: 0.45;
607
+ }
608
+
609
+ .st-comboChart__legendButton {
610
+ align-items: center;
611
+ background: none;
612
+ border: 0;
613
+ border-radius: var(--st-radius-sm, 0.25rem);
614
+ color: inherit;
615
+ cursor: pointer;
616
+ display: inline-flex;
617
+ font: inherit;
618
+ gap: var(--st-spacing-1, 0.25rem);
619
+ padding: 0.125rem 0.25rem;
620
+ }
621
+
622
+ .st-comboChart__legendButton:focus-visible {
623
+ outline: 2px solid var(--st-semantic-border-interactive);
624
+ outline-offset: 2px;
625
+ }
626
+
570
627
  .st-comboChart__legendSwatch {
571
628
  display: inline-block;
572
629
  flex: none;
@@ -17,6 +17,15 @@ type ComboChartProps = {
17
17
  leftAxisLabel?: string;
18
18
  rightAxisLabel?: string;
19
19
  legend?: boolean;
20
+ /**
21
+ * Interactive legend (FR-4). Ids/labels of bar/line series hidden from the
22
+ * render (controlled by the parent; default = all visible). Hidden series
23
+ * are omitted and their legend item is shown "off" (`aria-pressed`).
24
+ * Undefined → legacy non-interactive legend, unless `onToggleSeries` is set.
25
+ */
26
+ hiddenSeries?: string[];
27
+ /** Emitted on click / Enter / Space on a legend item. */
28
+ onToggleSeries?: (seriesId: string) => void;
20
29
  width?: number;
21
30
  height?: number;
22
31
  label: string;
@@ -1 +1 @@
1
- {"version":3,"file":"ComboChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/ComboChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAMF,KAAK,eAAe,GAAG;IACrB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,mBAAmB,EAAE,CAAC;IAC7B,KAAK,CAAC,EAAE,oBAAoB,EAAE,CAAC;IAC/B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA0VJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"ComboChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/ComboChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAMF,KAAK,eAAe,GAAG;IACrB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,mBAAmB,EAAE,CAAC;IAC7B,KAAK,CAAC,EAAE,oBAAoB,EAAE,CAAC;IAC/B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,yDAAyD;IACzD,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA6WJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -61,6 +61,7 @@
61
61
  } from "./chartAnnotations.js";
62
62
  import { formatDataLabel, normalizeDataLabels, type DataLabelsProp } from "./chartDataLabels.js";
63
63
  import { keyForX, resolveActiveIndex } from "./chartCrosshair.js";
64
+ import { datapointAriaLabel, datapointNavAction, rovingTabIndex } from "./chartKeyboardNav.js";
64
65
 
65
66
  type LineChartProps = {
66
67
  data: LineChartDatum[];
@@ -126,6 +127,21 @@
126
127
  * keep the shared hover channel in sync.
127
128
  */
128
129
  onHoverKeyChange?: (key: string | null) => void;
130
+ /**
131
+ * FR-5 — keyboard navigation of the data points (roving tabindex). When `true`
132
+ * (or implied by wiring `onSelectKey`), a thin focusable overlay is rendered
133
+ * over the points: the chart owns ONE tab stop, ←/↑/→/↓ move the focus between
134
+ * points (data order), Home/End jump to the first/last, Enter/Space select the
135
+ * focused point (`onSelectKey`), Escape leaves the navigation. Each focused
136
+ * point announces its `x` + value. Absent ⇒ no overlay, rendering unchanged.
137
+ */
138
+ keyboardNav?: boolean;
139
+ /**
140
+ * Emitted when the user selects the focused point via Enter/Space (its key,
141
+ * `String(x)`), or `null` when the navigation is left via Escape. Wiring it
142
+ * also turns the keyboard navigation on.
143
+ */
144
+ onSelectKey?: (key: string | null) => void;
129
145
  class?: string;
130
146
  };
131
147
 
@@ -149,6 +165,8 @@
149
165
  showLegend,
150
166
  hoverKey,
151
167
  onHoverKeyChange,
168
+ keyboardNav,
169
+ onSelectKey,
152
170
  class: className
153
171
  }: LineChartProps = $props();
154
172
 
@@ -323,6 +341,9 @@
323
341
  }
324
342
 
325
343
  let hoveredIndex: number | null = $state(null);
344
+ // FR-5 — roving keyboard focus over the data points (separate from hover).
345
+ let focusedIndex: number = $state(-1);
346
+ let datapointRefs: Array<SVGRectElement | null> = [];
326
347
 
327
348
  const plotWidth = $derived(Math.max(width - MARGIN.left - MARGIN.right, 1));
328
349
  const plotHeight = $derived(Math.max(height - MARGIN.top - MARGIN.bottom, 1));
@@ -661,6 +682,35 @@
661
682
  // provided (resolved against `hoverKeys`), else the internal pointer index.
662
683
  const activeIndex = $derived(resolveActiveIndex(hoverKey, hoveredIndex, hoverKeys));
663
684
 
685
+ // --- Keyboard navigation (FR-5) ------------------------------------------
686
+ // Active when wired explicitly (`keyboardNav`) or implicitly (`onSelectKey`).
687
+ // Renders a focusable overlay (one transparent hit-rect per point) carrying a
688
+ // single roving tab stop. Arrow/Home/End move focus, Enter/Space select,
689
+ // Escape leaves. Focus also feeds the shared hover channel (FR-3 synergy).
690
+ const navEnabled = $derived((keyboardNav === true || onSelectKey !== undefined) && points.length > 0);
691
+ // Comfortable square hit area centred on each dot.
692
+ const NAV_HIT = 18;
693
+ function focusDatum(index: number) {
694
+ focusedIndex = index;
695
+ datapointRefs[index]?.focus();
696
+ emitHoverKey(index);
697
+ }
698
+ function handleDatapointKeyDown(event: KeyboardEvent, index: number) {
699
+ const action = datapointNavAction(event.key, index, points.length);
700
+ if (!action) return;
701
+ event.preventDefault();
702
+ if (action.kind === "move") {
703
+ focusDatum(action.index);
704
+ } else if (action.kind === "select") {
705
+ onSelectKey?.(hoverKeys[index] ?? null);
706
+ } else {
707
+ focusedIndex = -1;
708
+ emitHoverKey(null);
709
+ onSelectKey?.(null);
710
+ (event.currentTarget as SVGElement).blur();
711
+ }
712
+ }
713
+
664
714
  const classes = () =>
665
715
  ["st-lineChart", `st-lineChart--${tone}`, className].filter(Boolean).join(" ");
666
716
  </script>
@@ -862,6 +912,42 @@
862
912
  </g>
863
913
  {/if}
864
914
  </svg>
915
+
916
+ <!-- Keyboard navigation overlay (FR-5) — a focusable, transparent hit layer
917
+ over the points. NOT aria-hidden: it is the accessible roving cursor.
918
+ Each rect announces its category + value; the focus ring is tokenised
919
+ via CSS. Absent unless keyboard nav is enabled. -->
920
+ {#if navEnabled}
921
+ <svg
922
+ class="st-lineChart__navLayer"
923
+ viewBox="0 0 {width} {height}"
924
+ preserveAspectRatio="xMidYMid meet"
925
+ width="100%"
926
+ height="100%"
927
+ role="group"
928
+ aria-label={`${label} — points de données`}
929
+ >
930
+ {#each points as p, i (p.index)}
931
+ <rect
932
+ bind:this={datapointRefs[i]}
933
+ class="st-lineChart__navDatum"
934
+ x={p.x - NAV_HIT / 2}
935
+ y={p.y - NAV_HIT / 2}
936
+ width={NAV_HIT}
937
+ height={NAV_HIT}
938
+ rx="3"
939
+ role="img"
940
+ tabindex={rovingTabIndex(i, focusedIndex, points.length)}
941
+ aria-label={datapointAriaLabel(p.datum.x, p.datum.y)}
942
+ onkeydown={(event) => handleDatapointKeyDown(event, i)}
943
+ onfocus={() => {
944
+ focusedIndex = i;
945
+ emitHoverKey(i);
946
+ }}
947
+ />
948
+ {/each}
949
+ </svg>
950
+ {/if}
865
951
  </div>
866
952
 
867
953
  <ChartDataList {label} items={dataValueItems} />
@@ -904,6 +990,7 @@
904
990
 
905
991
  .st-lineChart__visual {
906
992
  display: block;
993
+ position: relative;
907
994
  }
908
995
 
909
996
  .st-lineChart__grid {
@@ -100,6 +100,21 @@ type LineChartProps = {
100
100
  * keep the shared hover channel in sync.
101
101
  */
102
102
  onHoverKeyChange?: (key: string | null) => void;
103
+ /**
104
+ * FR-5 — keyboard navigation of the data points (roving tabindex). When `true`
105
+ * (or implied by wiring `onSelectKey`), a thin focusable overlay is rendered
106
+ * over the points: the chart owns ONE tab stop, ←/↑/→/↓ move the focus between
107
+ * points (data order), Home/End jump to the first/last, Enter/Space select the
108
+ * focused point (`onSelectKey`), Escape leaves the navigation. Each focused
109
+ * point announces its `x` + value. Absent ⇒ no overlay, rendering unchanged.
110
+ */
111
+ keyboardNav?: boolean;
112
+ /**
113
+ * Emitted when the user selects the focused point via Enter/Space (its key,
114
+ * `String(x)`), or `null` when the navigation is left via Escape. Wiring it
115
+ * also turns the keyboard navigation on.
116
+ */
117
+ onSelectKey?: (key: string | null) => void;
103
118
  class?: string;
104
119
  };
105
120
  declare const LineChart: import("svelte").Component<LineChartProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"LineChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/LineChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG;IAC3B,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;IACV;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;AAEpF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,IAAI,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,CAAC;AAI5C,OAAO,EAIH,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AACjC,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAI/F,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,cAAc,EAAE,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,cAAc,CAAC,EAAE,kBAAkB,EAAE,CAAC;IACtC,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IACpB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,qDAAqD;IACrD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B;;;;OAIG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA+qBJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"LineChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/LineChart.svelte.ts"],"names":[],"mappings":"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"}
@@ -33,6 +33,16 @@
33
33
  * values already live in the accessible ChartDataList.
34
34
  */
35
35
  dataLabels?: DataLabelsProp;
36
+ /**
37
+ * Interactive legend (FR-4). Ids/labels of series hidden from the render
38
+ * (controlled by the parent; default = all visible). Each segment whose
39
+ * `label` ∈ `hiddenSeries` is omitted and its legend item is shown "off"
40
+ * (`aria-pressed`). Undefined → legacy non-interactive legend, unless
41
+ * `onToggleSeries` is provided.
42
+ */
43
+ hiddenSeries?: string[];
44
+ /** Emitted on click / Enter / Space on a legend item. */
45
+ onToggleSeries?: (seriesId: string) => void;
36
46
  class?: string;
37
47
  };
38
48
 
@@ -43,9 +53,15 @@
43
53
  label,
44
54
  showLegend = true,
45
55
  dataLabels,
56
+ hiddenSeries,
57
+ onToggleSeries,
46
58
  class: className
47
59
  }: StackedBarChartProps = $props();
48
60
 
61
+ // Interactive legend is active as soon as the parent wires either prop.
62
+ const legendInteractive = $derived(onToggleSeries !== undefined || hiddenSeries !== undefined);
63
+ const hiddenSet = $derived(new Set(hiddenSeries ?? []));
64
+
49
65
  const MARGIN = { top: 14, right: 16, bottom: 34, left: 44 };
50
66
  const TONES = ["category1","category2","category3","category4","category5","category6","category7","category8"] as const;
51
67
 
@@ -81,7 +97,9 @@
81
97
  let hovered: { bar: number; seg: number } | null = $state(null);
82
98
 
83
99
  const scales = $derived.by(() => {
84
- const totals = data.map((b) => b.segments.reduce((s, x) => s + Math.max(x.value, 0), 0));
100
+ const totals = data.map((b) =>
101
+ b.segments.reduce((s, x) => (hiddenSet.has(x.label) ? s : s + Math.max(x.value, 0)), 0)
102
+ );
85
103
  const ticks = niceTicks(0, Math.max(0, ...totals));
86
104
  return {
87
105
  ticks, domainMax: ticks[ticks.length - 1],
@@ -98,23 +116,32 @@
98
116
  return data.map((bar, bi) => {
99
117
  const x = MARGIN.left + band * bi + (band - barWidth) / 2;
100
118
  let acc = 0;
101
- const segs = bar.segments.map((seg, si) => {
102
- const v = Math.max(seg.value, 0);
103
- const yTop = MARGIN.top + scaleLinear(acc + v, 0, domainMax, plotH, 0);
104
- const yBottom = MARGIN.top + scaleLinear(acc, 0, domainMax, plotH, 0);
105
- acc += v;
106
- return {
107
- x, y: yTop, width: barWidth, height: Math.max(yBottom - yTop, 0),
108
- seg, tone: seg.tone ?? TONES[si % TONES.length],
109
- cx: x + barWidth / 2, cy: yTop + (yBottom - yTop) / 2
110
- };
111
- });
119
+ // Tone is bound to the original segment index so it stays stable when a
120
+ // series is toggled off; hidden segments are dropped before stacking.
121
+ const segs = bar.segments
122
+ .map((seg, si) => ({ seg, tone: seg.tone ?? TONES[si % TONES.length] }))
123
+ .filter(({ seg }) => !hiddenSet.has(seg.label))
124
+ .map(({ seg, tone }) => {
125
+ const v = Math.max(seg.value, 0);
126
+ const yTop = MARGIN.top + scaleLinear(acc + v, 0, domainMax, plotH, 0);
127
+ const yBottom = MARGIN.top + scaleLinear(acc, 0, domainMax, plotH, 0);
128
+ acc += v;
129
+ return {
130
+ x, y: yTop, width: barWidth, height: Math.max(yBottom - yTop, 0),
131
+ seg, tone,
132
+ cx: x + barWidth / 2, cy: yTop + (yBottom - yTop) / 2
133
+ };
134
+ });
112
135
  return { x, band, label: bar.label, segs, cxLabel: MARGIN.left + band * (bi + 0.5) };
113
136
  });
114
137
  });
115
138
 
116
139
  const dataValueItems = $derived(
117
- data.flatMap((bar) => bar.segments.map((seg) => `${bar.label}, ${seg.label}: ${seg.value}`))
140
+ data.flatMap((bar) =>
141
+ bar.segments
142
+ .filter((seg) => !hiddenSet.has(seg.label))
143
+ .map((seg) => `${bar.label}, ${seg.label}: ${seg.value}`)
144
+ )
118
145
  );
119
146
 
120
147
  // --- Data labels ----------------------------------------------------------
@@ -206,9 +233,22 @@
206
233
  {#if showLegend && legend.length > 0}
207
234
  <ul class="st-stackedBar__legend">
208
235
  {#each legend as item (item.seriesLabel)}
209
- <li class="st-stackedBar__legendItem">
210
- <span class="st-stackedBar__legendSwatch st-stackedBar__legendSwatch--{item.tone}" aria-hidden="true"></span>
211
- {item.seriesLabel}
236
+ {@const off = hiddenSet.has(item.seriesLabel)}
237
+ <li class="st-stackedBar__legendItem" class:st-stackedBar__legendItem--off={legendInteractive && off}>
238
+ {#if legendInteractive}
239
+ <button
240
+ type="button"
241
+ class="st-stackedBar__legendButton"
242
+ aria-pressed={off}
243
+ onclick={() => onToggleSeries?.(item.seriesLabel)}
244
+ >
245
+ <span class="st-stackedBar__legendSwatch st-stackedBar__legendSwatch--{item.tone}" aria-hidden="true"></span>
246
+ {item.seriesLabel}
247
+ </button>
248
+ {:else}
249
+ <span class="st-stackedBar__legendSwatch st-stackedBar__legendSwatch--{item.tone}" aria-hidden="true"></span>
250
+ {item.seriesLabel}
251
+ {/if}
212
252
  </li>
213
253
  {/each}
214
254
  </ul>
@@ -243,6 +283,12 @@
243
283
  .st-stackedBar__tooltipValue { opacity: 0.85; }
244
284
  .st-stackedBar__legend { display: flex; flex-wrap: wrap; gap: 0.75rem; list-style: none; margin: 0.5rem 0 0; padding: 0; }
245
285
  .st-stackedBar__legendItem { align-items: center; color: var(--st-semantic-text-secondary); display: inline-flex; font-size: 0.75rem; gap: 0.35rem; }
286
+ .st-stackedBar__legendItem--off { opacity: 0.45; }
287
+ .st-stackedBar__legendButton {
288
+ align-items: center; background: none; border: 0; border-radius: var(--st-radius-sm, 0.25rem);
289
+ color: inherit; cursor: pointer; display: inline-flex; font: inherit; gap: 0.35rem; padding: 0.125rem 0.25rem;
290
+ }
291
+ .st-stackedBar__legendButton:focus-visible { outline: 2px solid var(--st-semantic-border-interactive); outline-offset: 2px; }
246
292
  .st-stackedBar__legendSwatch { border-radius: 2px; height: 0.7rem; width: 0.7rem; }
247
293
  .st-stackedBar__legendSwatch--category1 { background: var(--st-semantic-data-category1); }
248
294
  .st-stackedBar__legendSwatch--category2 { background: var(--st-semantic-data-category2); }
@@ -23,6 +23,16 @@ type StackedBarChartProps = {
23
23
  * values already live in the accessible ChartDataList.
24
24
  */
25
25
  dataLabels?: DataLabelsProp;
26
+ /**
27
+ * Interactive legend (FR-4). Ids/labels of series hidden from the render
28
+ * (controlled by the parent; default = all visible). Each segment whose
29
+ * `label` ∈ `hiddenSeries` is omitted and its legend item is shown "off"
30
+ * (`aria-pressed`). Undefined → legacy non-interactive legend, unless
31
+ * `onToggleSeries` is provided.
32
+ */
33
+ hiddenSeries?: string[];
34
+ /** Emitted on click / Enter / Space on a legend item. */
35
+ onToggleSeries?: (seriesId: string) => void;
26
36
  class?: string;
27
37
  };
28
38
  declare const StackedBarChart: import("svelte").Component<StackedBarChartProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"StackedBarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/StackedBarChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B,CAAC;AAIJ,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG/F,KAAK,oBAAoB,GAAG;IAC1B,IAAI,EAAE,eAAe,EAAE,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAmLJ,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"StackedBarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/StackedBarChart.svelte.ts"],"names":[],"mappings":"AAGE,MAAM,MAAM,cAAc,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACrD,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B,CAAC;AAIJ,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG/F,KAAK,oBAAoB,GAAG;IAC1B,IAAI,EAAE,eAAe,EAAE,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,yDAAyD;IACzD,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4MJ,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -0,0 +1,29 @@
1
+ /** Keys that drive the roving focus, mapped to an action. */
2
+ export type DatapointNavAction = {
3
+ kind: "move";
4
+ index: number;
5
+ } | {
6
+ kind: "select";
7
+ } | {
8
+ kind: "escape";
9
+ };
10
+ /**
11
+ * Maps a keydown to a roving-nav action given the current focused index and the
12
+ * datum count. Returns `null` for keys we don't handle (so the caller leaves the
13
+ * event untouched). `count` is assumed >= 1 when called.
14
+ */
15
+ export declare function datapointNavAction(key: string, current: number, count: number): DatapointNavAction | null;
16
+ /**
17
+ * The roving `tabindex` for the datum at `index`: `0` (the single tab stop) for
18
+ * the focused datum, `-1` for every other. When nothing is focused yet
19
+ * (`focusedIndex < 0`) the FIRST datum holds the tab stop so the group is
20
+ * reachable by Tab.
21
+ */
22
+ export declare function rovingTabIndex(index: number, focusedIndex: number, count: number): number;
23
+ /**
24
+ * Accessible label for a focused datum: its category followed by its value, e.g.
25
+ * `"Janvier, 42"`. `category` is the x/categorical label, `value` the y value.
26
+ * Kept framework-agnostic and identical across the three packages.
27
+ */
28
+ export declare function datapointAriaLabel(category: string | number, value: string | number): string;
29
+ //# sourceMappingURL=chartKeyboardNav.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chartKeyboardNav.d.ts","sourceRoot":"","sources":["../src/lib/chartKeyboardNav.ts"],"names":[],"mappings":"AAqBA,6DAA6D;AAC7D,MAAM,MAAM,kBAAkB,GAC1B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEvB;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,kBAAkB,GAAG,IAAI,CAwB3B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAGzF;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAE5F"}
@@ -0,0 +1,69 @@
1
+ // --- Chart datapoint keyboard navigation (shared, framework-agnostic) --------
2
+ //
3
+ // FR-5: ROVING-TABINDEX keyboard navigation over a cartesian chart's data
4
+ // points (Bar / Line / Area). The decorative SVG stays `aria-hidden`; a thin,
5
+ // transparent overlay of focusable hit-rects (one per datum, in data order) is
6
+ // rendered on top. The overlay carries a SINGLE tab stop — the focused datum —
7
+ // and the arrow keys move that focus between datums:
8
+ //
9
+ // ←/↑ previous datum →/↓ next datum
10
+ // Home first datum End last datum
11
+ // Enter / Space select the focused datum Escape leave the nav
12
+ //
13
+ // Each focusable element announces its category + value via `aria-label`
14
+ // (built by `datapointAriaLabel`). Selection emits `onSelectKey?(key | null)`;
15
+ // Escape clears the internal selection (emits null). The focus also feeds the
16
+ // shared crosshair channel: moving focus emits `onHoverKeyChange(key)` so a
17
+ // linked tooltip (FR-3) tracks the keyboard cursor, and leaving emits null.
18
+ //
19
+ // Purely additive: a chart that wires neither `onSelectKey` nor `keyboardNav`
20
+ // renders exactly as before (no overlay, no extra tab stop).
21
+ /**
22
+ * Maps a keydown to a roving-nav action given the current focused index and the
23
+ * datum count. Returns `null` for keys we don't handle (so the caller leaves the
24
+ * event untouched). `count` is assumed >= 1 when called.
25
+ */
26
+ export function datapointNavAction(key, current, count) {
27
+ if (count <= 0)
28
+ return null;
29
+ const clamp = (i) => Math.min(count - 1, Math.max(0, i));
30
+ switch (key) {
31
+ case "ArrowRight":
32
+ case "ArrowDown":
33
+ return { kind: "move", index: clamp(current + 1) };
34
+ case "ArrowLeft":
35
+ case "ArrowUp":
36
+ return { kind: "move", index: clamp(current - 1) };
37
+ case "Home":
38
+ return { kind: "move", index: 0 };
39
+ case "End":
40
+ return { kind: "move", index: count - 1 };
41
+ case "Enter":
42
+ case " ":
43
+ case "Spacebar": // legacy IE/Edge value
44
+ return { kind: "select" };
45
+ case "Escape":
46
+ case "Esc": // legacy value
47
+ return { kind: "escape" };
48
+ default:
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * The roving `tabindex` for the datum at `index`: `0` (the single tab stop) for
54
+ * the focused datum, `-1` for every other. When nothing is focused yet
55
+ * (`focusedIndex < 0`) the FIRST datum holds the tab stop so the group is
56
+ * reachable by Tab.
57
+ */
58
+ export function rovingTabIndex(index, focusedIndex, count) {
59
+ const active = focusedIndex >= 0 && focusedIndex < count ? focusedIndex : 0;
60
+ return index === active ? 0 : -1;
61
+ }
62
+ /**
63
+ * Accessible label for a focused datum: its category followed by its value, e.g.
64
+ * `"Janvier, 42"`. `category` is the x/categorical label, `value` the y value.
65
+ * Kept framework-agnostic and identical across the three packages.
66
+ */
67
+ export function datapointAriaLabel(category, value) {
68
+ return `${category}, ${value}`;
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentropic/design-system-svelte",
3
- "version": "0.34.25",
3
+ "version": "0.34.27",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"