diff --git a/package-lock.json b/package-lock.json
index 02c70fcd..c30b4e2f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "maidr",
- "version": "3.50.0",
+ "version": "3.51.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maidr",
- "version": "3.50.0",
+ "version": "3.51.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
@@ -60,7 +60,8 @@
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
- "react-dom": "^18.0.0 || ^19.0.0"
+ "react-dom": "^18.0.0 || ^19.0.0",
+ "victory": "^37.0.0"
},
"peerDependenciesMeta": {
"react": {
@@ -68,6 +69,9 @@
},
"react-dom": {
"optional": true
+ },
+ "victory": {
+ "optional": true
}
}
},
diff --git a/package.json b/package.json
index ceb2b908..7cc2f4c1 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,11 @@
"types": "./dist/react.d.mts",
"import": "./dist/react.mjs",
"default": "./dist/react.mjs"
+ },
+ "./victory": {
+ "types": "./dist/victory.d.mts",
+ "import": "./dist/victory.mjs",
+ "default": "./dist/victory.mjs"
}
},
"main": "dist/maidr.js",
@@ -20,9 +25,10 @@
"dist"
],
"scripts": {
- "build": "vite build && vite build --config vite.react.config.ts",
+ "build": "vite build && vite build --config vite.react.config.ts && vite build --config vite.victory.config.ts",
"build:script": "vite build",
"build:react": "vite build --config vite.react.config.ts",
+ "build:victory": "vite build --config vite.victory.config.ts",
"prepublishOnly": "npm run build",
"prepare": "husky",
"commitlint": "commitlint --from=HEAD~1 --to=HEAD",
@@ -42,7 +48,8 @@
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
- "react-dom": "^18.0.0 || ^19.0.0"
+ "react-dom": "^18.0.0 || ^19.0.0",
+ "victory": "^37.0.0"
},
"peerDependenciesMeta": {
"react": {
@@ -50,6 +57,9 @@
},
"react-dom": {
"optional": true
+ },
+ "victory": {
+ "optional": true
}
},
"dependencies": {
diff --git a/src/victory/MaidrVictory.tsx b/src/victory/MaidrVictory.tsx
new file mode 100644
index 00000000..65309296
--- /dev/null
+++ b/src/victory/MaidrVictory.tsx
@@ -0,0 +1,159 @@
+import type { Maidr as MaidrData, MaidrLayer } from '@type/grammar';
+import type { JSX } from 'react';
+import type { MaidrVictoryProps, VictoryLayerInfo } from './types';
+import { useLayoutEffect, useRef, useState } from 'react';
+import { Maidr } from '../maidr-component';
+import { extractVictoryLayers, toMaidrLayer } from './adapter';
+import { clearTaggedElements, tagLayerElements } from './selectors';
+
+/**
+ * Collects all legend labels across layers that define them.
+ */
+function collectLegend(layers: VictoryLayerInfo[]): string[] | undefined {
+ const allLabels: string[] = [];
+ for (const layer of layers) {
+ if (layer.legend)
+ allLabels.push(...layer.legend);
+ }
+ return allLabels.length > 0 ? allLabels : undefined;
+}
+
+/**
+ * Produces a stable, serialisable fingerprint from extracted Victory layers.
+ *
+ * React `children` creates a new object reference on every render, which
+ * makes it an unstable dependency for hooks. Instead we compare the
+ * JSON-serialisable layer data: if the actual chart data hasn't changed
+ * the effect can skip DOM re-tagging.
+ */
+function layerFingerprint(layers: VictoryLayerInfo[]): string {
+ return JSON.stringify(layers.map(l => ({
+ t: l.victoryType,
+ d: l.data,
+ n: l.dataCount,
+ })));
+}
+
+/**
+ * React component that wraps Victory chart components and provides
+ * accessible, non-visual access through MAIDR's audio sonification,
+ * text descriptions, braille output, and keyboard navigation.
+ *
+ * Supports all Victory data components that have MAIDR equivalents:
+ * - `VictoryBar` → bar chart
+ * - `VictoryLine` → line chart
+ * - `VictoryArea` → line chart (filled)
+ * - `VictoryScatter` → scatter plot
+ * - `VictoryPie` → bar chart (categorical data)
+ * - `VictoryBoxPlot` → box plot
+ * - `VictoryCandlestick` → candlestick chart
+ * - `VictoryHistogram` → histogram
+ * - `VictoryGroup` → dodged/grouped bar chart
+ * - `VictoryStack` → stacked bar chart
+ *
+ * @example
+ * ```tsx
+ * import { MaidrVictory } from 'maidr/victory';
+ * import { VictoryChart, VictoryBar } from 'victory';
+ *
+ * function AccessibleBarChart() {
+ * return (
+ *
+ *
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function MaidrVictory({
+ id,
+ title,
+ subtitle,
+ caption,
+ children,
+}: MaidrVictoryProps): JSX.Element {
+ const containerRef = useRef(null);
+ const prevFingerprintRef = useRef('');
+ const [maidrData, setMaidrData] = useState(() => ({
+ id,
+ title,
+ subtitle,
+ caption,
+ subplots: [[{ layers: [] }]],
+ }));
+
+ // useLayoutEffect runs synchronously after DOM mutations, before paint.
+ // This guarantees Victory's SVG elements exist when we inspect the DOM
+ // for selector tagging.
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ if (!container)
+ return;
+
+ // Extract data from Victory component props via React children
+ // introspection (pure computation, does not require DOM).
+ const victoryLayers = extractVictoryLayers(children);
+
+ // Skip DOM re-tagging when the underlying data hasn't changed.
+ // This avoids redundant work when the parent re-renders with the
+ // same chart data (children reference changes but content is equal).
+ const fp = layerFingerprint(victoryLayers);
+ if (fp === prevFingerprintRef.current)
+ return;
+ prevFingerprintRef.current = fp;
+
+ // Clear stale data-maidr-victory-* attributes from a previous tagging
+ // pass to avoid ghost selectors.
+ clearTaggedElements(container);
+
+ if (victoryLayers.length === 0) {
+ // Preserve metadata (title, subtitle, caption) even when no
+ // supported Victory data components are found.
+ setMaidrData({
+ id,
+ title,
+ subtitle,
+ caption,
+ subplots: [[{ layers: [] }]],
+ });
+ return;
+ }
+
+ // Tag rendered SVG elements with data attributes for reliable
+ // CSS-selector-based highlighting.
+ const claimed = new Set();
+ const maidrLayers: MaidrLayer[] = victoryLayers.map((layer, index) => {
+ const selector = tagLayerElements(container, layer, index, claimed);
+ return toMaidrLayer(layer, selector);
+ });
+
+ // Build the MAIDR data schema.
+ setMaidrData({
+ id,
+ title,
+ subtitle,
+ caption,
+ subplots: [[{
+ layers: maidrLayers,
+ legend: collectLegend(victoryLayers),
+ }]],
+ });
+ });
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/victory/adapter.ts b/src/victory/adapter.ts
new file mode 100644
index 00000000..347baa88
--- /dev/null
+++ b/src/victory/adapter.ts
@@ -0,0 +1,588 @@
+import type {
+ BarPoint,
+ BoxPoint,
+ CandlestickPoint,
+ CandlestickTrend,
+ HistogramPoint,
+ LinePoint,
+ MaidrLayer,
+ ScatterPoint,
+ SegmentedPoint,
+} from '@type/grammar';
+import type { ReactElement, ReactNode } from 'react';
+import type { VictoryComponentType, VictoryLayerData, VictoryLayerInfo } from './types';
+import { TraceType } from '@type/grammar';
+import { Children, isValidElement } from 'react';
+
+// ---------------------------------------------------------------------------
+// Component name detection
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolves the Victory component display name from a React element type.
+ *
+ * Victory components set `displayName` on their exported functions/classes.
+ * This also handles HOC-wrapped components by checking common wrapper
+ * conventions such as `WrappedComponent`, `render`, and `type`.
+ */
+function getVictoryDisplayName(type: unknown): string | null {
+ if (!type)
+ return null;
+
+ if (typeof type === 'function' || typeof type === 'object') {
+ const obj = type as Record;
+
+ // Direct displayName or function name
+ const name = (obj.displayName as string | undefined)
+ ?? (obj.name as string | undefined)
+ ?? '';
+ if (name.startsWith('Victory'))
+ return name;
+
+ // HOC-wrapped components (e.g. React.memo, React.forwardRef)
+ if (obj.WrappedComponent)
+ return getVictoryDisplayName(obj.WrappedComponent);
+ if (obj.render)
+ return getVictoryDisplayName(obj.render);
+ if (obj.type)
+ return getVictoryDisplayName(obj.type);
+ }
+
+ return null;
+}
+
+/**
+ * Checks whether a display name corresponds to a supported Victory data
+ * component. Container components (VictoryGroup, VictoryStack, VictoryChart)
+ * are handled separately.
+ */
+function isDataComponent(name: string): name is VictoryComponentType {
+ return (
+ name === 'VictoryBar'
+ || name === 'VictoryLine'
+ || name === 'VictoryScatter'
+ || name === 'VictoryArea'
+ || name === 'VictoryPie'
+ || name === 'VictoryBoxPlot'
+ || name === 'VictoryCandlestick'
+ || name === 'VictoryHistogram'
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Data accessors
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolves the data accessor for a Victory component prop.
+ *
+ * Victory allows `x` and `y` to be a string key, a function, or omitted
+ * (defaults to "x" / "y").
+ */
+function resolveAccessor(accessor: unknown, fallback: string): (d: Record) => unknown {
+ if (typeof accessor === 'function')
+ return accessor as (d: Record) => unknown;
+ if (typeof accessor === 'string')
+ return (d: Record) => d[accessor];
+ return (d: Record) => d[fallback];
+}
+
+// ---------------------------------------------------------------------------
+// Axis label extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extracts axis labels from VictoryAxis children inside a VictoryChart.
+ */
+function extractAxisLabels(children: ReactNode): { x?: string; y?: string } {
+ const result: { x?: string; y?: string } = {};
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child))
+ return;
+ const name = getVictoryDisplayName(child.type);
+ if (name !== 'VictoryAxis')
+ return;
+
+ const props = child.props as Record;
+ const label = props.label as string | undefined;
+ if (!label)
+ return;
+
+ if (props.dependentAxis) {
+ result.y = label;
+ } else {
+ result.x = label;
+ }
+ });
+
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Per-component data extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Validates that `rawData` is a non-empty array of objects.
+ */
+function validateRawData(rawData: unknown): rawData is Record[] {
+ return Array.isArray(rawData) && rawData.length > 0
+ && typeof rawData[0] === 'object' && rawData[0] !== null;
+}
+
+/**
+ * Extracts data from a VictoryBar or VictoryPie element.
+ */
+function extractBarData(
+ props: Record,
+): { data: VictoryLayerData; count: number } | null {
+ const rawData = props.data;
+ if (!validateRawData(rawData))
+ return null;
+
+ const getX = resolveAccessor(props.x, 'x');
+ const getY = resolveAccessor(props.y, 'y');
+ const points: BarPoint[] = rawData.map(d => ({
+ x: getX(d) as string | number,
+ y: getY(d) as number | string,
+ }));
+
+ return { data: { kind: 'bar', points }, count: rawData.length };
+}
+
+/**
+ * Extracts data from a VictoryLine or VictoryArea element.
+ */
+function extractLineData(
+ props: Record,
+): { data: VictoryLayerData; count: number } | null {
+ const rawData = props.data;
+ if (!validateRawData(rawData))
+ return null;
+
+ const getX = resolveAccessor(props.x, 'x');
+ const getY = resolveAccessor(props.y, 'y');
+ const points: LinePoint[] = rawData.map(d => ({
+ x: getX(d) as number | string,
+ y: Number(getY(d)),
+ }));
+
+ return { data: { kind: 'line', points: [points] }, count: rawData.length };
+}
+
+/**
+ * Extracts data from a VictoryScatter element.
+ */
+function extractScatterData(
+ props: Record,
+): { data: VictoryLayerData; count: number } | null {
+ const rawData = props.data;
+ if (!validateRawData(rawData))
+ return null;
+
+ const getX = resolveAccessor(props.x, 'x');
+ const getY = resolveAccessor(props.y, 'y');
+ const points: ScatterPoint[] = rawData.map(d => ({
+ x: Number(getX(d)),
+ y: Number(getY(d)),
+ }));
+
+ return { data: { kind: 'scatter', points }, count: rawData.length };
+}
+
+/**
+ * Extracts data from a VictoryBoxPlot element.
+ *
+ * Victory box plots accept pre-computed statistics:
+ * `{ x, min, q1, median, q3, max }`
+ * or an array of y values from which Victory derives statistics.
+ * We only support the pre-computed form for reliable data extraction.
+ */
+function extractBoxData(
+ props: Record,
+): { data: VictoryLayerData; count: number } | null {
+ const rawData = props.data;
+ if (!validateRawData(rawData))
+ return null;
+
+ const points: BoxPoint[] = rawData.map((d) => {
+ const x = d.x as string | number;
+ return {
+ fill: String(x),
+ lowerOutliers: (d.lowerOutliers ?? []) as number[],
+ min: Number(d.min ?? 0),
+ q1: Number(d.q1 ?? 0),
+ q2: Number(d.median ?? d.q2 ?? 0),
+ q3: Number(d.q3 ?? 0),
+ max: Number(d.max ?? 0),
+ upperOutliers: (d.upperOutliers ?? []) as number[],
+ };
+ });
+
+ return { data: { kind: 'box', points }, count: rawData.length };
+}
+
+/**
+ * Extracts data from a VictoryCandlestick element.
+ *
+ * Victory candlestick data: `{ x, open, close, high, low }`
+ */
+function extractCandlestickData(
+ props: Record,
+): { data: VictoryLayerData; count: number } | null {
+ const rawData = props.data;
+ if (!validateRawData(rawData))
+ return null;
+
+ const points: CandlestickPoint[] = rawData.map((d) => {
+ const open = Number(d.open);
+ const close = Number(d.close);
+ const high = Number(d.high);
+ const low = Number(d.low);
+
+ let trend: CandlestickTrend = 'Neutral';
+ if (close > open)
+ trend = 'Bull';
+ else if (close < open)
+ trend = 'Bear';
+
+ return {
+ value: String(d.x),
+ open,
+ high,
+ low,
+ close,
+ volume: Number(d.volume ?? 0),
+ trend,
+ volatility: high - low,
+ };
+ });
+
+ return { data: { kind: 'candlestick', points }, count: rawData.length };
+}
+
+/**
+ * Extracts data from a VictoryHistogram element.
+ *
+ * VictoryHistogram accepts raw values and a `bins` prop. Since Victory
+ * computes bins internally during render, we derive equal-width bins
+ * ourselves from the raw data to produce MAIDR's `HistogramPoint[]`.
+ */
+function extractHistogramData(
+ props: Record,
+): { data: VictoryLayerData; count: number } | null {
+ const rawData = props.data;
+ if (!validateRawData(rawData))
+ return null;
+
+ const getX = resolveAccessor(props.x, 'x');
+ const values = rawData.map(d => Number(getX(d)));
+
+ const binCount = typeof props.bins === 'number'
+ ? (props.bins as number)
+ : Math.ceil(Math.sqrt(values.length));
+
+ // Use reduce instead of Math.min/max(...values) to avoid stack overflow
+ // on large datasets (spread arguments hit the engine's call stack limit
+ // at ~100k elements).
+ const min = values.reduce((a, b) => (a < b ? a : b), values[0]);
+ const max = values.reduce((a, b) => (a > b ? a : b), values[0]);
+ const binWidth = (max - min) / binCount || 1;
+
+ // Build histogram bins
+ const bins = Array.from(
+ { length: binCount },
+ (_, i) => ({
+ count: 0,
+ xMin: min + i * binWidth,
+ xMax: min + (i + 1) * binWidth,
+ }),
+ );
+
+ for (const v of values) {
+ let idx = Math.floor((v - min) / binWidth);
+ if (idx >= binCount)
+ idx = binCount - 1;
+ bins[idx].count++;
+ }
+
+ const points: HistogramPoint[] = bins.map(b => ({
+ x: `${b.xMin.toFixed(1)}-${b.xMax.toFixed(1)}`,
+ y: b.count,
+ xMin: b.xMin,
+ xMax: b.xMax,
+ yMin: 0,
+ yMax: b.count,
+ }));
+
+ return { data: { kind: 'histogram', points }, count: points.length };
+}
+
+/**
+ * Converts a single Victory data component into a {@link VictoryLayerInfo}.
+ */
+function extractLayerFromElement(
+ element: ReactElement,
+ layerId: number,
+ axisLabels: { x?: string; y?: string },
+): VictoryLayerInfo | null {
+ const name = getVictoryDisplayName(element.type);
+ if (!name || !isDataComponent(name))
+ return null;
+
+ const props = element.props as Record;
+
+ let extracted: { data: VictoryLayerData; count: number } | null = null;
+
+ switch (name) {
+ case 'VictoryBar':
+ case 'VictoryPie':
+ extracted = extractBarData(props);
+ break;
+ case 'VictoryLine':
+ case 'VictoryArea':
+ extracted = extractLineData(props);
+ break;
+ case 'VictoryScatter':
+ extracted = extractScatterData(props);
+ break;
+ case 'VictoryBoxPlot':
+ extracted = extractBoxData(props);
+ break;
+ case 'VictoryCandlestick':
+ extracted = extractCandlestickData(props);
+ break;
+ case 'VictoryHistogram':
+ extracted = extractHistogramData(props);
+ break;
+ }
+
+ if (!extracted)
+ return null;
+
+ return {
+ id: String(layerId),
+ victoryType: name,
+ data: extracted.data,
+ xAxisLabel: axisLabels.x,
+ yAxisLabel: axisLabels.y,
+ dataCount: extracted.count,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Grouped / stacked bar extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extracts a segmented (grouped or stacked) bar layer from a VictoryGroup
+ * or VictoryStack container.
+ *
+ * Each child VictoryBar becomes one series (row) in the resulting
+ * `SegmentedPoint[][]` data.
+ */
+function extractSegmentedLayer(
+ containerElement: ReactElement,
+ containerType: 'VictoryGroup' | 'VictoryStack',
+ layerId: number,
+ axisLabels: { x?: string; y?: string },
+): VictoryLayerInfo | null {
+ const containerProps = containerElement.props as { children?: ReactNode };
+ const series: SegmentedPoint[][] = [];
+ const legend: string[] = [];
+ let totalElements = 0;
+
+ Children.forEach(containerProps.children, (child) => {
+ if (!isValidElement(child))
+ return;
+ const name = getVictoryDisplayName(child.type);
+ if (name !== 'VictoryBar')
+ return;
+
+ const props = child.props as Record;
+ const rawData = props.data;
+ if (!validateRawData(rawData))
+ return;
+
+ const getX = resolveAccessor(props.x, 'x');
+ const getY = resolveAccessor(props.y, 'y');
+ const seriesName = (props.name as string) ?? `Series ${series.length + 1}`;
+
+ const points: SegmentedPoint[] = rawData.map(d => ({
+ x: getX(d) as string | number,
+ y: getY(d) as number | string,
+ fill: seriesName,
+ }));
+
+ series.push(points);
+ legend.push(seriesName);
+ totalElements += rawData.length;
+ });
+
+ if (series.length === 0)
+ return null;
+
+ const traceType: VictoryComponentType = containerType;
+
+ return {
+ id: String(layerId),
+ victoryType: traceType,
+ data: { kind: 'segmented', points: series },
+ xAxisLabel: axisLabels.x,
+ yAxisLabel: axisLabels.y,
+ dataCount: totalElements,
+ legend,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tree walking
+// ---------------------------------------------------------------------------
+
+/**
+ * Walks the React element tree to extract Victory data layers.
+ *
+ * Handles:
+ * - `` wrappers (processes children)
+ * - Standalone data components (e.g. ``)
+ * - `` and `` for segmented bar charts
+ */
+export function extractVictoryLayers(children: ReactNode): VictoryLayerInfo[] {
+ const layers: VictoryLayerInfo[] = [];
+ let layerId = 0;
+
+ function processChildren(childNodes: ReactNode, axisLabels: { x?: string; y?: string }): void {
+ Children.forEach(childNodes, (child) => {
+ if (!isValidElement(child))
+ return;
+
+ const name = getVictoryDisplayName(child.type);
+ if (!name)
+ return;
+
+ // VictoryGroup / VictoryStack → segmented bar
+ if (name === 'VictoryGroup' || name === 'VictoryStack') {
+ const segmented = extractSegmentedLayer(child, name, layerId, axisLabels);
+ if (segmented) {
+ layers.push(segmented);
+ layerId++;
+ }
+ return;
+ }
+
+ // Individual data components
+ const layer = extractLayerFromElement(child, layerId, axisLabels);
+ if (layer) {
+ layers.push(layer);
+ layerId++;
+ }
+ });
+ }
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child))
+ return;
+
+ const name = getVictoryDisplayName(child.type);
+
+ if (name === 'VictoryChart') {
+ const chartProps = child.props as { children?: ReactNode };
+ const axisLabels = extractAxisLabels(chartProps.children);
+ processChildren(chartProps.children, axisLabels);
+ } else {
+ processChildren(child, {});
+ }
+ });
+
+ return layers;
+}
+
+// ---------------------------------------------------------------------------
+// MAIDR schema conversion
+// ---------------------------------------------------------------------------
+
+/**
+ * Converts a {@link VictoryLayerInfo} into the MAIDR {@link MaidrLayer}
+ * schema.
+ *
+ * @param layer - Intermediate Victory layer info
+ * @param selector - CSS selector for the SVG elements (may be undefined if
+ * tagging was not possible)
+ */
+export function toMaidrLayer(layer: VictoryLayerInfo, selector?: string): MaidrLayer {
+ const axes: MaidrLayer['axes'] = {
+ x: layer.xAxisLabel,
+ y: layer.yAxisLabel,
+ };
+
+ const { data } = layer;
+
+ switch (data.kind) {
+ case 'bar':
+ return {
+ id: layer.id,
+ type: TraceType.BAR,
+ axes,
+ selectors: selector,
+ data: data.points,
+ };
+
+ case 'line':
+ return {
+ id: layer.id,
+ type: TraceType.LINE,
+ axes,
+ selectors: selector ? [selector] : undefined,
+ data: data.points,
+ };
+
+ case 'scatter':
+ return {
+ id: layer.id,
+ type: TraceType.SCATTER,
+ axes,
+ selectors: selector,
+ data: data.points,
+ };
+
+ case 'box':
+ return {
+ id: layer.id,
+ type: TraceType.BOX,
+ axes,
+ data: data.points,
+ };
+
+ case 'candlestick':
+ return {
+ id: layer.id,
+ type: TraceType.CANDLESTICK,
+ axes,
+ data: data.points,
+ };
+
+ case 'histogram':
+ return {
+ id: layer.id,
+ type: TraceType.HISTOGRAM,
+ axes,
+ selectors: selector,
+ data: data.points,
+ };
+
+ case 'segmented': {
+ const traceType = layer.victoryType === 'VictoryStack'
+ ? TraceType.STACKED
+ : TraceType.DODGED;
+
+ return {
+ id: layer.id,
+ type: traceType,
+ axes,
+ selectors: selector,
+ data: data.points,
+ };
+ }
+ }
+}
diff --git a/src/victory/index.ts b/src/victory/index.ts
new file mode 100644
index 00000000..bd2d05e7
--- /dev/null
+++ b/src/victory/index.ts
@@ -0,0 +1,39 @@
+/**
+ * Victory charting library adapter for MAIDR.
+ *
+ * Provides the `` component that wraps Victory chart components
+ * and automatically adds accessible, non-visual access through audio
+ * sonification, text descriptions, braille output, and keyboard navigation.
+ *
+ * @remarks
+ * Requires `victory` and React 18+ as peer dependencies.
+ *
+ * @example
+ * ```tsx
+ * import { MaidrVictory } from 'maidr/victory';
+ * import { VictoryChart, VictoryBar, VictoryAxis } from 'victory';
+ *
+ * function AccessibleChart() {
+ * return (
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @packageDocumentation
+ */
+export { MaidrVictory } from './MaidrVictory';
+export type { MaidrVictoryProps } from './types';
diff --git a/src/victory/selectors.ts b/src/victory/selectors.ts
new file mode 100644
index 00000000..3e868bc2
--- /dev/null
+++ b/src/victory/selectors.ts
@@ -0,0 +1,164 @@
+import type { VictoryLayerInfo } from './types';
+
+/**
+ * Data-attribute prefix used to tag Victory-rendered SVG elements so that
+ * MAIDR's `Svg.selectAllElements()` can reliably select them later.
+ */
+const ATTR_PREFIX = 'data-maidr-victory';
+
+/**
+ * Removes all `data-maidr-victory-*` attributes from elements inside the
+ * container. Must be called before re-tagging to prevent stale attributes
+ * from accumulating across re-renders.
+ */
+export function clearTaggedElements(container: HTMLElement): void {
+ const svg = container.querySelector('svg');
+ if (!svg)
+ return;
+
+ const tagged = svg.querySelectorAll(`[${ATTR_PREFIX}-0], [${ATTR_PREFIX}-1], [${ATTR_PREFIX}-2], [${ATTR_PREFIX}-3], [${ATTR_PREFIX}-4], [${ATTR_PREFIX}-5], [${ATTR_PREFIX}-6], [${ATTR_PREFIX}-7], [${ATTR_PREFIX}-8], [${ATTR_PREFIX}-9]`);
+ for (const el of tagged) {
+ const attrs = Array.from(el.attributes);
+ for (const attr of attrs) {
+ if (attr.name.startsWith(ATTR_PREFIX)) {
+ el.removeAttribute(attr.name);
+ }
+ }
+ }
+}
+
+/**
+ * Finds the Victory data elements inside a container for a given layer,
+ * tags them with a unique data attribute, and returns the CSS selector
+ * that matches exactly those elements.
+ *
+ * Strategy:
+ * 1. Victory renders each data component's elements with
+ * `role="presentation"` on the individual data shapes.
+ * 2. We query for the expected SVG tag (e.g. `rect` for bars) that
+ * carries `role="presentation"`.
+ * 3. We skip elements that belong to other layers by tracking which
+ * elements were already claimed.
+ * 4. Each claimed element receives a `data-maidr-victory-`
+ * attribute so MAIDR can select them deterministically.
+ *
+ * @remarks
+ * This relies on Victory's `role="presentation"` attribute on data
+ * elements, which is a stable Victory convention (tested with v37).
+ * If Victory changes this convention, selector tagging will silently
+ * degrade and highlighting will stop working (audio/text/braille are
+ * unaffected).
+ *
+ * @param container - The DOM node wrapping the Victory chart
+ * @param layer - The extracted layer info
+ * @param layerIndex - Numeric index for generating unique attribute names
+ * @param claimed - Set of elements already claimed by prior layers
+ * @returns A CSS selector string, or `undefined` if elements could not be
+ * matched (highlighting will gracefully degrade).
+ */
+export function tagLayerElements(
+ container: HTMLElement,
+ layer: VictoryLayerInfo,
+ layerIndex: number,
+ claimed: Set,
+): string | undefined {
+ const svg = container.querySelector('svg');
+ if (!svg)
+ return undefined;
+
+ const attrName = `${ATTR_PREFIX}-${layerIndex}`;
+ const { victoryType } = layer;
+
+ // Line and area charts: single representing the full series
+ if (victoryType === 'VictoryLine' || victoryType === 'VictoryArea') {
+ return tagLineOrAreaElements(svg, layer, attrName, claimed);
+ }
+
+ // Box, candlestick: complex multi-element compositions that don't map
+ // cleanly to a single selector strategy. Skip selector tagging so that
+ // MAIDR's audio/text/braille still work (highlighting degrades).
+ if (victoryType === 'VictoryBoxPlot' || victoryType === 'VictoryCandlestick') {
+ return undefined;
+ }
+
+ // Discrete-element charts: one SVG element per data point.
+ // VictoryBar and VictoryHistogram render elements.
+ // VictoryScatter, VictoryPie, VictoryGroup, and VictoryStack render
+ // elements (VictoryPie uses for arc slices, not ,
+ // despite being mapped to TraceType.BAR for data representation).
+ const tag = victoryType === 'VictoryBar' || victoryType === 'VictoryHistogram'
+ ? 'rect'
+ : 'path';
+
+ return tagDiscreteElements(svg, tag, layer, attrName, claimed);
+}
+
+/**
+ * Tags discrete SVG elements (one element per data point) for bar,
+ * histogram, scatter, pie, and segmented (stacked/dodged) charts.
+ */
+function tagDiscreteElements(
+ svg: SVGElement,
+ tag: string,
+ layer: VictoryLayerInfo,
+ attrName: string,
+ claimed: Set,
+): string | undefined {
+ const candidates = Array.from(
+ svg.querySelectorAll(`${tag}[role="presentation"]`),
+ ).filter(el => !claimed.has(el));
+
+ // Victory renders exactly one element per data point.
+ // Take the first `dataCount` unclaimed elements.
+ const matched = candidates.slice(0, layer.dataCount);
+
+ if (matched.length !== layer.dataCount) {
+ return undefined;
+ }
+
+ for (const el of matched) {
+ el.setAttribute(attrName, '');
+ claimed.add(el);
+ }
+
+ return `[${attrName}]`;
+}
+
+/**
+ * Tags the path element for a VictoryLine or VictoryArea layer.
+ *
+ * Victory renders a single `` with `role="presentation"` for
+ * the line/area. MAIDR's line trace parses the `d` attribute to derive
+ * individual point positions.
+ */
+function tagLineOrAreaElements(
+ svg: SVGElement,
+ layer: VictoryLayerInfo,
+ attrName: string,
+ claimed: Set,
+): string | undefined {
+ const candidates = Array.from(
+ svg.querySelectorAll('path[role="presentation"]'),
+ ).filter(el => !claimed.has(el));
+
+ if (candidates.length === 0)
+ return undefined;
+
+ // Heuristic: pick the first path whose `d` attribute contains enough
+ // SVG commands to plausibly represent the data points.
+ for (const candidate of candidates) {
+ const d = candidate.getAttribute('d') ?? '';
+ const commandCount = (d.match(/[MLCQTSA]/gi) ?? []).length;
+ if (commandCount >= layer.dataCount) {
+ candidate.setAttribute(attrName, '');
+ claimed.add(candidate);
+ return `[${attrName}]`;
+ }
+ }
+
+ // Fallback: use the first unclaimed path.
+ const fallback = candidates[0];
+ fallback.setAttribute(attrName, '');
+ claimed.add(fallback);
+ return `[${attrName}]`;
+}
diff --git a/src/victory/types.ts b/src/victory/types.ts
new file mode 100644
index 00000000..bd5375bd
--- /dev/null
+++ b/src/victory/types.ts
@@ -0,0 +1,78 @@
+import type {
+ BarPoint,
+ BoxPoint,
+ CandlestickPoint,
+ HistogramPoint,
+ LinePoint,
+ ScatterPoint,
+ SegmentedPoint,
+} from '@type/grammar';
+import type { ReactNode } from 'react';
+
+/**
+ * Props for the MaidrVictory component.
+ */
+export interface MaidrVictoryProps {
+ /** Unique identifier for the chart. Used for DOM element IDs. */
+ id: string;
+ /** Chart title displayed in text descriptions. */
+ title?: string;
+ /** Chart subtitle. */
+ subtitle?: string;
+ /** Chart caption. */
+ caption?: string;
+ /** Victory chart components to make accessible. */
+ children: ReactNode;
+}
+
+/**
+ * Victory chart component types that MAIDR can extract data from.
+ */
+export type VictoryComponentType
+ = | 'VictoryBar'
+ | 'VictoryLine'
+ | 'VictoryScatter'
+ | 'VictoryArea'
+ | 'VictoryPie'
+ | 'VictoryBoxPlot'
+ | 'VictoryCandlestick'
+ | 'VictoryHistogram'
+ | 'VictoryGroup'
+ | 'VictoryStack';
+
+/**
+ * Discriminated union of all supported layer data shapes.
+ *
+ * Using a discriminated union instead of `unknown` ensures that each
+ * layer's data is validated at extraction time and carries the correct
+ * type through to the MAIDR schema conversion.
+ */
+export type VictoryLayerData
+ = | { kind: 'bar'; points: BarPoint[] }
+ | { kind: 'line'; points: LinePoint[][] }
+ | { kind: 'scatter'; points: ScatterPoint[] }
+ | { kind: 'box'; points: BoxPoint[] }
+ | { kind: 'candlestick'; points: CandlestickPoint[] }
+ | { kind: 'histogram'; points: HistogramPoint[] }
+ | { kind: 'segmented'; points: SegmentedPoint[][] };
+
+/**
+ * Intermediate representation of a Victory data layer before conversion
+ * to the MAIDR schema.
+ */
+export interface VictoryLayerInfo {
+ /** Index-based layer ID. */
+ id: string;
+ /** The Victory component type that produced this layer. */
+ victoryType: VictoryComponentType;
+ /** Extracted and validated data points from the Victory component. */
+ data: VictoryLayerData;
+ /** X-axis label (from VictoryAxis or fallback). */
+ xAxisLabel?: string;
+ /** Y-axis label (from VictoryAxis or fallback). */
+ yAxisLabel?: string;
+ /** Number of data elements expected in the DOM (used for selector validation). */
+ dataCount: number;
+ /** Legend labels for multi-series segmented charts. */
+ legend?: string[];
+}
diff --git a/vite.victory.config.ts b/vite.victory.config.ts
new file mode 100644
index 00000000..27b32721
--- /dev/null
+++ b/vite.victory.config.ts
@@ -0,0 +1,53 @@
+import path from 'node:path';
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+export default defineConfig({
+ plugins: [
+ react(),
+ dts({
+ tsconfigPath: './tsconfig.build.json',
+ rollupTypes: true,
+ insertTypesEntry: false,
+ }),
+ ],
+ build: {
+ lib: {
+ entry: path.resolve(__dirname, 'src/victory/index.ts'),
+ formats: ['es'],
+ fileName: () => 'victory.mjs',
+ },
+ sourcemap: true,
+ outDir: 'dist',
+ emptyOutDir: false,
+ rollupOptions: {
+ external: [
+ 'react',
+ 'react-dom',
+ 'react/jsx-runtime',
+ 'victory',
+ ],
+ onwarn(warning, warn) {
+ if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
+ return;
+ }
+ warn(warning);
+ },
+ },
+ },
+ define: {
+ 'process.env': {},
+ },
+ resolve: {
+ alias: {
+ '@command': path.resolve(__dirname, 'src/command'),
+ '@model': path.resolve(__dirname, 'src/model'),
+ '@state': path.resolve(__dirname, 'src/state'),
+ '@service': path.resolve(__dirname, 'src/service'),
+ '@type': path.resolve(__dirname, 'src/type'),
+ '@ui': path.resolve(__dirname, 'src/ui'),
+ '@util': path.resolve(__dirname, 'src/util'),
+ },
+ },
+});