@spatika/react 1.3.0 → 1.4.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 (59) hide show
  1. package/README.md +6 -2
  2. package/dist/composites/EventCalendar.d.ts +13 -2
  3. package/dist/composites/EventCalendar.d.ts.map +1 -1
  4. package/dist/composites/EventCalendar.js +10 -24
  5. package/dist/composites/cartesian-charts.d.ts +12 -2
  6. package/dist/composites/cartesian-charts.d.ts.map +1 -1
  7. package/dist/composites/cartesian-charts.js +27 -13
  8. package/dist/composites/chart-composition.d.ts +19 -1
  9. package/dist/composites/chart-composition.d.ts.map +1 -1
  10. package/dist/composites/chart-composition.js +124 -62
  11. package/dist/composites/chart-interaction.d.ts +81 -0
  12. package/dist/composites/chart-interaction.d.ts.map +1 -0
  13. package/dist/composites/chart-interaction.js +56 -0
  14. package/dist/composites/chart-ui.d.ts +8 -13
  15. package/dist/composites/chart-ui.d.ts.map +1 -1
  16. package/dist/composites/chart-ui.js +9 -6
  17. package/dist/composites/flow-charts.d.ts +6 -1
  18. package/dist/composites/flow-charts.d.ts.map +1 -1
  19. package/dist/composites/flow-charts.js +28 -14
  20. package/dist/composites/radial-charts.d.ts +11 -1
  21. package/dist/composites/radial-charts.d.ts.map +1 -1
  22. package/dist/composites/radial-charts.js +33 -14
  23. package/dist/composites/scheduler-ui.d.ts +14 -1
  24. package/dist/composites/scheduler-ui.d.ts.map +1 -1
  25. package/dist/composites/scheduler-ui.js +23 -2
  26. package/dist/index.d.ts +8 -6
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -4
  29. package/dist/lib/chips.d.ts +3 -0
  30. package/dist/lib/chips.d.ts.map +1 -1
  31. package/dist/lib/chips.js +10 -0
  32. package/dist/lib/scheduler.d.ts +12 -0
  33. package/dist/lib/scheduler.d.ts.map +1 -1
  34. package/dist/lib/scheduler.js +22 -0
  35. package/dist/primitives/NativeSelect.d.ts +10 -1
  36. package/dist/primitives/NativeSelect.d.ts.map +1 -1
  37. package/dist/primitives/NativeSelect.js +2 -2
  38. package/package.json +3 -3
  39. package/skills/spatika-ui/SKILL.md +4 -0
  40. package/src/composites/EventCalendar.tsx +35 -24
  41. package/src/composites/cartesian-charts.test.tsx +86 -2
  42. package/src/composites/cartesian-charts.tsx +84 -16
  43. package/src/composites/chart-composition.test.tsx +15 -0
  44. package/src/composites/chart-composition.tsx +229 -88
  45. package/src/composites/chart-interaction.ts +136 -0
  46. package/src/composites/chart-ui.tsx +35 -27
  47. package/src/composites/flow-charts.test.tsx +21 -2
  48. package/src/composites/flow-charts.tsx +59 -25
  49. package/src/composites/radial-charts.test.tsx +17 -2
  50. package/src/composites/radial-charts.tsx +38 -12
  51. package/src/composites/scheduler-ui.tsx +128 -26
  52. package/src/composites/scheduler.test.tsx +39 -0
  53. package/src/index.test.ts +4 -0
  54. package/src/index.ts +18 -1
  55. package/src/lib/chips.ts +16 -0
  56. package/src/lib/scheduler.test.ts +16 -0
  57. package/src/lib/scheduler.ts +34 -0
  58. package/src/primitives/NativeSelect.test.tsx +28 -0
  59. package/src/primitives/NativeSelect.tsx +14 -4
@@ -62,6 +62,14 @@ import {
62
62
  seriesMeta,
63
63
  type ChartHover,
64
64
  } from "./chart-ui";
65
+ import {
66
+ bindChartMark,
67
+ seriesItemColor,
68
+ type ChartItemEvent,
69
+ type ChartMarkRenderContext,
70
+ type ChartReferenceLine,
71
+ type ChartTooltipRenderer,
72
+ } from "./chart-interaction";
65
73
 
66
74
  export type ComposedSeriesType = "bar" | "line" | "area" | "scatter";
67
75
 
@@ -70,6 +78,8 @@ export type ComposedSeries = {
70
78
  type?: ComposedSeriesType;
71
79
  label?: string;
72
80
  color?: string;
81
+ /** Per-category colors; each entry falls back to `color`. */
82
+ itemColors?: Array<string | null | undefined>;
73
83
  data?: Array<number | null>;
74
84
  dataKey?: string;
75
85
  stack?: string;
@@ -85,6 +95,7 @@ type ResolvedSeries = {
85
95
  type: ComposedSeriesType;
86
96
  label: string;
87
97
  color: string;
98
+ itemColors?: Array<string | null | undefined>;
88
99
  data: Array<number | null>;
89
100
  stack?: string;
90
101
  yAxisId?: string;
@@ -135,6 +146,10 @@ type ChartContextValue = {
135
146
  brush: { x0: number; x1: number } | null;
136
147
  setBrush: (brush: { x0: number; x1: number } | null) => void;
137
148
  exportChart: (format: "svg" | "png") => Promise<void>;
149
+ onItemClick?: (event: ChartItemEvent) => void;
150
+ renderMark?: (ctx: ChartMarkRenderContext) => ReactNode;
151
+ referenceLines?: ChartReferenceLine[];
152
+ showLabels?: boolean;
138
153
  };
139
154
 
140
155
  const ChartContext = createContext<ChartContextValue | null>(null);
@@ -175,6 +190,14 @@ export type ChartContainerProps = {
175
190
  locale?: string;
176
191
  children?: ReactNode;
177
192
  "aria-label"?: string;
193
+ /** When true, plot height follows the parent instead of the numeric `height`. */
194
+ fillHeight?: boolean;
195
+ onItemClick?: (event: ChartItemEvent) => void;
196
+ renderTooltip?: ChartTooltipRenderer;
197
+ renderMark?: (ctx: ChartMarkRenderContext) => ReactNode;
198
+ referenceLines?: ChartReferenceLine[];
199
+ /** Draw the numeric value at the end of each bar. */
200
+ showLabels?: boolean;
178
201
  };
179
202
 
180
203
  function useElementSize(ref: RefObject<HTMLElement | null>, fallbackWidth: number, fallbackHeight: number) {
@@ -207,6 +230,7 @@ function resolveSeries(series: ComposedSeries[], dataset: ChartDataset | undefin
207
230
  type,
208
231
  label: item.label ?? item.dataKey ?? item.id ?? `Series ${index + 1}`,
209
232
  color: item.color ?? chartColor(index, colors),
233
+ itemColors: item.itemColors,
210
234
  data,
211
235
  stack: item.stack,
212
236
  yAxisId: item.yAxisId,
@@ -277,12 +301,19 @@ export function ChartContainer({
277
301
  locale,
278
302
  children,
279
303
  "aria-label": ariaLabel,
304
+ fillHeight = false,
305
+ onItemClick,
306
+ renderTooltip,
307
+ renderMark,
308
+ referenceLines,
309
+ showLabels = false,
280
310
  }: ChartContainerProps) {
281
311
  const surfaceRef = useRef<HTMLDivElement>(null);
282
312
  const svgRef = useRef<SVGSVGElement>(null);
283
313
  const clipPathId = `spk-chart-clip-${useId().replace(/:/g, "")}`;
284
314
  const measured = useElementSize(surfaceRef, width ?? 320, height);
285
315
  const plotWidth = width ?? measured.width;
316
+ const plotHeight = fillHeight ? measured.height : height;
286
317
  const hasRight = yAxis?.some((axis) => axis.position === "right");
287
318
  const m = resolveMargin(
288
319
  margin ?? (hasRight ? { ...DEFAULT_CHART_MARGIN, right: 48 } : undefined),
@@ -320,7 +351,7 @@ export function ChartContainer({
320
351
  const [brush, setBrush] = useState<{ x0: number; x1: number } | null>(null);
321
352
  const activeHighlight = selected ?? highlighted;
322
353
 
323
- const plot = plotBox(plotWidth, height, m);
354
+ const plot = plotBox(plotWidth, plotHeight, m);
324
355
  const visible = resolved.filter((item) => !hidden.has(item.id));
325
356
  const leftSeries = visible.filter((item) => !item.yAxisId || item.yAxisId === (leftAxis?.id ?? item.yAxisId));
326
357
  const rightSeries = visible.filter((item) => rightAxis && item.yAxisId === rightAxis.id);
@@ -372,7 +403,7 @@ export function ChartContainer({
372
403
  const ctx: ChartContextValue = {
373
404
  slot,
374
405
  width: plotWidth,
375
- height,
406
+ height: plotHeight,
376
407
  margin: m,
377
408
  plot,
378
409
  categories,
@@ -409,6 +440,10 @@ export function ChartContainer({
409
440
  brush,
410
441
  setBrush,
411
442
  exportChart: doExport,
443
+ onItemClick,
444
+ renderMark,
445
+ referenceLines,
446
+ showLabels,
412
447
  };
413
448
 
414
449
  const legend = seriesMeta(resolved, colors);
@@ -420,7 +455,7 @@ export function ChartContainer({
420
455
  <ChartContext.Provider value={ctx}>
421
456
  <div
422
457
  data-slot={slot}
423
- className={cn("spk-chart", animated && "spk-chart--animated", className)}
458
+ className={cn("spk-chart", animated && "spk-chart--animated", fillHeight && "spk-chart--fill", className)}
424
459
  role="img"
425
460
  aria-label={label}
426
461
  >
@@ -428,9 +463,9 @@ export function ChartContainer({
428
463
  {hideLegend ? null : (
429
464
  <ChartLegend items={legend} hiddenIds={hidden} onToggle={toggleSeries} />
430
465
  )}
431
- <div ref={surfaceRef} className="spk-chart-surface" style={{ height }}>
432
- {plotWidth > 0 ? children ?? <DefaultChartSurface /> : null}
433
- <ChartTooltip hover={hover} boundsWidth={plotWidth} />
466
+ <div ref={surfaceRef} className="spk-chart-surface" style={fillHeight ? undefined : { height: plotHeight }}>
467
+ {plotWidth > 0 && plotHeight > 0 ? children ?? <DefaultChartSurface /> : null}
468
+ <ChartTooltip hover={hover} boundsWidth={plotWidth} render={renderTooltip} />
434
469
  </div>
435
470
  <ChartA11yTable />
436
471
  </div>
@@ -450,6 +485,7 @@ function DefaultChartSurface() {
450
485
  <ChartsXAxis />
451
486
  <ChartsYAxis />
452
487
  {rightAxis ? <ChartsYAxis position="right" /> : null}
488
+ <ChartsReferenceLine />
453
489
  <ChartsBrush />
454
490
  </ChartSurface>
455
491
  );
@@ -609,50 +645,81 @@ export function BarPlot() {
609
645
  const barW = (ctx.bandwidth - gap * (n - 1)) / n;
610
646
  const zero = ctx.yAt(0);
611
647
  return (
612
- <PlotClip slot="bar-plot">
613
- {bars.map((item, s) =>
614
- item.data.map((value, i) => {
615
- if (value == null) return null;
616
- let x = ctx.xAt(i);
617
- let y1: number;
618
- let y2: number;
619
- if (stacks) {
620
- const seg = stacks[s]![i]!;
621
- y1 = ctx.yAt(seg.end, item.yAxisId);
622
- y2 = ctx.yAt(seg.start, item.yAxisId);
623
- } else {
624
- x = ctx.xAt(i) + s * (barW + gap);
625
- y1 = ctx.yAt(value, item.yAxisId);
626
- y2 = zero;
627
- }
628
- const y = Math.min(y1, y2);
629
- const h = Math.max(1, Math.abs(y2 - y1));
630
- const opacity = markOpacity(ctx.highlightScope, ctx.highlighted, item.id, i);
631
- return (
632
- <path
633
- key={`${item.id}-${i}`}
634
- className="spk-chart-mark"
635
- d={roundedRectPath(x, y, ctx.stacked ? ctx.bandwidth : barW, h, ctx.borderRadius)}
636
- fill={item.color}
637
- opacity={opacity}
638
- onMouseEnter={(event) => {
639
- ctx.setHighlighted({ seriesId: item.id, dataIndex: i });
640
- ctx.setHover({
641
- x: event.nativeEvent.offsetX,
642
- y: event.nativeEvent.offsetY,
643
- title: String(ctx.categories[i]),
644
- items: [{ color: item.color, label: item.label, value: formatSeriesValue(value) }],
645
- });
646
- }}
647
- onMouseLeave={() => {
648
- ctx.setHighlighted(null);
649
- ctx.setHover(null);
650
- }}
651
- />
652
- );
653
- }),
654
- )}
655
- </PlotClip>
648
+ <>
649
+ <PlotClip slot="bar-plot">
650
+ {bars.map((item, s) =>
651
+ item.data.map((value, i) => {
652
+ if (value == null) return null;
653
+ let x = ctx.xAt(i);
654
+ let y1: number;
655
+ let y2: number;
656
+ if (stacks) {
657
+ const seg = stacks[s]![i]!;
658
+ y1 = ctx.yAt(seg.end, item.yAxisId);
659
+ y2 = ctx.yAt(seg.start, item.yAxisId);
660
+ } else {
661
+ x = ctx.xAt(i) + s * (barW + gap);
662
+ y1 = ctx.yAt(value, item.yAxisId);
663
+ y2 = zero;
664
+ }
665
+ const y = Math.min(y1, y2);
666
+ const h = Math.max(1, Math.abs(y2 - y1));
667
+ const opacity = markOpacity(ctx.highlightScope, ctx.highlighted, item.id, i);
668
+ const color = seriesItemColor(item.color, item.itemColors, i);
669
+ const event: ChartItemEvent = {
670
+ seriesId: item.id,
671
+ seriesLabel: item.label,
672
+ dataIndex: i,
673
+ category: ctx.categories[i] ?? i,
674
+ value,
675
+ color,
676
+ };
677
+ return (
678
+ <path
679
+ key={`${item.id}-${i}`}
680
+ d={roundedRectPath(x, y, ctx.stacked ? ctx.bandwidth : barW, h, ctx.borderRadius)}
681
+ fill={color}
682
+ opacity={opacity}
683
+ {...bindChartMark({
684
+ event,
685
+ hover: {
686
+ title: String(ctx.categories[i]),
687
+ items: [{ color, label: item.label, value: formatSeriesValue(value) }],
688
+ },
689
+ onItemClick: ctx.onItemClick,
690
+ setHover: ctx.setHover,
691
+ clearHover: () => ctx.setHover(null),
692
+ onHighlight: ctx.setHighlighted,
693
+ })}
694
+ />
695
+ );
696
+ }),
697
+ )}
698
+ </PlotClip>
699
+ {ctx.showLabels ? (
700
+ <g data-slot="bar-labels" pointerEvents="none">
701
+ {bars.map((item, s) =>
702
+ item.data.map((value, i) => {
703
+ if (value == null) return null;
704
+ const x = ctx.stacked ? ctx.xAt(i) : ctx.xAt(i) + s * (barW + gap);
705
+ const y = ctx.yAt(value, item.yAxisId);
706
+ const width = ctx.stacked ? ctx.bandwidth : barW;
707
+ return (
708
+ <text
709
+ key={`${item.id}-${i}-label`}
710
+ className="spk-chart-label"
711
+ x={x + width / 2}
712
+ y={y - 4}
713
+ textAnchor="middle"
714
+ >
715
+ {formatSeriesValue(value)}
716
+ </text>
717
+ );
718
+ }),
719
+ )}
720
+ </g>
721
+ ) : null}
722
+ </>
656
723
  );
657
724
  }
658
725
 
@@ -705,29 +772,40 @@ function LineLikePlot({ types, slot }: { types: ComposedSeriesType[]; slot: stri
705
772
  className="spk-chart-mark"
706
773
  />
707
774
  {item.showMark
708
- ? pts.map((pt) => (
709
- <circle
710
- key={pt.i}
711
- cx={pt.x}
712
- cy={pt.y}
713
- r={3.4}
714
- fill={item.color}
715
- className="spk-chart-mark"
716
- onMouseEnter={() => {
717
- ctx.setHighlighted({ seriesId: item.id, dataIndex: pt.i });
718
- ctx.setHover({
719
- x: pt.x,
720
- y: pt.y,
721
- title: String(ctx.categories[pt.i]),
722
- items: [{ color: item.color, label: item.label, value: formatSeriesValue(pt.value) }],
723
- });
724
- }}
725
- onMouseLeave={() => {
726
- ctx.setHighlighted(null);
727
- ctx.setHover(null);
728
- }}
729
- />
730
- ))
775
+ ? pts.map((pt) => {
776
+ const color = seriesItemColor(item.color, item.itemColors, pt.i);
777
+ const event: ChartItemEvent = {
778
+ seriesId: item.id,
779
+ seriesLabel: item.label,
780
+ dataIndex: pt.i,
781
+ category: ctx.categories[pt.i] ?? pt.i,
782
+ value: pt.value,
783
+ color,
784
+ };
785
+ const mark = ctx.renderMark?.({ ...event, x: pt.x, y: pt.y }) ?? (
786
+ <circle cx={pt.x} cy={pt.y} r={3.4} fill={color} />
787
+ );
788
+ return (
789
+ <g
790
+ key={pt.i}
791
+ {...bindChartMark({
792
+ event,
793
+ hover: {
794
+ x: pt.x,
795
+ y: pt.y,
796
+ title: String(ctx.categories[pt.i]),
797
+ items: [{ color, label: item.label, value: formatSeriesValue(pt.value) }],
798
+ },
799
+ onItemClick: ctx.onItemClick,
800
+ setHover: ctx.setHover,
801
+ clearHover: () => ctx.setHover(null),
802
+ onHighlight: ctx.setHighlighted,
803
+ })}
804
+ >
805
+ {mark}
806
+ </g>
807
+ );
808
+ })
731
809
  : null}
732
810
  </g>
733
811
  );
@@ -763,31 +841,37 @@ export function ScatterPlot() {
763
841
  const cx = applyZoom(xScale(point.x), ctx.plot.left, ctx.plot.iw, ctx.zoom.x);
764
842
  const cy = ctx.yAt(point.y, item.yAxisId);
765
843
  const opacity = markOpacity(ctx.highlightScope, ctx.highlighted, item.id, i);
844
+ const color = seriesItemColor(item.color, item.itemColors, i);
845
+ const event: ChartItemEvent = {
846
+ seriesId: item.id,
847
+ seriesLabel: item.label,
848
+ dataIndex: i,
849
+ category: point.id ?? i,
850
+ value: point.y,
851
+ color,
852
+ };
766
853
  return (
767
854
  <circle
768
855
  key={`${item.id}-${point.id ?? i}`}
769
856
  cx={cx}
770
857
  cy={cy}
771
858
  r={4.5}
772
- fill={item.color}
859
+ fill={color}
773
860
  opacity={opacity}
774
- className="spk-chart-mark"
775
- onMouseEnter={(event) => {
776
- ctx.setHighlighted({ seriesId: item.id, dataIndex: i });
777
- ctx.setHover({
778
- x: event.nativeEvent.offsetX,
779
- y: event.nativeEvent.offsetY,
861
+ {...bindChartMark({
862
+ event,
863
+ hover: {
780
864
  title: item.label,
781
865
  items: [
782
- { color: item.color, label: "x", value: formatChartNumber(point.x, ctx.locale) },
783
- { color: item.color, label: "y", value: formatChartNumber(point.y, ctx.locale) },
866
+ { color, label: "x", value: formatChartNumber(point.x, ctx.locale) },
867
+ { color, label: "y", value: formatChartNumber(point.y, ctx.locale) },
784
868
  ],
785
- });
786
- }}
787
- onMouseLeave={() => {
788
- ctx.setHighlighted(null);
789
- ctx.setHover(null);
790
- }}
869
+ },
870
+ onItemClick: ctx.onItemClick,
871
+ setHover: ctx.setHover,
872
+ clearHover: () => ctx.setHover(null),
873
+ onHighlight: ctx.setHighlighted,
874
+ })}
791
875
  />
792
876
  );
793
877
  });
@@ -813,6 +897,63 @@ export function ChartsBrush() {
813
897
  );
814
898
  }
815
899
 
900
+ /** Horizontal (value) or vertical (category index) guide overlay. */
901
+ export function ChartsReferenceLine() {
902
+ const { referenceLines, yAt, xAt, plot, bandwidth } = useChartContext();
903
+ if (!referenceLines?.length) return null;
904
+ return (
905
+ <g data-slot="chart-reference-lines">
906
+ {referenceLines.map((line, index) => {
907
+ const color = line.color ?? "var(--primary)";
908
+ const dash = line.strokeDasharray ?? "4 4";
909
+ if (line.y != null) {
910
+ const y = yAt(line.y, line.yAxisId);
911
+ return (
912
+ <g key={`y-${index}`}>
913
+ <line
914
+ className="spk-chart-ref-line"
915
+ x1={plot.left}
916
+ x2={plot.right}
917
+ y1={y}
918
+ y2={y}
919
+ stroke={color}
920
+ strokeDasharray={dash}
921
+ />
922
+ {line.label ? (
923
+ <text className="spk-chart-ref-label" x={plot.right - 4} y={y - 4} textAnchor="end">
924
+ {line.label}
925
+ </text>
926
+ ) : null}
927
+ </g>
928
+ );
929
+ }
930
+ if (line.xIndex != null) {
931
+ const x = xAt(line.xIndex) + bandwidth / 2;
932
+ return (
933
+ <g key={`x-${index}`}>
934
+ <line
935
+ className="spk-chart-ref-line"
936
+ x1={x}
937
+ x2={x}
938
+ y1={plot.top}
939
+ y2={plot.bottom}
940
+ stroke={color}
941
+ strokeDasharray={dash}
942
+ />
943
+ {line.label ? (
944
+ <text className="spk-chart-ref-label" x={x + 4} y={plot.top + 12}>
945
+ {line.label}
946
+ </text>
947
+ ) : null}
948
+ </g>
949
+ );
950
+ }
951
+ return null;
952
+ })}
953
+ </g>
954
+ );
955
+ }
956
+
816
957
  export function ChartsLegend() {
817
958
  const { series, hidden, toggleSeries } = useChartContext();
818
959
  return (
@@ -0,0 +1,136 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export type ChartTooltipItem = {
5
+ color: string;
6
+ label: string;
7
+ value: string;
8
+ };
9
+
10
+ export type ChartHover = {
11
+ x: number;
12
+ y: number;
13
+ title?: string;
14
+ items: ChartTooltipItem[];
15
+ dataIndex?: number;
16
+ seriesId?: string;
17
+ category?: string | number | Date;
18
+ } | null;
19
+
20
+ /** Payload fired when a chart mark (bar, slice, point, cell) is activated. */
21
+ export type ChartItemEvent = {
22
+ seriesId: string;
23
+ seriesLabel: string;
24
+ dataIndex: number;
25
+ category: string | number | Date;
26
+ value: number | null;
27
+ color: string;
28
+ };
29
+
30
+ /** Geometry plus item identity, used by `renderMark` on line and area series. */
31
+ export type ChartMarkRenderContext = ChartItemEvent & {
32
+ x: number;
33
+ y: number;
34
+ };
35
+
36
+ /**
37
+ * Overlay guide on a cartesian plot.
38
+ * Set `y` for a horizontal value line, or `xIndex` for a vertical category line.
39
+ */
40
+ export type ChartReferenceLine = {
41
+ y?: number;
42
+ xIndex?: number;
43
+ yAxisId?: string;
44
+ color?: string;
45
+ label?: string;
46
+ strokeDasharray?: string;
47
+ };
48
+
49
+ export type ChartTooltipRenderer = (hover: NonNullable<ChartHover>) => ReactNode;
50
+
51
+ export type ChartMarkBind = {
52
+ className: string;
53
+ role?: "button";
54
+ tabIndex?: number;
55
+ "aria-label"?: string;
56
+ onClick?: (event: { stopPropagation(): void }) => void;
57
+ onKeyDown?: (event: { key: string; preventDefault(): void }) => void;
58
+ onMouseEnter?: (event: { nativeEvent: { offsetX: number; offsetY: number } }) => void;
59
+ onMouseLeave?: () => void;
60
+ };
61
+
62
+ /** Per-category color, falling back to the series token. */
63
+ export function seriesItemColor(
64
+ color: string,
65
+ itemColors: Array<string | null | undefined> | undefined,
66
+ index: number,
67
+ ): string {
68
+ return itemColors?.[index] || color;
69
+ }
70
+
71
+ export function chartItemAriaLabel(event: ChartItemEvent): string {
72
+ const category = String(event.category);
73
+ if (event.seriesLabel && event.seriesLabel !== category) {
74
+ return `${event.seriesLabel} ${category}`;
75
+ }
76
+ return category;
77
+ }
78
+
79
+ /**
80
+ * Pointer, keyboard, and hover bindings for an SVG mark.
81
+ * Adds a button role only when `onItemClick` is set so legends stay the sole named buttons.
82
+ */
83
+ export function bindChartMark({
84
+ event,
85
+ hover,
86
+ onItemClick,
87
+ setHover,
88
+ clearHover,
89
+ onHighlight,
90
+ }: {
91
+ event: ChartItemEvent;
92
+ hover: Pick<NonNullable<ChartHover>, "title" | "items"> & Partial<NonNullable<ChartHover>>;
93
+ onItemClick?: (event: ChartItemEvent) => void;
94
+ setHover: (hover: ChartHover) => void;
95
+ clearHover: () => void;
96
+ onHighlight?: (item: { seriesId: string; dataIndex: number } | null) => void;
97
+ }): ChartMarkBind {
98
+ const clickable = Boolean(onItemClick);
99
+ const applyHover = (x: number, y: number) => {
100
+ onHighlight?.({ seriesId: event.seriesId, dataIndex: event.dataIndex });
101
+ setHover({
102
+ x: hover.x ?? x,
103
+ y: hover.y ?? y,
104
+ title: hover.title,
105
+ items: hover.items,
106
+ dataIndex: event.dataIndex,
107
+ seriesId: event.seriesId,
108
+ category: event.category,
109
+ });
110
+ };
111
+ return {
112
+ className: cn("spk-chart-mark", clickable && "spk-chart-mark--interactive"),
113
+ role: clickable ? "button" : undefined,
114
+ tabIndex: clickable ? 0 : undefined,
115
+ "aria-label": clickable ? chartItemAriaLabel(event) : undefined,
116
+ onClick: clickable
117
+ ? (native) => {
118
+ native.stopPropagation();
119
+ onItemClick?.(event);
120
+ }
121
+ : undefined,
122
+ onKeyDown: clickable
123
+ ? (native) => {
124
+ if (native.key === "Enter" || native.key === " ") {
125
+ native.preventDefault();
126
+ onItemClick?.(event);
127
+ }
128
+ }
129
+ : undefined,
130
+ onMouseEnter: (native) => applyHover(native.nativeEvent.offsetX, native.nativeEvent.offsetY),
131
+ onMouseLeave: () => {
132
+ onHighlight?.(null);
133
+ clearHover();
134
+ },
135
+ };
136
+ }