@sentropic/design-system-svelte 0.34.28 → 0.34.33

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 (41) hide show
  1. package/dist/Badge.svelte +66 -2
  2. package/dist/Badge.svelte.d.ts +21 -0
  3. package/dist/Badge.svelte.d.ts.map +1 -1
  4. package/dist/CandlestickChart.svelte +286 -11
  5. package/dist/CandlestickChart.svelte.d.ts +17 -3
  6. package/dist/CandlestickChart.svelte.d.ts.map +1 -1
  7. package/dist/CellDecorationIcon.svelte +39 -0
  8. package/dist/CellDecorationIcon.svelte.d.ts +7 -0
  9. package/dist/CellDecorationIcon.svelte.d.ts.map +1 -0
  10. package/dist/Collapsible.svelte +55 -1
  11. package/dist/Collapsible.svelte.d.ts +15 -0
  12. package/dist/Collapsible.svelte.d.ts.map +1 -1
  13. package/dist/Collapsible.test.d.ts +2 -0
  14. package/dist/Collapsible.test.d.ts.map +1 -0
  15. package/dist/Collapsible.test.js +68 -0
  16. package/dist/ComboChart.svelte +333 -2
  17. package/dist/ComboChart.svelte.d.ts +34 -0
  18. package/dist/ComboChart.svelte.d.ts.map +1 -1
  19. package/dist/DataTable.svelte +91 -2
  20. package/dist/DataTable.svelte.d.ts +12 -0
  21. package/dist/DataTable.svelte.d.ts.map +1 -1
  22. package/dist/KpiCard.svelte +66 -1
  23. package/dist/KpiCard.svelte.d.ts +7 -0
  24. package/dist/KpiCard.svelte.d.ts.map +1 -1
  25. package/dist/OHLCChart.svelte +286 -11
  26. package/dist/OHLCChart.svelte.d.ts +17 -3
  27. package/dist/OHLCChart.svelte.d.ts.map +1 -1
  28. package/dist/ScatterPlot.svelte +260 -6
  29. package/dist/ScatterPlot.svelte.d.ts +25 -0
  30. package/dist/ScatterPlot.svelte.d.ts.map +1 -1
  31. package/dist/SelectableList.svelte +36 -17
  32. package/dist/SelectableList.svelte.d.ts.map +1 -1
  33. package/dist/SelectableRow.svelte +53 -1
  34. package/dist/SelectableRow.svelte.d.ts +10 -0
  35. package/dist/SelectableRow.svelte.d.ts.map +1 -1
  36. package/dist/cellDecoration.d.ts +36 -0
  37. package/dist/cellDecoration.d.ts.map +1 -0
  38. package/dist/cellDecoration.js +71 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/package.json +3 -3
@@ -7,7 +7,22 @@
7
7
  /** État ouvert (bindable). */
8
8
  open?: boolean;
9
9
  title: string;
10
+ /**
11
+ * Density of the trigger — `"md"` (default) is the current render. `"sm"`
12
+ * de-emphasizes the trigger (smaller font/weight/padding) for NESTED /
13
+ * level-2 collapsibles; `"lg"` enlarges it. Additive: with `size` unset the
14
+ * trigger renders byte-identically to before.
15
+ */
16
+ size?: "sm" | "md" | "lg";
10
17
  disabled?: boolean;
18
+ /**
19
+ * Trailing content rendered inside the trigger, BETWEEN the title and the
20
+ * chevron (e.g. a count Badge, a status Tag, a glyph). The chevron stays the
21
+ * rightmost affordance. If the trailing content carries information SR users
22
+ * need as part of the trigger name, set `aria-label` on the Collapsible via
23
+ * `...rest` (e.g. `aria-label="Entities, 128 items"`).
24
+ */
25
+ trailing?: Snippet;
11
26
  onToggle?: (open: boolean) => void;
12
27
  class?: string;
13
28
  children?: Snippet;
@@ -16,7 +31,9 @@
16
31
  let {
17
32
  open = $bindable(false),
18
33
  title,
34
+ size = "md",
19
35
  disabled = false,
36
+ trailing,
20
37
  onToggle,
21
38
  class: className,
22
39
  children,
@@ -26,7 +43,14 @@
26
43
  const uid = `st-collapsible-${Math.random().toString(36).slice(2, 9)}`;
27
44
 
28
45
  const classes = $derived(
29
- ["st-collapsible", open ? "st-collapsible--open" : null, className].filter(Boolean).join(" ")
46
+ [
47
+ "st-collapsible",
48
+ `st-collapsible--${size}`,
49
+ open ? "st-collapsible--open" : null,
50
+ className
51
+ ]
52
+ .filter(Boolean)
53
+ .join(" ")
30
54
  );
31
55
 
32
56
  function toggle() {
@@ -47,6 +71,9 @@
47
71
  onclick={toggle}
48
72
  >
49
73
  <span class="st-collapsible__title">{title}</span>
74
+ {#if trailing}
75
+ <span class="st-collapsible__trailing">{@render trailing()}</span>
76
+ {/if}
50
77
  <span class="st-collapsible__icon" aria-hidden="true">
51
78
  <ChevronDown size={18} strokeWidth={2.25} />
52
79
  </span>
@@ -103,10 +130,37 @@
103
130
  cursor: not-allowed;
104
131
  }
105
132
 
133
+ /* Density variants (additive). `md` is the UNTOUCHED base `.st-collapsible__trigger`
134
+ above — no `--md` rule exists, so a `size="md"` (or unset) trigger renders
135
+ byte-identically. `--sm` de-emphasizes for nesting; `--lg` enlarges. Every
136
+ leaf falls back to a base literal so a theme that emits no
137
+ `--st-component-collapsible-*` renders these variants identically. */
138
+ .st-collapsible--sm .st-collapsible__trigger {
139
+ font-size: var(--st-component-collapsible-sm-fontSize, 0.875rem);
140
+ font-weight: var(--st-component-collapsible-sm-fontWeight, 500);
141
+ padding: var(--st-component-collapsible-sm-paddingBlock, 0.4rem)
142
+ var(--st-component-collapsible-sm-paddingInline, 0.25rem);
143
+ }
144
+
145
+ .st-collapsible--lg .st-collapsible__trigger {
146
+ font-size: var(--st-component-collapsible-lg-fontSize, 1rem);
147
+ padding: var(--st-component-collapsible-lg-paddingBlock, 0.875rem)
148
+ var(--st-component-collapsible-lg-paddingInline, 0.25rem);
149
+ }
150
+
106
151
  .st-collapsible__title {
107
152
  flex: 1 1 auto;
108
153
  }
109
154
 
155
+ /* Trigger trailing slot (additive). Holds a count badge / status / glyph
156
+ between the title and the chevron; never grows, so the chevron stays the
157
+ rightmost affordance and the title keeps `flex: 1 1 auto`. */
158
+ .st-collapsible__trailing {
159
+ align-items: center;
160
+ display: inline-flex;
161
+ flex: 0 0 auto;
162
+ }
163
+
110
164
  .st-collapsible__icon {
111
165
  align-items: center;
112
166
  color: var(--st-semantic-text-secondary);
@@ -4,7 +4,22 @@ type CollapsibleProps = Omit<HTMLAttributes<HTMLDivElement>, "class" | "title">
4
4
  /** État ouvert (bindable). */
5
5
  open?: boolean;
6
6
  title: string;
7
+ /**
8
+ * Density of the trigger — `"md"` (default) is the current render. `"sm"`
9
+ * de-emphasizes the trigger (smaller font/weight/padding) for NESTED /
10
+ * level-2 collapsibles; `"lg"` enlarges it. Additive: with `size` unset the
11
+ * trigger renders byte-identically to before.
12
+ */
13
+ size?: "sm" | "md" | "lg";
7
14
  disabled?: boolean;
15
+ /**
16
+ * Trailing content rendered inside the trigger, BETWEEN the title and the
17
+ * chevron (e.g. a count Badge, a status Tag, a glyph). The chevron stays the
18
+ * rightmost affordance. If the trailing content carries information SR users
19
+ * need as part of the trigger name, set `aria-label` on the Collapsible via
20
+ * `...rest` (e.g. `aria-label="Entities, 128 items"`).
21
+ */
22
+ trailing?: Snippet;
8
23
  onToggle?: (open: boolean) => void;
9
24
  class?: string;
10
25
  children?: Snippet;
@@ -1 +1 @@
1
- {"version":3,"file":"Collapsible.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Collapsible.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,gBAAgB,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG;IAChF,8BAA8B;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAgDJ,QAAA,MAAM,WAAW,0DAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"Collapsible.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Collapsible.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,gBAAgB,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG;IAChF,8BAA8B;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA4DJ,QAAA,MAAM,WAAW,0DAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=Collapsible.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Collapsible.test.d.ts","sourceRoot":"","sources":["../src/lib/Collapsible.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,68 @@
1
+ import { fireEvent, render } from "@testing-library/svelte";
2
+ import { createRawSnippet } from "svelte";
3
+ import { describe, expect, it } from "vitest";
4
+ import Collapsible from "./Collapsible.svelte";
5
+ const snippet = (html) => createRawSnippet(() => ({ render: () => `<span>${html}</span>` }));
6
+ // Svelte scopes component styles by appending a per-component hash class (e.g.
7
+ // "svelte-pav3x3") to every styled element's class list. Compare against the
8
+ // SEMANTIC class only (the first token) so the assertions are stable across
9
+ // builds and read the structural class, not the scope hash.
10
+ const structuralClass = (el) => el.className.split(/\s+/)[0];
11
+ describe("Collapsible — base (byte-identity)", () => {
12
+ it("renders the default trigger with no trailing span and the md density class", () => {
13
+ const { container } = render(Collapsible, { props: { title: "Entities" } });
14
+ const root = container.querySelector(".st-collapsible");
15
+ expect(root).toBeTruthy();
16
+ // Default density is md (additive class), and there is NO trailing span when
17
+ // the slot is absent — the trigger keeps its original [title][chevron] shape.
18
+ expect(root.classList.contains("st-collapsible--md")).toBe(true);
19
+ expect(root.classList.contains("st-collapsible--sm")).toBe(false);
20
+ expect(container.querySelector(".st-collapsible__trailing")).toBeNull();
21
+ expect(container.querySelector(".st-collapsible__title")?.textContent).toBe("Entities");
22
+ // Trigger order is preserved: only the title span then the chevron.
23
+ const trigger = container.querySelector(".st-collapsible__trigger");
24
+ const spans = Array.from(trigger.children).map(structuralClass);
25
+ expect(spans).toEqual(["st-collapsible__title", "st-collapsible__icon"]);
26
+ });
27
+ });
28
+ describe("Collapsible — size", () => {
29
+ it("size=\"sm\" toggles st-collapsible--sm", () => {
30
+ const { container } = render(Collapsible, { props: { title: "Type", size: "sm" } });
31
+ const root = container.querySelector(".st-collapsible");
32
+ expect(root.classList.contains("st-collapsible--sm")).toBe(true);
33
+ expect(root.classList.contains("st-collapsible--md")).toBe(false);
34
+ });
35
+ it("size=\"lg\" toggles st-collapsible--lg", () => {
36
+ const { container } = render(Collapsible, { props: { title: "Section", size: "lg" } });
37
+ expect(container.querySelector(".st-collapsible").classList.contains("st-collapsible--lg")).toBe(true);
38
+ });
39
+ });
40
+ describe("Collapsible — trailing slot", () => {
41
+ it("renders trailing content between the title and the chevron", () => {
42
+ const { container } = render(Collapsible, {
43
+ props: { title: "Entities", trailing: snippet("128") },
44
+ });
45
+ const trailing = container.querySelector(".st-collapsible__trailing");
46
+ expect(trailing).toBeTruthy();
47
+ expect(trailing?.textContent).toContain("128");
48
+ // Order inside the trigger: title, trailing, chevron (chevron stays last).
49
+ const trigger = container.querySelector(".st-collapsible__trigger");
50
+ const order = Array.from(trigger.children).map(structuralClass);
51
+ expect(order).toEqual([
52
+ "st-collapsible__title",
53
+ "st-collapsible__trailing",
54
+ "st-collapsible__icon",
55
+ ]);
56
+ });
57
+ });
58
+ describe("Collapsible — a11y unchanged", () => {
59
+ it("toggles aria-expanded on click with size + trailing set", async () => {
60
+ const { container } = render(Collapsible, {
61
+ props: { title: "Entities", size: "sm", trailing: snippet("7") },
62
+ });
63
+ const trigger = container.querySelector(".st-collapsible__trigger");
64
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
65
+ await fireEvent.click(trigger);
66
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
67
+ });
68
+ });
@@ -25,6 +25,15 @@
25
25
 
26
26
  <script lang="ts">
27
27
  import ChartDataList from "./ChartDataList.svelte";
28
+ import {
29
+ resolveAnnotations,
30
+ annotationDataListItems,
31
+ polygonPoints,
32
+ type ChartAnnotation
33
+ } from "./chartAnnotations.js";
34
+ import { formatDataLabel, normalizeDataLabels, type DataLabelsProp } from "./chartDataLabels.js";
35
+ import { keyForX, resolveActiveIndex } from "./chartCrosshair.js";
36
+ import { datapointAriaLabel, datapointNavAction, rovingTabIndex } from "./chartKeyboardNav.js";
28
37
 
29
38
  type ComboChartProps = {
30
39
  categories: string[];
@@ -42,6 +51,38 @@
42
51
  hiddenSeries?: string[];
43
52
  /** Emitted on click / Enter / Space on a legend item. */
44
53
  onToggleSeries?: (seriesId: string) => void;
54
+ /**
55
+ * Annotation overlay in DATA space. The x coordinate is CATEGORICAL — it
56
+ * matches a category by equality (band centre); the y coordinate (and
57
+ * `value`/`from`/`to`) are LEFT (bar) value-axis numbers. Regions render
58
+ * behind the bars, every other kind above. Additive: absent ⇒ unchanged.
59
+ */
60
+ annotations?: ChartAnnotation[];
61
+ /**
62
+ * Per-datum value labels on BOTH the bars and the line points. `false`/absent
63
+ * (default) → none. `true` → each value with the chart's numeric formatter.
64
+ * Object → `format(value)` and/or a `position` override. Labels are
65
+ * `aria-hidden` — the values already live in the accessible ChartDataList.
66
+ */
67
+ dataLabels?: DataLabelsProp;
68
+ /**
69
+ * CONTROLLED synchronised hover key (FR-3). The key is the CATEGORY string.
70
+ * When provided (string or null), the crosshair tracks this key instead of
71
+ * the chart's internal pointer hover (null ⇒ nothing shown). Absent keeps
72
+ * the legacy uncontrolled behaviour.
73
+ */
74
+ hoverKey?: string | null;
75
+ /** Emitted when the user hovers a bar/point (its CATEGORY) or leaves (`null`). */
76
+ onHoverKeyChange?: (key: string | null) => void;
77
+ /**
78
+ * FR-5 — keyboard navigation of the categories (roving tabindex). When `true`
79
+ * (or implied by wiring `onSelectKey`), a focusable overlay of one column per
80
+ * category is rendered: one tab stop, arrows move, Home/End jump, Enter/Space
81
+ * select, Escape leaves. Absent ⇒ no overlay, rendering unchanged.
82
+ */
83
+ keyboardNav?: boolean;
84
+ /** Emitted on Enter/Space (category) or `null` on Escape. */
85
+ onSelectKey?: (key: string | null) => void;
45
86
  width?: number;
46
87
  height?: number;
47
88
  label: string;
@@ -57,12 +98,21 @@
57
98
  legend = true,
58
99
  hiddenSeries,
59
100
  onToggleSeries,
101
+ annotations,
102
+ dataLabels,
103
+ hoverKey,
104
+ onHoverKeyChange,
105
+ keyboardNav,
106
+ onSelectKey,
60
107
  width = 480,
61
108
  height = 240,
62
109
  label,
63
110
  class: className
64
111
  }: ComboChartProps = $props();
65
112
 
113
+ let focusedIndex: number = $state(-1);
114
+ let datapointRefs: Array<SVGRectElement | null> = [];
115
+
66
116
  // Interactive legend is active as soon as the parent wires either prop.
67
117
  const legendInteractive = $derived(onToggleSeries !== undefined || hiddenSeries !== undefined);
68
118
  const hiddenSet = $derived(new Set(hiddenSeries ?? []));
@@ -263,22 +313,103 @@
263
313
  .flatMap((s) => categories.map((c, ci) => `${s.label}, ${c}: ${s.data[ci] ?? 0}`)),
264
314
  ...lines
265
315
  .filter((s) => !hiddenSet.has(s.label))
266
- .flatMap((s) => categories.map((c, ci) => `${s.label}, ${c}: ${s.data[ci] ?? 0}`))
316
+ .flatMap((s) => categories.map((c, ci) => `${s.label}, ${c}: ${s.data[ci] ?? 0}`)),
317
+ ...annotationDataListItems(annotations)
267
318
  ]);
268
319
 
320
+ // --- Annotation overlay ---------------------------------------------------
321
+ // `xScale` matches a category by equality → its band centre (relative to the
322
+ // plot); `yScale` maps a LEFT (bar) value-axis number. Out-of-domain coords
323
+ // yield null → dropped, so an annotation never escapes the plot.
324
+ const resolvedAnnotations = $derived(
325
+ resolveAnnotations(annotations, {
326
+ xScale: (v: number | string) => {
327
+ const i = categories.indexOf(String(v));
328
+ return i < 0 ? null : bandCenter(i) - MARGIN.left;
329
+ },
330
+ yScale: (v: number) =>
331
+ Number.isFinite(v) ? scaleLinear(v, leftScale.domainMin, leftScale.domainMax, plotHeight, 0) : null,
332
+ plotLeft: MARGIN.left,
333
+ plotTop: MARGIN.top,
334
+ plotWidth,
335
+ plotHeight
336
+ })
337
+ );
338
+ const annotationRegions = $derived(resolvedAnnotations.filter((a) => a.kind === "region"));
339
+ const annotationAbove = $derived(resolvedAnnotations.filter((a) => a.kind !== "region"));
340
+
341
+ // --- Data labels ----------------------------------------------------------
342
+ // One value label per visible bar (outside) + per visible line point (top).
343
+ const dataLabelOpts = $derived(normalizeDataLabels(dataLabels));
344
+ const barDataLabelItems = $derived(
345
+ dataLabelOpts.enabled
346
+ ? barGroups.flatMap((group, gi) =>
347
+ group.map((seg, si) => {
348
+ const inside = dataLabelOpts.position === "inside" || dataLabelOpts.position === "center";
349
+ return {
350
+ key: `bar-${gi}-${si}`,
351
+ x: seg.cx,
352
+ y: inside ? seg.y + seg.height / 2 : seg.cy - 6,
353
+ text: formatDataLabel(seg.value, dataLabelOpts, formatTick),
354
+ baseline: (inside ? "middle" : "auto") as "middle" | "auto"
355
+ };
356
+ })
357
+ )
358
+ : []
359
+ );
360
+ const lineDataLabelItems = $derived(
361
+ dataLabelOpts.enabled
362
+ ? lineSeries.flatMap((series, li) =>
363
+ series.hidden
364
+ ? []
365
+ : series.points.map((p, pi) => {
366
+ const center =
367
+ dataLabelOpts.position === "center" || dataLabelOpts.position === "inside";
368
+ return {
369
+ key: `line-${li}-${pi}`,
370
+ x: p.x,
371
+ y: center ? p.y : p.y - 8,
372
+ text: formatDataLabel(p.value, dataLabelOpts, formatTick),
373
+ baseline: (center ? "middle" : "auto") as "middle" | "auto"
374
+ };
375
+ })
376
+ )
377
+ : []
378
+ );
379
+
380
+ // --- Crosshair + keyboard nav keys (FR-3 / FR-5) --------------------------
381
+ // The shared datum is the CATEGORY: its key is the category string.
382
+ const hoverKeys = $derived(categories.map((c) => keyForX(c)));
383
+ const categorySummary = (ci: number): string =>
384
+ [
385
+ ...bars.filter((s) => !hiddenSet.has(s.label)),
386
+ ...lines.filter((s) => !hiddenSet.has(s.label))
387
+ ]
388
+ .map((s) => {
389
+ const raw = s.data[ci];
390
+ return raw == null || !Number.isFinite(raw) ? null : `${s.label}: ${raw}`;
391
+ })
392
+ .filter((v): v is string => v !== null)
393
+ .join(", ");
394
+
269
395
  type Hover =
270
396
  | { kind: "bar"; gi: number; si: number }
271
397
  | { kind: "line"; li: number; pi: number }
272
398
  | null;
273
- let hovered: Hover = $state(null);
399
+ let hovered = $state<Hover>(null);
274
400
 
401
+ function emitHoverKey(index: number | null) {
402
+ onHoverKeyChange?.(index == null ? null : hoverKeys[index] ?? null);
403
+ }
275
404
  function handleLeave() {
276
405
  hovered = null;
406
+ emitHoverKey(null);
277
407
  }
278
408
  function handleVisualPointerMove(event: PointerEvent) {
279
409
  const target = event.target;
280
410
  if (!(target instanceof Element)) {
281
411
  hovered = null;
412
+ emitHoverKey(null);
282
413
  return;
283
414
  }
284
415
  const kind = target.getAttribute("data-chart-kind");
@@ -286,10 +417,50 @@
286
417
  const b = Number(target.getAttribute("data-chart-b"));
287
418
  if (kind === "bar" && Number.isInteger(a) && Number.isInteger(b)) {
288
419
  hovered = { kind: "bar", gi: a, si: b };
420
+ emitHoverKey(a); // gi === category index
289
421
  } else if (kind === "line" && Number.isInteger(a) && Number.isInteger(b)) {
290
422
  hovered = { kind: "line", li: a, pi: b };
423
+ emitHoverKey(b); // pi === category index
291
424
  } else {
292
425
  hovered = null;
426
+ emitHoverKey(null);
427
+ }
428
+ }
429
+
430
+ // Category index whose crosshair is DISPLAYED: the controlled `hoverKey` when
431
+ // provided (resolved against the category keys), else the internal pointer
432
+ // category (derived from the hovered bar/line datum).
433
+ const internalCategoryIndex = $derived(
434
+ hovered == null ? null : hovered.kind === "bar" ? hovered.gi : hovered.pi
435
+ );
436
+ const activeCategoryIndex = $derived(
437
+ resolveActiveIndex(hoverKey, internalCategoryIndex, hoverKeys)
438
+ );
439
+ const crosshairX = $derived(activeCategoryIndex >= 0 ? bandCenter(activeCategoryIndex) : null);
440
+
441
+ // --- Keyboard navigation (FR-5) ------------------------------------------
442
+ // One focusable transparent column per category carries the roving tab stop.
443
+ const navEnabled = $derived(
444
+ (keyboardNav === true || onSelectKey !== undefined) && categories.length > 0
445
+ );
446
+ function focusDatum(index: number) {
447
+ focusedIndex = index;
448
+ datapointRefs[index]?.focus();
449
+ emitHoverKey(index);
450
+ }
451
+ function handleDatapointKeyDown(event: KeyboardEvent, index: number) {
452
+ const action = datapointNavAction(event.key, index, categories.length);
453
+ if (!action) return;
454
+ event.preventDefault();
455
+ if (action.kind === "move") {
456
+ focusDatum(action.index);
457
+ } else if (action.kind === "select") {
458
+ onSelectKey?.(hoverKeys[index] ?? null);
459
+ } else {
460
+ focusedIndex = -1;
461
+ emitHoverKey(null);
462
+ onSelectKey?.(null);
463
+ (event.currentTarget as SVGElement).blur();
293
464
  }
294
465
  }
295
466
 
@@ -403,6 +574,20 @@
403
574
  </text>
404
575
  {/each}
405
576
 
577
+ <!-- Annotation regions sit BEHIND the bars (filled bands). -->
578
+ {#if annotationRegions.length > 0}
579
+ <g class="st-comboChart__annotations st-comboChart__annotations--behind">
580
+ {#each annotationRegions as a (a.key)}
581
+ {#if a.kind === "region"}
582
+ <rect class="st-comboChart__annotationRegion" x={a.x} y={a.y} width={a.width} height={a.height} />
583
+ {#if a.label}
584
+ <text class="st-comboChart__annotationLabel" x={a.x + 4} y={a.y + 11}>{a.label}</text>
585
+ {/if}
586
+ {/if}
587
+ {/each}
588
+ </g>
589
+ {/if}
590
+
406
591
  <!-- bars -->
407
592
  {#each barGroups as group, gi (gi)}
408
593
  {#each group as seg, si (si)}
@@ -444,7 +629,93 @@
444
629
  {/each}
445
630
  {/if}
446
631
  {/each}
632
+
633
+ <!-- Annotations ABOVE the bars/lines: lines, shapes, points, labels. -->
634
+ {#if annotationAbove.length > 0}
635
+ <g class="st-comboChart__annotations st-comboChart__annotations--above">
636
+ {#each annotationAbove as a (a.key)}
637
+ {#if a.kind === "line"}
638
+ <line class="st-comboChart__annotationLine" x1={a.x1} y1={a.y1} x2={a.x2} y2={a.y2} />
639
+ {#if a.label}
640
+ <text
641
+ class="st-comboChart__annotationLabel"
642
+ x={a.axis === "x" ? a.x1 + 4 : MARGIN.left + plotWidth - 4}
643
+ y={a.axis === "x" ? MARGIN.top + 11 : a.y1 - 4}
644
+ text-anchor={a.axis === "x" ? "start" : "end"}
645
+ >
646
+ {a.label}
647
+ </text>
648
+ {/if}
649
+ {:else if a.kind === "shape"}
650
+ <polygon class="st-comboChart__annotationShape" points={polygonPoints(a.points)} />
651
+ {#if a.label}
652
+ <text class="st-comboChart__annotationLabel" x={a.labelX} y={a.labelY} text-anchor="middle">{a.label}</text>
653
+ {/if}
654
+ {:else if a.kind === "point"}
655
+ <circle class="st-comboChart__annotationPoint" cx={a.x} cy={a.y} r="4.5" />
656
+ {#if a.label}
657
+ <text class="st-comboChart__annotationLabel" x={a.x} y={a.y - 8} text-anchor="middle">{a.label}</text>
658
+ {/if}
659
+ {:else if a.kind === "label"}
660
+ <text class="st-comboChart__annotationText" x={a.x} y={a.y} text-anchor={a.anchor}>{a.text}</text>
661
+ {/if}
662
+ {/each}
663
+ </g>
664
+ {/if}
665
+
666
+ <!-- Data labels — one value per bar + per line point, on top. aria-hidden. -->
667
+ {#if barDataLabelItems.length + lineDataLabelItems.length > 0}
668
+ <g class="st-comboChart__dataLabels" aria-hidden="true">
669
+ {#each barDataLabelItems as d (d.key)}
670
+ <text class="st-comboChart__dataLabel" x={d.x} y={d.y} text-anchor="middle" dominant-baseline={d.baseline}>{d.text}</text>
671
+ {/each}
672
+ {#each lineDataLabelItems as d (d.key)}
673
+ <text class="st-comboChart__dataLabel" x={d.x} y={d.y} text-anchor="middle" dominant-baseline={d.baseline}>{d.text}</text>
674
+ {/each}
675
+ </g>
676
+ {/if}
677
+
678
+ <!-- Crosshair (FR-3) — a tokenised dashed line on the CATEGORY axis at the
679
+ active category. Decorative (aria-hidden). -->
680
+ {#if crosshairX !== null}
681
+ <g class="st-comboChart__crosshair" aria-hidden="true">
682
+ <line class="st-comboChart__crosshairLine" x1={crosshairX} x2={crosshairX} y1={MARGIN.top} y2={MARGIN.top + plotHeight} />
683
+ </g>
684
+ {/if}
447
685
  </svg>
686
+
687
+ <!-- Keyboard navigation overlay (FR-5) — a focusable, transparent column per
688
+ category. NOT aria-hidden: the accessible roving cursor. -->
689
+ {#if navEnabled}
690
+ <svg
691
+ class="st-comboChart__navLayer"
692
+ viewBox="0 0 {width} {height}"
693
+ preserveAspectRatio="xMidYMid meet"
694
+ width="100%"
695
+ height="100%"
696
+ role="group"
697
+ aria-label="{label} — points de données"
698
+ >
699
+ {#each categories as category, ci (ci)}
700
+ <rect
701
+ bind:this={datapointRefs[ci]}
702
+ class="st-comboChart__navDatum"
703
+ x={MARGIN.left + (plotWidth / Math.max(categories.length, 1)) * ci}
704
+ y={MARGIN.top}
705
+ width={plotWidth / Math.max(categories.length, 1)}
706
+ height={plotHeight}
707
+ role="img"
708
+ tabindex={rovingTabIndex(ci, focusedIndex, categories.length)}
709
+ aria-label={datapointAriaLabel(category, categorySummary(ci))}
710
+ onkeydown={(event) => handleDatapointKeyDown(event, ci)}
711
+ onfocus={() => {
712
+ focusedIndex = ci;
713
+ emitHoverKey(ci);
714
+ }}
715
+ />
716
+ {/each}
717
+ </svg>
718
+ {/if}
448
719
  </div>
449
720
 
450
721
  <ChartDataList {label} items={dataValueItems} />
@@ -578,6 +849,66 @@
578
849
  .st-comboChart__dot--category7 { fill: var(--st-semantic-data-category7); }
579
850
  .st-comboChart__dot--category8 { fill: var(--st-semantic-data-category8); }
580
851
 
852
+ /* --- Annotation layer ----------------------------------------------------
853
+ Regions render BEHIND the bars; lines/shapes/points/labels render ABOVE. */
854
+ .st-comboChart__annotationRegion {
855
+ fill: color-mix(in srgb, var(--st-semantic-feedback-info) 12%, transparent);
856
+ stroke: none;
857
+ }
858
+ .st-comboChart__annotationLine {
859
+ stroke: var(--st-semantic-feedback-info);
860
+ stroke-width: 1.5;
861
+ stroke-dasharray: 4 3;
862
+ }
863
+ .st-comboChart__annotationShape {
864
+ fill: color-mix(in srgb, var(--st-semantic-feedback-info) 14%, transparent);
865
+ stroke: var(--st-semantic-feedback-info);
866
+ stroke-width: 1.5;
867
+ }
868
+ .st-comboChart__annotationPoint {
869
+ fill: var(--st-semantic-feedback-info);
870
+ stroke: var(--st-semantic-surface-default);
871
+ stroke-width: 1.5;
872
+ }
873
+ .st-comboChart__annotationLabel,
874
+ .st-comboChart__annotationText {
875
+ fill: var(--st-semantic-text-primary);
876
+ font-size: 0.625rem;
877
+ font-weight: 600;
878
+ }
879
+
880
+ /* Data labels — per-bar + per-point value, drawn on top. Token-only colour. */
881
+ .st-comboChart__dataLabel {
882
+ fill: var(--st-semantic-text-primary);
883
+ font-size: 0.6875rem;
884
+ font-weight: 600;
885
+ }
886
+
887
+ /* --- Crosshair layer (FR-3) ----------------------------------------------
888
+ A tokenised dashed line on the CATEGORY axis at the active category. */
889
+ .st-comboChart__crosshairLine {
890
+ stroke: var(--st-semantic-border-strong);
891
+ stroke-width: 1;
892
+ stroke-dasharray: 3 3;
893
+ opacity: 0.7;
894
+ }
895
+
896
+ /* --- Keyboard navigation layer (FR-5) ------------------------------------
897
+ A focusable, transparent overlay of one column per category. */
898
+ .st-comboChart__navLayer {
899
+ inset: 0;
900
+ position: absolute;
901
+ }
902
+ .st-comboChart__navDatum {
903
+ fill: transparent;
904
+ outline: none;
905
+ }
906
+ .st-comboChart__navDatum:focus-visible {
907
+ fill: color-mix(in srgb, var(--st-semantic-border-interactive) 12%, transparent);
908
+ outline: 2px solid var(--st-semantic-border-interactive);
909
+ outline-offset: 1px;
910
+ }
911
+
581
912
  @media (prefers-reduced-motion: reduce) {
582
913
  .st-comboChart__bar,
583
914
  .st-comboChart__dot {
@@ -10,6 +10,8 @@ export type ComboChartLineSeries = {
10
10
  tone?: ComboChartTone;
11
11
  smooth?: boolean;
12
12
  };
13
+ import { type ChartAnnotation } from "./chartAnnotations.js";
14
+ import { type DataLabelsProp } from "./chartDataLabels.js";
13
15
  type ComboChartProps = {
14
16
  categories: string[];
15
17
  bars?: ComboChartBarSeries[];
@@ -26,6 +28,38 @@ type ComboChartProps = {
26
28
  hiddenSeries?: string[];
27
29
  /** Emitted on click / Enter / Space on a legend item. */
28
30
  onToggleSeries?: (seriesId: string) => void;
31
+ /**
32
+ * Annotation overlay in DATA space. The x coordinate is CATEGORICAL — it
33
+ * matches a category by equality (band centre); the y coordinate (and
34
+ * `value`/`from`/`to`) are LEFT (bar) value-axis numbers. Regions render
35
+ * behind the bars, every other kind above. Additive: absent ⇒ unchanged.
36
+ */
37
+ annotations?: ChartAnnotation[];
38
+ /**
39
+ * Per-datum value labels on BOTH the bars and the line points. `false`/absent
40
+ * (default) → none. `true` → each value with the chart's numeric formatter.
41
+ * Object → `format(value)` and/or a `position` override. Labels are
42
+ * `aria-hidden` — the values already live in the accessible ChartDataList.
43
+ */
44
+ dataLabels?: DataLabelsProp;
45
+ /**
46
+ * CONTROLLED synchronised hover key (FR-3). The key is the CATEGORY string.
47
+ * When provided (string or null), the crosshair tracks this key instead of
48
+ * the chart's internal pointer hover (null ⇒ nothing shown). Absent keeps
49
+ * the legacy uncontrolled behaviour.
50
+ */
51
+ hoverKey?: string | null;
52
+ /** Emitted when the user hovers a bar/point (its CATEGORY) or leaves (`null`). */
53
+ onHoverKeyChange?: (key: string | null) => void;
54
+ /**
55
+ * FR-5 — keyboard navigation of the categories (roving tabindex). When `true`
56
+ * (or implied by wiring `onSelectKey`), a focusable overlay of one column per
57
+ * category is rendered: one tab stop, arrows move, Home/End jump, Enter/Space
58
+ * select, Escape leaves. Absent ⇒ no overlay, rendering unchanged.
59
+ */
60
+ keyboardNav?: boolean;
61
+ /** Emitted on Enter/Space (category) or `null` on Escape. */
62
+ onSelectKey?: (key: string | null) => void;
29
63
  width?: number;
30
64
  height?: number;
31
65
  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;;;;;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"}
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;AAIJ,OAAO,EAIH,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AACjC,OAAO,EAAwC,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAK/F,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;;;;;OAKG;IACH,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4jBJ,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}