@galaxy-io/dls 1.3.0 → 1.3.2

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 (43) hide show
  1. package/dist/charts/BarChart.d.ts +3 -3
  2. package/dist/charts/BarChart.js +68 -104
  3. package/dist/charts/ChartCategoryTargets.d.ts +27 -0
  4. package/dist/charts/ChartCategoryTargets.js +75 -0
  5. package/dist/charts/ChartFrame.d.ts +34 -6
  6. package/dist/charts/ChartFrame.js +72 -15
  7. package/dist/charts/ChartPrimitives.d.ts +35 -0
  8. package/dist/charts/ChartPrimitives.js +75 -38
  9. package/dist/charts/ChartTooltip.d.ts +3 -3
  10. package/dist/charts/ChartTooltip.js +2 -2
  11. package/dist/charts/LineChart.js +47 -62
  12. package/dist/charts/PieChart.js +48 -26
  13. package/dist/charts/barChartGeometry.d.ts +66 -33
  14. package/dist/charts/barChartGeometry.js +7 -15
  15. package/dist/charts/barChartLegend.d.ts +25 -0
  16. package/dist/charts/barChartLegend.js +23 -0
  17. package/dist/charts/chartFormat.d.ts +9 -1
  18. package/dist/charts/chartFormat.js +13 -1
  19. package/dist/charts/chartScales.d.ts +58 -22
  20. package/dist/charts/chartScales.js +92 -38
  21. package/dist/charts/constants.d.ts +22 -6
  22. package/dist/charts/constants.js +22 -1
  23. package/dist/charts/lineChartGeometry.d.ts +53 -15
  24. package/dist/charts/lineChartGeometry.js +6 -15
  25. package/dist/charts/pieChartGeometry.d.ts +20 -8
  26. package/dist/charts/pieChartGeometry.js +3 -13
  27. package/dist/charts/types.d.ts +51 -145
  28. package/dist/charts/types.js +22 -3
  29. package/dist/charts/useCartesianChartLayout.d.ts +70 -0
  30. package/dist/charts/useCartesianChartLayout.js +56 -0
  31. package/dist/charts/useChartDimensions.d.ts +10 -3
  32. package/dist/charts/useChartDimensions.js +14 -4
  33. package/dist/charts/useChartInteraction.d.ts +13 -8
  34. package/dist/charts/useChartInteraction.js +7 -11
  35. package/dist/charts/useSeriesColorResolver.d.ts +37 -0
  36. package/dist/charts/useSeriesColorResolver.js +27 -0
  37. package/dist/inputs/DateInput.js +3 -3
  38. package/dist/inputs/MultiSelectInput.d.ts +3 -2
  39. package/dist/inputs/MultiSelectInput.js +8 -8
  40. package/dist/inputs/SelectInput.d.ts +4 -8
  41. package/dist/inputs/SelectInput.js +14 -20
  42. package/dist/styles.css +13 -11
  43. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { BarChartNormalization } from "./types";
2
2
  import type { BarChartGroupDatum, BaseCartesianChartProps, ChartSeriesStyles, ChartSize } from "./types";
3
- export type BarChartProps<TMetric extends string, TPivot extends string = string, TComponentKey extends string = string> = BaseCartesianChartProps & ChartSize & {
3
+ export type BarChartProps<TMetric extends string, TComponentKey extends string = string> = BaseCartesianChartProps & ChartSize & {
4
4
  /**
5
5
  * Presentation for every metric, keyed by metric.
6
6
  *
@@ -10,7 +10,7 @@ export type BarChartProps<TMetric extends string, TPivot extends string = string
10
10
  */
11
11
  series: ChartSeriesStyles<TMetric>;
12
12
  /** Categories along the x-axis, left to right. */
13
- groups: BarChartGroupDatum<NoInfer<TMetric>, NoInfer<TPivot>, NoInfer<TComponentKey>>[];
13
+ groups: BarChartGroupDatum<NoInfer<TMetric>, NoInfer<TComponentKey>>[];
14
14
  /** @default BarChartNormalization.NONE */
15
15
  normalization?: BarChartNormalization;
16
16
  /**
@@ -27,5 +27,5 @@ export type BarChartProps<TMetric extends string, TPivot extends string = string
27
27
  * `bars` in a group render side by side, several `components` in a bar stack,
28
28
  * and a chart doing both at once needs no extra configuration.
29
29
  */
30
- declare const BarChart: <TMetric extends string, TPivot extends string = string, TComponentKey extends string = string>({ series, groups, width, height, fillWidth, fillHeight, aspectRatio, margin, normalization, valueUnit, valueFormatter, labelFormatter, valueDomain, categoryLabelMode, categoryAxisTitle, valueAxisTitle, showGrid, showLegend, noTooltip, tooltipRenderer, isLoading, contentWhenEmpty, ariaLabel, ariaDescription, onPinnedChange, }: BarChartProps<TMetric, TPivot, TComponentKey>) => import("react").JSX.Element;
30
+ declare const BarChart: <TMetric extends string, TComponentKey extends string = string>({ series, groups, width, height, fillWidth, fillHeight, aspectRatio, margin, normalization, valueUnit, valueFormatter, labelFormatter, valueDomain, categoryLabelMode, categoryAxisTitle, valueAxisTitle, showGrid, showLegend, noTooltip, tooltipRenderer, isLoading, contentWhenEmpty, ariaLabel, ariaDescription, onPinnedChange, }: BarChartProps<TMetric, TComponentKey>) => import("react").JSX.Element;
31
31
  export default BarChart;
@@ -1,12 +1,10 @@
1
- import { useGalaxyTheme } from "../theme/GalaxyTheme.js";
2
- import { BarChartNormalization, ChartMarkState } from "./types.js";
3
- import { addAxisTitleSpace, getValueTicks } from "./chartScales.js";
1
+ import { BarChartNormalization, ChartKind, ChartTargetShape } from "./types.js";
2
+ import { useSeriesColorResolver } from "./useSeriesColorResolver.js";
4
3
  import { useChartInteraction } from "./useChartInteraction.js";
5
- import { useChartFormatters } from "./useChartFormatters.js";
6
- import { useChartDimensions } from "./useChartDimensions.js";
7
- import { resolveChartPaletteColor, resolveSeriesColor } from "./chartTheme.js";
8
- import { buildBarChartGeometry, roundedRectPath } from "./barChartGeometry.js";
9
- import { ChartBarPath, ChartHitTarget } from "./ChartPrimitives.js";
4
+ import { useCartesianChartLayout } from "./useCartesianChartLayout.js";
5
+ import { getBarLegendItems } from "./barChartLegend.js";
6
+ import { buildBarChartGeometry, getBarValueDomain, roundedRectPath } from "./barChartGeometry.js";
7
+ import { ChartBarPath } from "./ChartPrimitives.js";
10
8
  import ChartFrame from "./ChartFrame.js";
11
9
  import { ChartCategoryAxis, ChartValueAxis } from "./ChartAxis.js";
12
10
  import { useCallback, useMemo, useRef } from "react";
@@ -20,44 +18,48 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
20
18
  * and a chart doing both at once needs no extra configuration.
21
19
  */
22
20
  var BarChart = ({ series, groups, width, height, fillWidth, fillHeight, aspectRatio, margin, normalization = BarChartNormalization.NONE, valueUnit, valueFormatter, labelFormatter, valueDomain, categoryLabelMode, categoryAxisTitle, valueAxisTitle, showGrid = true, showLegend = true, noTooltip = false, tooltipRenderer, isLoading = false, contentWhenEmpty, ariaLabel, ariaDescription, onPinnedChange }) => {
23
- const { theme } = useGalaxyTheme();
24
21
  const containerRef = useRef(null);
25
- const dimensions = useChartDimensions({
26
- containerRef,
27
- width,
28
- height,
29
- fillWidth,
30
- fillHeight,
31
- aspectRatio,
32
- margin: addAxisTitleSpace(margin, {
33
- hasValueTitle: Boolean(valueAxisTitle),
34
- hasCategoryTitle: Boolean(categoryAxisTitle)
35
- })
36
- });
37
- const { plotWidth, plotHeight } = dimensions;
38
- const { formatValue, formatLabel } = useChartFormatters({
39
- valueFormatter,
40
- valueUnit,
41
- labelFormatter
42
- });
22
+ const colors = useSeriesColorResolver(series);
43
23
  /**
44
- * Axis and tooltip formatting for a percent-normalized chart.
24
+ * Axis-only formatting for a percent-normalized chart.
45
25
  *
46
- * Normalization rescales values into 0–1, so the caller's formatter — which
47
- * describes their real units — would label the axis `0.2`, `0.4`. Unless they
26
+ * Normalization rescales the *axis* into 0–1, so the caller's formatter —
27
+ * which describes their real units — would label it `0.2`, `0.4`. Unless they
48
28
  * asked for something specific, show shares as percentages.
29
+ *
30
+ * Deliberately not used for the tooltip. `stackComponents` normalizes each
31
+ * segment's extent but leaves its `value` raw, so a tooltip row still carries
32
+ * the real number: the axis reports the share, the tooltip reports what the
33
+ * share is made of. Running a row through this would multiply a raw 200 into
34
+ * `20000%`.
49
35
  */
50
- const formatAxisValue = useMemo(() => {
36
+ const deriveAxisFormatter = useCallback((formatValue) => {
51
37
  if (normalization !== BarChartNormalization.PERCENT) return formatValue;
52
38
  if (valueFormatter || valueUnit) return formatValue;
53
39
  return (value) => `${Math.round(value * 100)}%`;
54
40
  }, [
55
41
  normalization,
56
42
  valueFormatter,
57
- valueUnit,
58
- formatValue
43
+ valueUnit
59
44
  ]);
60
- const metricKeys = useMemo(() => Object.keys(series), [series]);
45
+ const { dimensions, domain, valueTicks, formatValue, formatLabel, formatAxisValue } = useCartesianChartLayout({
46
+ containerRef,
47
+ rawDomain: useMemo(() => getBarValueDomain(groups, normalization), [groups, normalization]),
48
+ valueDomain,
49
+ width,
50
+ height,
51
+ fillWidth,
52
+ fillHeight,
53
+ aspectRatio,
54
+ margin,
55
+ valueUnit,
56
+ valueFormatter,
57
+ labelFormatter,
58
+ deriveAxisFormatter,
59
+ valueAxisTitle,
60
+ categoryAxisTitle
61
+ });
62
+ const { plotWidth, plotHeight } = dimensions;
61
63
  /**
62
64
  * True when any bar carries more than one component.
63
65
  *
@@ -68,22 +70,14 @@ var BarChart = ({ series, groups, width, height, fillWidth, fillHeight, aspectRa
68
70
  * paint every segment identically.
69
71
  */
70
72
  const isStacked = useMemo(() => groups.some((group) => group.bars.some((bar) => bar.components.length > 1)), [groups]);
71
- const resolveColor = useCallback(({ metric, componentIndex, isStacked: barIsStacked, explicit }) => {
72
- if (explicit) return resolveChartPaletteColor(theme, explicit);
73
- if (barIsStacked) return resolveSeriesColor(theme, componentIndex);
74
- return resolveSeriesColor(theme, metricKeys.indexOf(metric), series[metric]?.color);
75
- }, [
76
- theme,
77
- metricKeys,
78
- series
79
- ]);
73
+ const resolveColor = useCallback(({ metric, componentIndex, isStacked: barIsStacked, explicit }) => barIsStacked ? colors.resolveByIndex(componentIndex, explicit) : colors.resolve(metric, explicit), [colors]);
80
74
  const geometry = useMemo(() => buildBarChartGeometry({
81
75
  groups,
82
76
  plotWidth,
83
77
  plotHeight,
84
78
  normalization,
85
79
  isStacked,
86
- valueDomain,
80
+ domain,
87
81
  resolveColor
88
82
  }), [
89
83
  groups,
@@ -91,65 +85,36 @@ var BarChart = ({ series, groups, width, height, fillWidth, fillHeight, aspectRa
91
85
  plotHeight,
92
86
  normalization,
93
87
  isStacked,
94
- valueDomain,
88
+ domain,
95
89
  resolveColor
96
90
  ]);
97
- const valueTicks = useMemo(() => getValueTicks(geometry.valueScale, valueDomain), [geometry.valueScale, valueDomain]);
98
91
  const isEmpty = groups.length === 0;
99
- const legendItems = useMemo(() => {
100
- if (!isStacked) return metricKeys.map((metric) => ({
101
- key: metric,
102
- label: series[metric].label,
103
- color: resolveColor({
104
- metric,
105
- barIndex: 0,
106
- componentIndex: 0,
107
- isStacked: false,
108
- explicit: series[metric].color
109
- })
110
- }));
111
- const seen = /* @__PURE__ */ new Map();
112
- for (const group of groups) for (const bar of group.bars) bar.components.forEach((component, componentIndex) => {
113
- if (!seen.has(component.key)) seen.set(component.key, {
114
- label: component.label,
115
- index: componentIndex,
116
- explicit: component.color
117
- });
118
- });
119
- return [...seen.entries()].map(([key, { label, index, explicit }]) => ({
120
- key,
121
- label,
122
- color: resolveColor({
123
- metric: metricKeys[0],
124
- barIndex: 0,
125
- componentIndex: index,
126
- isStacked: true,
127
- explicit
128
- })
129
- }));
130
- }, [
92
+ const legendItems = useMemo(() => getBarLegendItems({
131
93
  isStacked,
132
- metricKeys,
133
94
  series,
134
95
  groups,
135
- resolveColor
96
+ colors
97
+ }), [
98
+ isStacked,
99
+ series,
100
+ groups,
101
+ colors
136
102
  ]);
137
103
  const legendKeys = useMemo(() => legendItems.map((item) => item.key), [legendItems]);
138
104
  const interaction = useChartInteraction({
139
105
  categoryCount: geometry.bands.length,
140
106
  seriesKeys: legendKeys,
141
107
  disabled: noTooltip || isLoading || isEmpty,
108
+ isLoading,
142
109
  onPinnedChange
143
110
  });
144
- const tooltipModel = useMemo(() => {
145
- if (noTooltip || interaction.activeIndex === null) return null;
146
- const band = geometry.bands[interaction.activeIndex];
147
- const group = groups[interaction.activeIndex];
111
+ const buildCategoryTooltip = useCallback((index) => {
112
+ const band = geometry.bands[index];
113
+ const group = groups[index];
148
114
  if (!band || !group) return null;
149
- const rows = geometry.rects.filter((rect) => rect.groupIndex === interaction.activeIndex).map((rect) => ({
115
+ const rows = geometry.rects.filter((rect) => rect.groupIndex === index).map((rect) => ({
150
116
  key: rect.id,
151
117
  label: rect.label,
152
- group: series[rect.metric]?.label,
153
118
  value: rect.value,
154
119
  formattedValue: formatValue(rect.value),
155
120
  color: rect.color
@@ -161,23 +126,30 @@ var BarChart = ({ series, groups, width, height, fillWidth, fillHeight, aspectRa
161
126
  anchorY: band.anchorY
162
127
  };
163
128
  }, [
164
- noTooltip,
165
- interaction.activeIndex,
166
129
  geometry,
167
130
  groups,
168
- series,
169
131
  formatValue,
170
132
  formatLabel
171
133
  ]);
172
134
  const categoryPositions = useMemo(() => geometry.bands.map((band) => band.x), [geometry.bands]);
173
135
  const categoryStep = geometry.bands[0]?.width ?? 0;
174
- return /* @__PURE__ */ jsxs(ChartFrame, {
136
+ const categoryTargets = useMemo(() => geometry.bands.map((band) => ({
137
+ key: String(band.groupIndex),
138
+ shape: ChartTargetShape.RECT,
139
+ x: band.x,
140
+ y: band.y,
141
+ width: band.width,
142
+ height: band.height
143
+ })), [geometry.bands]);
144
+ return /* @__PURE__ */ jsx(ChartFrame, {
175
145
  containerRef,
176
146
  dimensions,
177
147
  fillWidth,
178
148
  fillHeight,
179
149
  interaction,
180
- ariaLabel: ariaLabel ?? `Bar chart with ${groups.length} categories and ${metricKeys.length} series`,
150
+ kind: ChartKind.BAR,
151
+ categoryCount: groups.length,
152
+ ariaLabel,
181
153
  ariaDescription,
182
154
  axes: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ChartValueAxis, {
183
155
  ticks: valueTicks,
@@ -199,31 +171,23 @@ var BarChart = ({ series, groups, width, height, fillWidth, fillHeight, aspectRa
199
171
  title: categoryAxisTitle
200
172
  })] }),
201
173
  noTooltip,
202
- tooltipModel,
174
+ buildCategoryTooltip,
203
175
  tooltipRenderer,
176
+ categoryTargets,
204
177
  anchorHalfWidth: categoryStep / 2,
205
178
  isEmpty,
206
179
  isLoading,
207
180
  contentWhenEmpty,
208
181
  showLegend,
209
182
  legendItems,
210
- children: [geometry.rects.map((rect) => /* @__PURE__ */ jsx(ChartBarPath, {
183
+ children: geometry.rects.map((rect) => /* @__PURE__ */ jsx(ChartBarPath, {
211
184
  d: roundedRectPath(rect.x, rect.y, rect.width, rect.height, rect.radius, rect.corners),
212
185
  fill: rect.color,
213
- "data-state": isLoading ? ChartMarkState.MUTED : interaction.getMarkState({
186
+ "data-state": interaction.getMarkState({
214
187
  categoryIndex: rect.groupIndex,
215
188
  seriesKey: isStacked ? rect.componentKey : rect.metric
216
189
  })
217
- }, rect.id)), !noTooltip && !isLoading && geometry.bands.map((band) => /* @__PURE__ */ jsx(ChartHitTarget, {
218
- x: band.x,
219
- y: band.y,
220
- width: band.width,
221
- height: band.height,
222
- "data-interactive": "true",
223
- onMouseEnter: () => interaction.onCategoryEnter(band.groupIndex),
224
- onMouseLeave: interaction.onCategoryLeave,
225
- onClick: () => interaction.onCategoryClick(band.groupIndex)
226
- }, band.groupIndex))]
190
+ }, rect.id))
227
191
  });
228
192
  };
229
193
  //#endregion
@@ -0,0 +1,27 @@
1
+ import type { ChartCategoryTarget } from "./types";
2
+ import type { ChartInteraction } from "./useChartInteraction";
3
+ /**
4
+ * The interactive layer: one focusable target per category, above the marks.
5
+ *
6
+ * Shared by all three charts so pointer and keyboard behave identically. Before
7
+ * this existed each chart spelled hit testing its own way — two rendered targets
8
+ * conditionally, the pie attached handlers permanently and computed
9
+ * `data-interactive` instead — and none of them was reachable by keyboard at
10
+ * all.
11
+ *
12
+ * **Focus rides the existing hover channel.** `onFocus` calls
13
+ * `onCategoryEnter` and `onBlur` calls `onCategoryLeave`, so the tooltip, the
14
+ * cursor line, the legend dimming and every `getMarkState` light up for a
15
+ * keyboard reader exactly as they do for a pointer — without
16
+ * `useChartInteraction` growing a third state axis to keep in sync.
17
+ */
18
+ interface ChartCategoryTargetsProps {
19
+ targets: ChartCategoryTarget[];
20
+ interaction: ChartInteraction;
21
+ /** Accessible name for one category. Derived from the tooltip, so they agree. */
22
+ getAriaLabel: (index: number) => string;
23
+ /** Arrow keys wrap at the ends. True for radial charts — a circle has no ends. */
24
+ wrap?: boolean;
25
+ }
26
+ declare const ChartCategoryTargets: ({ targets, interaction, getAriaLabel, wrap, }: ChartCategoryTargetsProps) => import("react").JSX.Element;
27
+ export default ChartCategoryTargets;
@@ -0,0 +1,75 @@
1
+ import { ChartTargetShape } from "./types.js";
2
+ import { clamp } from "./chartScales.js";
3
+ import { ChartCategoryTargetLayer, ChartHitTarget, ChartHitTargetPath } from "./ChartPrimitives.js";
4
+ import { match } from "ts-pattern";
5
+ import { useCallback, useRef, useState } from "react";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/charts/ChartCategoryTargets.tsx
8
+ var ChartCategoryTargets = ({ targets, interaction, getAriaLabel, wrap = false }) => {
9
+ const [focusIndex, setFocusIndex] = useState(0);
10
+ const nodeRefs = useRef([]);
11
+ /**
12
+ * Roving tabindex, preferring whatever is pinned.
13
+ *
14
+ * Tabbing away and back returns to the category the reader was holding rather
15
+ * than to the start, which is the difference between the pin being a place
16
+ * you are and a thing that merely happened.
17
+ */
18
+ const rovingIndex = clamp(interaction.pinnedIndex ?? focusIndex, 0, targets.length - 1);
19
+ const moveTo = useCallback((next) => {
20
+ const last = targets.length - 1;
21
+ const bounded = wrap ? (next % targets.length + targets.length) % targets.length : clamp(next, 0, last);
22
+ setFocusIndex(bounded);
23
+ nodeRefs.current[bounded]?.focus();
24
+ }, [targets.length, wrap]);
25
+ const onKeyDown = useCallback((event, index) => {
26
+ if (match(event.key).with("ArrowRight", "ArrowDown", () => moveTo(index + 1)).with("ArrowLeft", "ArrowUp", () => moveTo(index - 1)).with("Home", () => moveTo(0)).with("End", () => moveTo(targets.length - 1)).with("Enter", " ", () => interaction.onCategoryClick(index)).with("Escape", () => interaction.onBackdropClick()).otherwise(() => void 0) !== void 0 || [
27
+ "ArrowRight",
28
+ "ArrowDown",
29
+ "ArrowLeft",
30
+ "ArrowUp",
31
+ "Home",
32
+ "End",
33
+ "Enter",
34
+ " ",
35
+ "Escape"
36
+ ].includes(event.key)) event.preventDefault();
37
+ }, [
38
+ moveTo,
39
+ interaction,
40
+ targets.length
41
+ ]);
42
+ return /* @__PURE__ */ jsx(ChartCategoryTargetLayer, { children: targets.map((target, index) => {
43
+ const shared = {
44
+ tabIndex: index === rovingIndex ? 0 : -1,
45
+ role: "button",
46
+ "aria-label": getAriaLabel(index),
47
+ "aria-pressed": interaction.pinnedIndex === index,
48
+ "data-interactive": "true",
49
+ onFocus: () => interaction.onCategoryEnter(index),
50
+ onBlur: () => interaction.onCategoryLeave(),
51
+ onMouseEnter: () => interaction.onCategoryEnter(index),
52
+ onMouseLeave: () => interaction.onCategoryLeave(),
53
+ onClick: () => interaction.onCategoryClick(index),
54
+ onKeyDown: (event) => onKeyDown(event, index)
55
+ };
56
+ return match(target).with({ shape: ChartTargetShape.RECT }, (rect) => /* @__PURE__ */ jsx(ChartHitTarget, {
57
+ ref: (node) => {
58
+ nodeRefs.current[index] = node;
59
+ },
60
+ x: rect.x,
61
+ y: rect.y,
62
+ width: rect.width,
63
+ height: rect.height,
64
+ ...shared
65
+ }, rect.key)).with({ shape: ChartTargetShape.PATH }, (path) => /* @__PURE__ */ jsx(ChartHitTargetPath, {
66
+ ref: (node) => {
67
+ nodeRefs.current[index] = node;
68
+ },
69
+ d: path.d,
70
+ ...shared
71
+ }, path.key)).exhaustive();
72
+ }) });
73
+ };
74
+ //#endregion
75
+ export { ChartCategoryTargets as default };
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
- import type { ChartLegendItem, ChartPlotTooltipModel, ChartTooltipRenderer } from "./types";
2
+ import { ChartKind } from "./types";
3
+ import type { ChartCategoryTarget, ChartLegendItem, ChartPlotTooltipModel, ChartTooltipRenderer } from "./types";
3
4
  import type { ChartDimensions } from "./useChartDimensions";
4
5
  import type { ChartInteraction } from "./useChartInteraction";
5
6
  import type { ChartTooltipVerticalAlign } from "./useChartTooltipPosition";
@@ -40,8 +41,13 @@ export interface ChartFrameProps {
40
41
  fillWidth?: boolean;
41
42
  fillHeight?: boolean;
42
43
  interaction: ChartInteraction;
43
- /** Already resolved pass `ariaLabel ?? <chart-specific default>`. */
44
- ariaLabel: string;
44
+ /** Which chart this is. Only used to phrase the default accessible name. */
45
+ kind: ChartKind;
46
+ /** Categories on the axis, or slices for a pie. Names the plot, so it counts
47
+ * what is *rendered* rather than what was supplied. */
48
+ categoryCount: number;
49
+ /** Overrides the name derived from {@link kind} and the counts. */
50
+ ariaLabel?: string;
45
51
  /** Long description, associated with the SVG via `aria-describedby`. */
46
52
  ariaDescription?: string;
47
53
  /**
@@ -61,9 +67,31 @@ export interface ChartFrameProps {
61
67
  /** HTML painted over the plot and under the tooltip — the donut centre. */
62
68
  plotOverlay?: ReactNode;
63
69
  noTooltip?: boolean;
64
- /** Anchor in plot coordinates; the frame applies the margin translate. */
65
- tooltipModel: ChartPlotTooltipModel | null;
70
+ /**
71
+ * Builds one category's tooltip, in plot coordinates.
72
+ *
73
+ * Taken as a function rather than a finished model because the frame needs it
74
+ * twice: once for the category under the pointer, and once per category to
75
+ * name it for a screen reader. Deriving both from the same builder is what
76
+ * makes the accessible name incapable of disagreeing with the tooltip.
77
+ *
78
+ * Must be stable; memoize it at the call site.
79
+ */
80
+ buildCategoryTooltip: (index: number) => ChartPlotTooltipModel | null;
66
81
  tooltipRenderer?: ChartTooltipRenderer;
82
+ /**
83
+ * One target per category, in plot coordinates and in category order.
84
+ *
85
+ * Empty disables interaction entirely, which is what a chart with nothing to
86
+ * plot passes.
87
+ */
88
+ categoryTargets?: ChartCategoryTarget[];
89
+ /** Applied to the target layer. Radial charts pass their centre translate. */
90
+ categoryTargetsTransform?: string;
91
+ /** Arrow keys wrap at the ends. True for radial charts. */
92
+ wrapCategoryNavigation?: boolean;
93
+ /** Overrides the accessible name of a category, which defaults to its tooltip. */
94
+ describeCategory?: (index: number) => string;
67
95
  /** Half a category's width, so the tooltip sits beside the marks. */
68
96
  anchorHalfWidth?: number;
69
97
  /** Radial charts pass their centre, in plot coordinates. */
@@ -78,5 +106,5 @@ export interface ChartFrameProps {
78
106
  /** Defaults to the left margin, aligning the legend with the plot, not the gutter. */
79
107
  legendInset?: number;
80
108
  }
81
- declare const ChartFrame: ({ containerRef, dimensions, fillWidth, fillHeight, interaction, ariaLabel, ariaDescription, axes, children, plotOverlay, noTooltip, tooltipModel, tooltipRenderer, anchorHalfWidth, placeAwayFromX, verticalAlign, isEmpty, isLoading, contentWhenEmpty, showLegend, legendItems, legendInset, }: ChartFrameProps) => import("react").JSX.Element;
109
+ declare const ChartFrame: ({ containerRef, dimensions, fillWidth, fillHeight, interaction, kind, categoryCount, ariaLabel, ariaDescription, axes, children, plotOverlay, noTooltip, buildCategoryTooltip, tooltipRenderer, categoryTargets, categoryTargetsTransform, wrapCategoryNavigation, describeCategory, anchorHalfWidth, placeAwayFromX, verticalAlign, isEmpty, isLoading, contentWhenEmpty, showLegend, legendItems, legendInset, }: ChartFrameProps) => import("react").JSX.Element;
82
110
  export default ChartFrame;
@@ -1,15 +1,34 @@
1
+ import { ChartKind } from "./types.js";
2
+ import { describeTooltipModel } from "./chartFormat.js";
1
3
  import { ChartBackdrop, ChartPlotBox, ChartPlotGroup, ChartRoot, ChartSvg } from "./ChartPrimitives.js";
4
+ import ChartCategoryTargets from "./ChartCategoryTargets.js";
2
5
  import ChartEmptyState from "./ChartEmptyState.js";
3
6
  import ChartLegend from "./ChartLegend.js";
4
- import ChartHoverTooltip from "./ChartTooltip.js";
5
- import { useId, useMemo } from "react";
7
+ import ChartTooltip from "./ChartTooltip.js";
8
+ import { match } from "ts-pattern";
9
+ import { useCallback, useId, useMemo } from "react";
6
10
  import { jsx, jsxs } from "react/jsx-runtime";
7
11
  //#region src/charts/ChartFrame.tsx
8
- var ChartFrame = ({ containerRef, dimensions, fillWidth, fillHeight, interaction, ariaLabel, ariaDescription, axes, children, plotOverlay, noTooltip = false, tooltipModel, tooltipRenderer, anchorHalfWidth, placeAwayFromX, verticalAlign, isEmpty, isLoading = false, contentWhenEmpty, showLegend = false, legendItems, legendInset }) => {
12
+ var ChartFrame = ({ containerRef, dimensions, fillWidth, fillHeight, interaction, kind, categoryCount, ariaLabel, ariaDescription, axes, children, plotOverlay, noTooltip = false, buildCategoryTooltip, tooltipRenderer, categoryTargets, categoryTargetsTransform, wrapCategoryNavigation, describeCategory, anchorHalfWidth, placeAwayFromX, verticalAlign, isEmpty, isLoading = false, contentWhenEmpty, showLegend = false, legendItems, legendInset }) => {
9
13
  const { width, height, plotWidth, plotHeight, margin, isMeasured, reservedHeight, reservedAspectRatio } = dimensions;
10
14
  const generatedId = useId();
11
15
  const descriptionId = ariaDescription ? `${generatedId}-desc` : void 0;
12
16
  /**
17
+ * Default accessible name, phrased here so the three charts cannot drift.
18
+ *
19
+ * Both counts describe what is *on screen*: `legendItems` rather than the
20
+ * `series` record, because a stacked bar's legend names its components, not
21
+ * its metrics — counting the record would announce "1 series" for a chart
22
+ * showing three. The two charts previously counted different things for the
23
+ * same situation, which is the drift this removes.
24
+ */
25
+ const resolvedAriaLabel = ariaLabel ?? match(kind).with(ChartKind.PIE, () => `Pie chart with ${categoryCount} slices`).with(ChartKind.BAR, () => `Bar chart with ${categoryCount} categories and ${legendItems.length} series`).with(ChartKind.LINE, () => `Line chart with ${categoryCount} categories and ${legendItems.length} series`).exhaustive();
26
+ const tooltipModel = useMemo(() => noTooltip || interaction.activeIndex === null ? null : buildCategoryTooltip(interaction.activeIndex), [
27
+ noTooltip,
28
+ buildCategoryTooltip,
29
+ interaction.activeIndex
30
+ ]);
31
+ /**
13
32
  * Plot space → container space, applied once, here.
14
33
  *
15
34
  * Memoized for correctness, not arithmetic cost. `ChartTooltip` retains the
@@ -28,6 +47,29 @@ var ChartFrame = ({ containerRef, dimensions, fillWidth, fillHeight, interaction
28
47
  margin.left,
29
48
  margin.top
30
49
  ]);
50
+ /**
51
+ * Name each category from its own tooltip, unless the chart says otherwise.
52
+ *
53
+ * Same builder as the visible tooltip, so the two cannot describe the chart
54
+ * differently.
55
+ */
56
+ const nameCategory = useCallback((index) => {
57
+ if (describeCategory) return describeCategory(index);
58
+ const model = buildCategoryTooltip(index);
59
+ return model ? describeTooltipModel(model) : "";
60
+ }, [describeCategory, buildCategoryTooltip]);
61
+ /**
62
+ * Whether the marks are reachable at all.
63
+ *
64
+ * Drives the SVG's role, which is the real cost of keyboard access:
65
+ * `role="img"` prunes the whole subtree from the accessibility tree, so
66
+ * nothing inside it can ever be focused *and* announced. A chart with live
67
+ * targets therefore has to be a `group` — and everything that is not a target
68
+ * has to be explicitly hidden, since under `group` it would otherwise become
69
+ * traversable. When there is nothing to reach, `img` stays: it announces one
70
+ * summary rather than an empty group.
71
+ */
72
+ const hasFocusableTargets = isMeasured && !noTooltip && !isLoading && !isEmpty && (categoryTargets?.length ?? 0) > 0;
31
73
  return /* @__PURE__ */ jsxs(ChartRoot, {
32
74
  $fillWidth: fillWidth,
33
75
  $fillHeight: fillHeight,
@@ -40,27 +82,42 @@ var ChartFrame = ({ containerRef, dimensions, fillWidth, fillHeight, interaction
40
82
  isMeasured && /* @__PURE__ */ jsxs(ChartSvg, {
41
83
  width,
42
84
  height,
43
- role: "img",
44
- "aria-label": ariaLabel,
85
+ role: hasFocusableTargets ? "group" : "img",
86
+ "aria-label": resolvedAriaLabel,
45
87
  "aria-describedby": descriptionId,
46
88
  children: [ariaDescription && /* @__PURE__ */ jsx("desc", {
47
89
  id: descriptionId,
48
90
  children: ariaDescription
49
91
  }), /* @__PURE__ */ jsxs("g", {
50
92
  transform: `translate(${margin.left}, ${margin.top})`,
51
- children: [axes, /* @__PURE__ */ jsxs(ChartPlotGroup, {
52
- "data-active": interaction.isEngaged ? "true" : "false",
53
- children: [/* @__PURE__ */ jsx(ChartBackdrop, {
54
- width: plotWidth,
55
- height: plotHeight,
56
- "data-interactive": interaction.pinnedIndex !== null ? "true" : "false",
57
- onClick: interaction.onBackdropClick
58
- }), children]
59
- })]
93
+ children: [
94
+ axes,
95
+ /* @__PURE__ */ jsx("g", {
96
+ "aria-hidden": "true",
97
+ children: /* @__PURE__ */ jsxs(ChartPlotGroup, {
98
+ "data-active": interaction.isEngaged ? "true" : "false",
99
+ children: [/* @__PURE__ */ jsx(ChartBackdrop, {
100
+ width: plotWidth,
101
+ height: plotHeight,
102
+ "data-interactive": interaction.pinnedIndex !== null ? "true" : "false",
103
+ onClick: interaction.onBackdropClick
104
+ }), children]
105
+ })
106
+ }),
107
+ hasFocusableTargets && categoryTargets && /* @__PURE__ */ jsx("g", {
108
+ transform: categoryTargetsTransform,
109
+ children: /* @__PURE__ */ jsx(ChartCategoryTargets, {
110
+ targets: categoryTargets,
111
+ interaction,
112
+ getAriaLabel: nameCategory,
113
+ wrap: wrapCategoryNavigation
114
+ })
115
+ })
116
+ ]
60
117
  })]
61
118
  }),
62
119
  isMeasured && plotOverlay,
63
- isMeasured && !noTooltip && /* @__PURE__ */ jsx(ChartHoverTooltip, {
120
+ isMeasured && !noTooltip && /* @__PURE__ */ jsx(ChartTooltip, {
64
121
  model: positionedTooltip,
65
122
  containerWidth: width,
66
123
  containerHeight: height,
@@ -45,6 +45,32 @@ export declare const ChartLinePath: import("@linaria/react").StyledComponent<imp
45
45
  export declare const ChartAreaPath: import("@linaria/react").StyledComponent<import("react").SVGProps<SVGPathElement> & Record<never, unknown>>;
46
46
  export declare const ChartPointCircle: import("@linaria/react").StyledComponent<import("react").SVGProps<SVGCircleElement> & Record<never, unknown>>;
47
47
  export declare const ChartArcPath: import("@linaria/react").StyledComponent<import("react").SVGProps<SVGPathElement> & Record<never, unknown>>;
48
+ /**
49
+ * Wrapper for the category target layer.
50
+ *
51
+ * Exists to carry the focus-ring colour: the targets themselves are plain
52
+ * `styled.x` because they receive refs (see the module note above), so they
53
+ * cannot read the theme directly. Setting `color` here lets them use
54
+ * `currentColor` and still take the token rather than inheriting whatever the
55
+ * consumer's page happens to set.
56
+ */
57
+ export declare const ChartCategoryTargetLayer: import("react").ComponentType<Pick<import("react").SVGProps<SVGGElement> & {
58
+ theme: import("../theme").Theme;
59
+ } & {
60
+ as?: React.ElementType;
61
+ }, "as" | keyof import("react").SVGProps<SVGGElement>> & {
62
+ theme?: import("@callstack/react-theme-provider").$DeepPartial<import("../theme").Theme> | undefined;
63
+ }> & import("@callstack/react-theme-provider/typings/hoist-non-react-statics").NonReactStatics<import("react").ComponentType<import("react").SVGProps<SVGGElement> & {
64
+ theme: import("../theme").Theme;
65
+ } & {
66
+ as?: React.ElementType;
67
+ }> & {
68
+ __wyw_meta: unknown;
69
+ } & import("react").FunctionComponent<import("react").SVGProps<SVGGElement> & {
70
+ theme: import("../theme").Theme;
71
+ } & {
72
+ as?: React.ElementType;
73
+ }>, {}>;
48
74
  /**
49
75
  * Per-category hit target, rendered above the marks.
50
76
  *
@@ -54,6 +80,15 @@ export declare const ChartArcPath: import("@linaria/react").StyledComponent<impo
54
80
  * still instead of chasing each segment.
55
81
  */
56
82
  export declare const ChartHitTarget: import("@linaria/react").StyledComponent<import("react").SVGProps<SVGRectElement> & Record<never, unknown>>;
83
+ /**
84
+ * The radial equivalent, reusing the arc's own `d`.
85
+ *
86
+ * A pie has no category axis to band, so its targets are the arc shapes
87
+ * themselves — drawn again, transparent, above the marks. That keeps hit
88
+ * testing and focus identical across all three charts instead of the pie
89
+ * hit-testing its visible arcs directly.
90
+ */
91
+ export declare const ChartHitTargetPath: import("@linaria/react").StyledComponent<import("react").SVGProps<SVGPathElement> & Record<never, unknown>>;
57
92
  /** Full-plot backdrop; clicking it clears a pinned category. */
58
93
  export declare const ChartBackdrop: import("@linaria/react").StyledComponent<import("react").SVGProps<SVGRectElement> & Record<never, unknown>>;
59
94
  /**