Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@
"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",
"files": [
"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",
Expand All @@ -42,14 +48,18 @@
},
"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": {
"optional": true
},
"react-dom": {
"optional": true
},
"victory": {
"optional": true
}
},
"dependencies": {
Expand Down
159 changes: 159 additions & 0 deletions src/victory/MaidrVictory.tsx
Original file line number Diff line number Diff line change
@@ -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 (
* <MaidrVictory id="sales" title="Sales by Quarter">
* <VictoryChart>
* <VictoryBar
* data={[
* { x: 'Q1', y: 120 },
* { x: 'Q2', y: 200 },
* { x: 'Q3', y: 150 },
* { x: 'Q4', y: 300 },
* ]}
* />
* </VictoryChart>
* </MaidrVictory>
* );
* }
* ```
*/
export function MaidrVictory({
id,
title,
subtitle,
caption,
children,
}: MaidrVictoryProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const prevFingerprintRef = useRef<string>('');
const [maidrData, setMaidrData] = useState<MaidrData>(() => ({
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<Element>();
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 (
<Maidr data={maidrData}>
<div ref={containerRef}>
{children}
</div>
</Maidr>
);
}
Loading