@optilogic/charts 1.3.10 → 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.
@@ -1,6 +1,12 @@
1
1
  import * as React from "react";
2
2
  import { ResponsiveContainer } from "recharts";
3
- import { cn } from "@optilogic/core";
3
+ import { cn, VisuallyHidden } from "@optilogic/core";
4
+
5
+ /** Column descriptor for the screen-reader data-table fallback. */
6
+ export interface ChartTableColumn {
7
+ key: string;
8
+ label: string;
9
+ }
4
10
 
5
11
  export interface ChartContainerProps {
6
12
  children: React.ReactNode;
@@ -11,6 +17,42 @@ export interface ChartContainerProps {
11
17
  emptyMessage?: string;
12
18
  title?: string;
13
19
  description?: string;
20
+ /**
21
+ * Accessible name for the visual chart region (SC 1.1.1). Falls back to
22
+ * `title` when omitted.
23
+ */
24
+ ariaLabel?: string;
25
+ /**
26
+ * Accessible description for the chart. Falls back to `description` when
27
+ * omitted. Exposed to assistive tech via `aria-describedby`.
28
+ */
29
+ ariaDescription?: string;
30
+ /**
31
+ * Row data rendered as a visually-hidden `<table>` so screen-reader users can
32
+ * access the underlying numbers (SC 1.1.1, 1.3.1). Requires `tableColumns`.
33
+ * Accepts any object row; cells are read by `tableColumns[].key`.
34
+ */
35
+ tableData?: ReadonlyArray<object>;
36
+ /** Column definitions for the data-table fallback. Requires `tableData`. */
37
+ tableColumns?: Array<ChartTableColumn>;
38
+ /**
39
+ * Makes each data point actionable (a "visualization link"). When provided, a
40
+ * keyboard- and screen-reader-reachable list of buttons is rendered — one per
41
+ * `tableData` row — that is visually hidden until focused (skip-link pattern),
42
+ * so pointer users click the chart while keyboard users tab to the same
43
+ * actions without an invisible-focus trap (WCAG SC 2.1.1, 2.4.7).
44
+ */
45
+ onRowActivate?: (row: object, index: number) => void;
46
+ /** Accessible label for a row's action. Defaults to the first column's value. */
47
+ rowActionLabel?: (row: object, index: number) => string;
48
+ }
49
+
50
+ function formatCell(value: unknown): string {
51
+ if (value == null) return "";
52
+ if (typeof value === "number") return value.toLocaleString();
53
+ if (value instanceof Date) return value.toLocaleString();
54
+ if (typeof value === "object") return JSON.stringify(value);
55
+ return String(value);
14
56
  }
15
57
 
16
58
  export const ChartContainer = React.forwardRef<
@@ -26,9 +68,27 @@ export const ChartContainer = React.forwardRef<
26
68
  emptyMessage = "No data available",
27
69
  title,
28
70
  description,
71
+ ariaLabel,
72
+ ariaDescription,
73
+ tableData,
74
+ tableColumns,
75
+ onRowActivate,
76
+ rowActionLabel,
29
77
  },
30
78
  ref,
31
79
  ) {
80
+ const descriptionId = React.useId();
81
+ const accessibleName = ariaLabel ?? title;
82
+ const accessibleDescription = ariaDescription ?? description;
83
+
84
+ const hasTable =
85
+ !loading &&
86
+ !empty &&
87
+ !!tableData &&
88
+ tableData.length > 0 &&
89
+ !!tableColumns &&
90
+ tableColumns.length > 0;
91
+
32
92
  return (
33
93
  <div ref={ref} className={cn("w-full", className)} style={{ height }}>
34
94
  {(title || description) && (
@@ -92,12 +152,79 @@ export const ChartContainer = React.forwardRef<
92
152
  {emptyMessage}
93
153
  </div>
94
154
  ) : (
95
- <ResponsiveContainer
96
- width="100%"
97
- height={title || description ? "calc(100% - 40px)" : "100%"}
155
+ <div
156
+ role="img"
157
+ aria-label={accessibleName}
158
+ aria-describedby={
159
+ accessibleDescription ? descriptionId : undefined
160
+ }
161
+ className="relative"
162
+ style={{
163
+ width: "100%",
164
+ height: title || description ? "calc(100% - 40px)" : "100%",
165
+ }}
98
166
  >
99
- {children as React.ReactElement}
100
- </ResponsiveContainer>
167
+ {accessibleDescription && (
168
+ <VisuallyHidden as="span" id={descriptionId}>
169
+ {accessibleDescription}
170
+ </VisuallyHidden>
171
+ )}
172
+ <ResponsiveContainer width="100%" height="100%">
173
+ {children as React.ReactElement}
174
+ </ResponsiveContainer>
175
+ {hasTable && (
176
+ <VisuallyHidden as="div">
177
+ <table>
178
+ {accessibleName && <caption>{accessibleName}</caption>}
179
+ <thead>
180
+ <tr>
181
+ {tableColumns.map((col) => (
182
+ <th key={col.key} scope="col">
183
+ {col.label}
184
+ </th>
185
+ ))}
186
+ </tr>
187
+ </thead>
188
+ <tbody>
189
+ {tableData.map((row, rowIndex) => {
190
+ const record = row as Record<string, unknown>;
191
+ return (
192
+ <tr key={rowIndex}>
193
+ {tableColumns.map((col) => (
194
+ <td key={col.key}>{formatCell(record[col.key])}</td>
195
+ ))}
196
+ </tr>
197
+ );
198
+ })}
199
+ </tbody>
200
+ </table>
201
+ </VisuallyHidden>
202
+ )}
203
+ {hasTable && onRowActivate && (
204
+ <nav
205
+ aria-label={`${accessibleName ?? "Chart"} data point links`}
206
+ className="sr-only focus-within:not-sr-only focus-within:absolute focus-within:z-20 focus-within:top-1 focus-within:left-1 focus-within:flex focus-within:max-h-[calc(100%-0.5rem)] focus-within:flex-col focus-within:gap-1 focus-within:overflow-auto focus-within:rounded-md focus-within:border focus-within:border-border focus-within:bg-popover focus-within:p-2 focus-within:shadow-md"
207
+ >
208
+ {tableData.map((row, rowIndex) => {
209
+ const record = row as Record<string, unknown>;
210
+ const firstKey = tableColumns[0]?.key;
211
+ const label =
212
+ rowActionLabel?.(row, rowIndex) ??
213
+ (firstKey ? formatCell(record[firstKey]) : `Row ${rowIndex + 1}`);
214
+ return (
215
+ <button
216
+ key={rowIndex}
217
+ type="button"
218
+ onClick={() => onRowActivate(row, rowIndex)}
219
+ className="rounded px-1 py-0.5 text-left text-sm text-foreground underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
220
+ >
221
+ {label}
222
+ </button>
223
+ );
224
+ })}
225
+ </nav>
226
+ )}
227
+ </div>
101
228
  )}
102
229
  </div>
103
230
  );
@@ -1,3 +1,23 @@
1
+ import type { CellValue } from "@optilogic/core";
2
+
3
+ /**
4
+ * Narrow an untyped datum field (chart data is `Record<string, unknown>`) to a
5
+ * {@link CellValue} at the boundary, without casting. Non-primitive values that
6
+ * aren't `Date` become `null`.
7
+ */
8
+ export function toCellValue(value: unknown): CellValue {
9
+ if (value == null) return null;
10
+ if (
11
+ typeof value === "number" ||
12
+ typeof value === "string" ||
13
+ typeof value === "boolean"
14
+ ) {
15
+ return value;
16
+ }
17
+ if (value instanceof Date) return value;
18
+ return null;
19
+ }
20
+
1
21
  const compactThresholds: [number, string][] = [
2
22
  [1_000_000_000, "B"],
3
23
  [1_000_000, "M"],
@@ -49,6 +49,12 @@ const FunnelChart = React.forwardRef<HTMLDivElement, FunnelChartProps>(
49
49
  height={height}
50
50
  loading={loading}
51
51
  empty={!data?.length}
52
+ ariaLabel="Funnel chart"
53
+ tableData={data}
54
+ tableColumns={[
55
+ { key: "name", label: "Stage" },
56
+ { key: "value", label: "Value" },
57
+ ]}
52
58
  >
53
59
  <RechartsFunnelChart margin={margin}>
54
60
  <Funnel
@@ -265,9 +265,18 @@ const GanttChart = React.forwardRef<HTMLDivElement, GanttChartProps>(
265
265
  const isHovered = hoveredId === task.id;
266
266
  const progress = task.progress ?? 0;
267
267
 
268
+ const taskLabel = `${task.name}, ${task.start.toLocaleDateString()} to ${task.end.toLocaleDateString()}${
269
+ task.progress != null
270
+ ? `, ${Math.round((task.progress ?? 0) * 100)}% complete`
271
+ : ""
272
+ }`;
273
+
268
274
  return (
269
275
  <g
270
276
  key={task.id}
277
+ tabIndex={0}
278
+ role={onTaskClick ? "button" : "img"}
279
+ aria-label={taskLabel}
271
280
  onMouseEnter={(e) => {
272
281
  setHoveredId(task.id);
273
282
  const rect = containerRef.current?.getBoundingClientRect();
@@ -281,7 +290,23 @@ const GanttChart = React.forwardRef<HTMLDivElement, GanttChartProps>(
281
290
  setHoveredId(null);
282
291
  setTooltipInfo(null);
283
292
  }}
293
+ // Keyboard/focus parity with pointer interaction (SC 2.1.1).
294
+ onFocus={() => {
295
+ setHoveredId(task.id);
296
+ setTooltipInfo({ task, x: barX, y: barY });
297
+ }}
298
+ onBlur={() => {
299
+ setHoveredId(null);
300
+ setTooltipInfo(null);
301
+ }}
302
+ onKeyDown={(e) => {
303
+ if (onTaskClick && (e.key === "Enter" || e.key === " ")) {
304
+ e.preventDefault();
305
+ onTaskClick(task);
306
+ }
307
+ }}
284
308
  onClick={() => onTaskClick?.(task)}
309
+ className="focus:outline-none focus-visible:[outline:2px_solid_hsl(var(--ring))]"
285
310
  style={{ cursor: onTaskClick ? "pointer" : "default" }}
286
311
  >
287
312
  {/* Row stripe */}
@@ -64,6 +64,7 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
64
64
  ) {
65
65
  const containerRef = React.useRef<HTMLDivElement>(null);
66
66
  const [hoveredCell, setHoveredCell] = React.useState<HeatmapCell | null>(null);
67
+ const [focusedKey, setFocusedKey] = React.useState<string | null>(null);
67
68
  const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 });
68
69
 
69
70
  const { valueMap, minVal, maxVal } = React.useMemo(() => {
@@ -150,10 +151,21 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
150
151
  const cx = labelColWidth + xi * cellSize;
151
152
  const cy = 28 + yi * cellSize;
152
153
  const cellData: HeatmapCell = { x: xLabel, y: yLabel, value: val ?? 0 };
154
+ const isInteractive = !!onCellClick;
155
+ const valueText =
156
+ val != null
157
+ ? valueFormatter
158
+ ? valueFormatter(val)
159
+ : val.toLocaleString()
160
+ : "no value";
161
+ const cellLabel = `${yLabel}, ${xLabel}: ${valueText}`;
153
162
 
154
163
  return (
155
164
  <g
156
165
  key={key}
166
+ role={isInteractive ? "button" : "img"}
167
+ tabIndex={0}
168
+ aria-label={cellLabel}
157
169
  onMouseEnter={(e) => {
158
170
  setHoveredCell(cellData);
159
171
  const rect = containerRef.current?.getBoundingClientRect();
@@ -163,8 +175,29 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
163
175
  });
164
176
  }}
165
177
  onMouseLeave={() => setHoveredCell(null)}
178
+ onFocus={() => {
179
+ setHoveredCell(cellData);
180
+ setFocusedKey(key);
181
+ setTooltipPos({
182
+ x: cx + cellSize / 2,
183
+ y: cy,
184
+ });
185
+ }}
186
+ onBlur={() => {
187
+ setHoveredCell(null);
188
+ setFocusedKey(null);
189
+ }}
166
190
  onClick={() => onCellClick?.(cellData)}
167
- style={{ cursor: onCellClick ? "pointer" : "default" }}
191
+ onKeyDown={(e) => {
192
+ if (
193
+ isInteractive &&
194
+ (e.key === "Enter" || e.key === " ")
195
+ ) {
196
+ e.preventDefault();
197
+ onCellClick?.(cellData);
198
+ }
199
+ }}
200
+ style={{ cursor: onCellClick ? "pointer" : "default", outline: "none" }}
168
201
  >
169
202
  <rect
170
203
  x={cx + 1}
@@ -179,11 +212,13 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
179
212
  }
180
213
  fillOpacity={val != null ? 0.85 : 0.3}
181
214
  stroke={
182
- hoveredCell?.x === xLabel && hoveredCell?.y === yLabel
183
- ? "hsl(var(--foreground))"
184
- : "none"
215
+ focusedKey === key
216
+ ? "hsl(var(--ring))"
217
+ : hoveredCell?.x === xLabel && hoveredCell?.y === yLabel
218
+ ? "hsl(var(--foreground))"
219
+ : "none"
185
220
  }
186
- strokeWidth={1.5}
221
+ strokeWidth={focusedKey === key ? 2.5 : 1.5}
187
222
  />
188
223
  {showValues && val != null && cellSize >= 32 && (
189
224
  <text
@@ -128,6 +128,16 @@ const SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(
128
128
  ) => {
129
129
  const tooltipProps = resolveTooltipProps(tooltip);
130
130
 
131
+ const tableData = React.useMemo(
132
+ () =>
133
+ (data?.links ?? []).map((link) => ({
134
+ source: data.nodes[link.source]?.name ?? String(link.source),
135
+ target: data.nodes[link.target]?.name ?? String(link.target),
136
+ value: link.value,
137
+ })),
138
+ [data],
139
+ );
140
+
131
141
  return (
132
142
  <ChartContainer
133
143
  ref={ref}
@@ -135,6 +145,13 @@ const SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(
135
145
  height={height}
136
146
  loading={loading}
137
147
  empty={!data?.nodes?.length}
148
+ ariaLabel="Sankey diagram"
149
+ tableData={tableData}
150
+ tableColumns={[
151
+ { key: "source", label: "Source" },
152
+ { key: "target", label: "Target" },
153
+ { key: "value", label: "Value" },
154
+ ]}
138
155
  >
139
156
  <Sankey
140
157
  data={data}
@@ -20,6 +20,27 @@ export interface TreemapChartProps extends BaseChartProps {
20
20
  loading?: boolean;
21
21
  }
22
22
 
23
+ /**
24
+ * Flatten a treemap tree into leaf rows for the screen-reader data table.
25
+ * Uses each leaf's own name/value so the numbers are exposed to assistive tech.
26
+ */
27
+ function flattenTreemap(
28
+ nodes: TreemapDatum[],
29
+ ): Array<Record<string, unknown>> {
30
+ const rows: Array<Record<string, unknown>> = [];
31
+ const walk = (items: TreemapDatum[]) => {
32
+ for (const item of items) {
33
+ if (item.children && item.children.length > 0) {
34
+ walk(item.children);
35
+ } else {
36
+ rows.push({ name: item.name, value: item.value ?? null });
37
+ }
38
+ }
39
+ };
40
+ walk(nodes);
41
+ return rows;
42
+ }
43
+
23
44
  interface TreemapContentProps {
24
45
  x: number;
25
46
  y: number;
@@ -46,6 +67,8 @@ function TreemapContent({
46
67
 
47
68
  return (
48
69
  <g>
70
+ {/* Full, untruncated label exposed to assistive tech (SC 1.1.1). */}
71
+ {name && <title>{name}</title>}
49
72
  <rect
50
73
  x={x}
51
74
  y={y}
@@ -86,7 +109,6 @@ const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
86
109
  live = false,
87
110
  className,
88
111
  height = "100%",
89
- margin,
90
112
  loading,
91
113
  },
92
114
  ref,
@@ -94,6 +116,8 @@ const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
94
116
  const tooltipProps = resolveTooltipProps(tooltip);
95
117
  const isAnimated = live ? false : animate;
96
118
 
119
+ const tableData = React.useMemo(() => flattenTreemap(data), [data]);
120
+
97
121
  return (
98
122
  <ChartContainer
99
123
  ref={ref}
@@ -101,6 +125,12 @@ const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
101
125
  height={height}
102
126
  loading={loading}
103
127
  empty={!data?.length}
128
+ ariaLabel="Treemap chart"
129
+ tableData={tableData}
130
+ tableColumns={[
131
+ { key: "name", label: "Name" },
132
+ { key: "value", label: "Value" },
133
+ ]}
104
134
  >
105
135
  <Treemap
106
136
  data={data}