Skip to content
Merged
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
133 changes: 133 additions & 0 deletions examples/test-role-switch.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Test: role="img" to role="application" switch</title>
<link rel="stylesheet" href="../dist/maidr.css" />
<script src="../dist/maidr.js"></script>
<style>
body { font-family: sans-serif; padding: 2rem; }
h1 { margin-bottom: 0.5rem; }
p { color: #555; margin-bottom: 2rem; }
.plot-container { margin-bottom: 3rem; border: 1px solid #ccc; padding: 1rem; }
h2 { margin-top: 0; }
</style>
</head>

<body>
<h1>Screen Reader Role Switch Test</h1>
<p>
Open this page with NVDA or JAWS. Press "g" in browse mode to jump between
graphics. Each chart should be discoverable as an image. On focus, the role
should switch to "application" so all single-letter keys work.
</p>

<!-- Plot 1: Simple Bar Chart -->
<div class="plot-container">
<h2>Plot 1: Bar Chart (Tips by Day)</h2>
<svg
xmlns="http://www.w3.org/2000/svg"
width="400" height="250" viewBox="0 0 400 250"
maidr-data='{
"id": "test-bar-1",
"title": "Tips by Day",
"subplots": [[{
"layers": [{
"id": "bar-layer-1",
"type": "bar",
"axes": { "x": "Day", "y": "Count" },
"data": [
{ "x": "Mon", "y": 20 },
{ "x": "Tue", "y": 35 },
{ "x": "Wed", "y": 28 },
{ "x": "Thu", "y": 42 },
{ "x": "Fri", "y": 55 }
]
}]
}]]
}'
>
<rect x="40" y="180" width="50" height="40" fill="steelblue" />
<rect x="110" y="145" width="50" height="75" fill="steelblue" />
<rect x="180" y="160" width="50" height="60" fill="steelblue" />
<rect x="250" y="130" width="50" height="90" fill="steelblue" />
<rect x="320" y="100" width="50" height="120" fill="steelblue" />
<text x="200" y="245" text-anchor="middle" font-size="12">Day</text>
</svg>
</div>

<!-- Plot 2: Another Bar Chart -->
<div class="plot-container">
<h2>Plot 2: Bar Chart (Sales by Region)</h2>
<svg
xmlns="http://www.w3.org/2000/svg"
width="400" height="250" viewBox="0 0 400 250"
maidr-data='{
"id": "test-bar-2",
"title": "Sales by Region",
"subplots": [[{
"layers": [{
"id": "bar-layer-2",
"type": "bar",
"axes": { "x": "Region", "y": "Sales ($K)" },
"data": [
{ "x": "North", "y": 120 },
{ "x": "South", "y": 85 },
{ "x": "East", "y": 150 },
{ "x": "West", "y": 95 }
]
}]
}]]
}'
>
<rect x="50" y="100" width="60" height="120" fill="coral" />
<rect x="140" y="140" width="60" height="80" fill="coral" />
<rect x="230" y="70" width="60" height="150" fill="coral" />
<rect x="320" y="130" width="60" height="90" fill="coral" />
<text x="200" y="245" text-anchor="middle" font-size="12">Region</text>
</svg>
</div>

<!-- Plot 3: Line Chart -->
<div class="plot-container">
<h2>Plot 3: Line Chart (Temperature)</h2>
<svg
xmlns="http://www.w3.org/2000/svg"
width="400" height="250" viewBox="0 0 400 250"
maidr-data='{
"id": "test-line-1",
"title": "Temperature Over Time",
"subplots": [[{
"layers": [{
"id": "line-layer-1",
"type": "line",
"axes": { "x": "Month", "y": "Temp (F)" },
"data": [[
{ "x": "Jan", "y": 32, "fill": "" },
{ "x": "Feb", "y": 35, "fill": "" },
{ "x": "Mar", "y": 45, "fill": "" },
{ "x": "Apr", "y": 58, "fill": "" },
{ "x": "May", "y": 68, "fill": "" },
{ "x": "Jun", "y": 78, "fill": "" }
]]
}]
}]]
}'
>
<polyline
points="40,200 110,190 180,160 250,120 320,90 390,60"
fill="none" stroke="green" stroke-width="2"
/>
<text x="200" y="245" text-anchor="middle" font-size="12">Month</text>
</svg>
</div>

<h2>Test Instructions</h2>
<ol>
<li><strong>Before focus (browse mode):</strong> Press "g" in NVDA to jump between graphics. All 3 plots should be found.</li>
<li><strong>On focus:</strong> Tab to a plot or click it. The role should change to "application". Press "g" — it should open the Go To Extrema dialog.</li>
<li><strong>Other keys:</strong> While focused, press "b" (braille), "t" (text), "s" (sonification), "r" (review) — all should work.</li>
<li><strong>On blur:</strong> Tab away. The role should revert to "img". Press "g" to find graphics again.</li>
</ol>
</body>
</html>
44 changes: 42 additions & 2 deletions src/maidr-component.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AppStore } from '@state/store';
import type { Maidr as MaidrData } from '@type/grammar';
import type { JSX, ReactNode } from 'react';
import { useRef } from 'react';
import { useMemo, useRef } from 'react';
import { useMaidrController } from './state/hook/useMaidrController';
import { createMaidrStore } from './state/store';
import { MaidrApp } from './ui/App';
Expand Down Expand Up @@ -34,6 +34,35 @@ export interface MaidrProps {
* }
* ```
*/
/**
* Derives a static instruction string from the MAIDR configuration for the
* initial render. This replicates what the old throwaway Controller / Context
* produced via {@link Context.getInstruction} so that screen readers can
* discover the chart (e.g. NVDA "g" key) before the user focuses it.
*
* Once the Controller is created on focus-in, {@link DisplayService} overwrites
* these attributes with the authoritative values.
*/
function getInitialInstruction(data: MaidrData): string {
const subplots = data.subplots;
const subplotCount = subplots.flat().length;

if (subplotCount > 1) {
return `This is a maidr figure containing ${subplotCount} subplots. Click to activate. Use arrow keys to navigate subplots and press 'ENTER'.`;
}

// Single subplot — describe the first layer's trace type.
const firstSubplot = subplots[0]?.[0];
const layerCount = firstSubplot?.layers.length ?? 0;
const traceType = firstSubplot?.layers[0]?.type ?? 'chart';

if (layerCount > 1) {
return `This is a maidr plot containing ${layerCount} layers, and this is layer 1 of ${layerCount}: ${traceType} plot. Click to activate. Use Arrows to navigate data points. Toggle B for Braille, T for Text, S for Sonification, and R for Review mode.`;
}

return `This is a maidr plot of type: ${traceType}. Click to activate. Use Arrows to navigate data points. Toggle B for Braille, T for Text, S for Sonification, and R for Review mode.`;
}

export function Maidr({ data, children }: MaidrProps): JSX.Element {
// Each Maidr instance gets its own isolated Redux store.
// useRef with lazy init guarantees the store persists for the component's
Expand All @@ -45,6 +74,10 @@ export function Maidr({ data, children }: MaidrProps): JSX.Element {

const { plotRef, figureRef, contextValue, onFocusIn, onFocusOut } = useMaidrController(data, store);

// Compute the initial instruction once so the plot is discoverable by screen
// readers (role="img" + aria-label) before any user interaction.
const initialInstruction = useMemo(() => getInitialInstruction(data), [data]);

return (
<article id={`maidr-article-${data.id}`}>
<figure
Expand All @@ -53,7 +86,14 @@ export function Maidr({ data, children }: MaidrProps): JSX.Element {
onFocus={onFocusIn}
onBlur={onFocusOut}
>
<div ref={plotRef} tabIndex={0} style={{ width: 'fit-content' }}>
<div
ref={plotRef}
tabIndex={0}
role="img"
aria-label={initialInstruction}
title={initialInstruction}
style={{ width: 'fit-content' }}
>
{children}
</div>
{contextValue && plotRef.current && (
Expand Down
25 changes: 7 additions & 18 deletions src/service/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,40 +101,30 @@ export class DisplayService implements Disposable {

/**
* Adds instruction ARIA labels to the plot element.
* Restores the plot to a passive image role for screen reader graphics navigation.
*/
private addInstruction(): void {
this.plot.setAttribute(Constant.ARIA_LABEL, this.getInstruction());
this.plot.setAttribute(Constant.TITLE, this.getInstruction());
this.plot.setAttribute(Constant.ROLE, Constant.IMAGE);
this.plot.removeAttribute(Constant.ARIA_ROLEDESCRIPTION);
this.plot.tabIndex = 0;
}

/**
* Removes or updates instruction ARIA labels when entering interactive mode.
*
* Uses {@link Constant.GRAPHICS_DOCUMENT role="graphics-document"} from the
* WAI-ARIA Graphics Module (https://www.w3.org/TR/graphics-aria-1.0/) to preserve
* screen reader quick-navigation (e.g. "g" key in NVDA, VO+Cmd+G in VoiceOver)
* while indicating this is an interactive graphical document.
*
* AT compatibility notes (tested roles, not exhaustive):
* - VoiceOver (macOS/iOS): good support for graphics-document
* - NVDA + Firefox/Chrome: support varies by version; verify after updates
* - JAWS + Chrome/Edge: support varies by version; verify after updates
*/
private removeInstruction(): void {
const instruction = this.hasEnteredInteractive ? '' : this.getInstruction(false);
if (instruction) {
this.plot.setAttribute(Constant.ARIA_LABEL, instruction);
this.plot.removeAttribute(Constant.TITLE);
this.plot.setAttribute(Constant.ROLE, Constant.APPLICATION);
this.plot.tabIndex = 0;
} else {
this.plot.removeAttribute(Constant.ARIA_LABEL);
this.plot.removeAttribute(Constant.TITLE);
this.plot.setAttribute(Constant.ROLE, Constant.APPLICATION);
this.plot.tabIndex = 0;
}
this.plot.removeAttribute(Constant.TITLE);
this.plot.setAttribute(Constant.ROLE, Constant.GRAPHICS_DOCUMENT);
this.plot.setAttribute(Constant.ARIA_ROLEDESCRIPTION, Constant.INTERACTIVE_CHART);
this.plot.tabIndex = 0;
}

/**
Expand Down Expand Up @@ -218,8 +208,7 @@ export class DisplayService implements Disposable {
this.plot.removeAttribute(Constant.ARIA_LABEL);
}

this.plot.setAttribute(Constant.ROLE, Constant.GRAPHICS_DOCUMENT);
this.plot.setAttribute(Constant.ARIA_ROLEDESCRIPTION, Constant.INTERACTIVE_CHART);
this.plot.setAttribute(Constant.ROLE, Constant.APPLICATION);
this.plot.focus();
if (!this.hasEnteredInteractive) {
this.hasEnteredInteractive = true;
Expand Down
8 changes: 2 additions & 6 deletions src/util/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,8 @@ export abstract class Constant {
// Attribute values.
/** DOM insertion position after the element */
static readonly AFTER_END = 'afterend';
/** ARIA graphics-document role value for interactive SVG charts (WAI-ARIA Graphics Module) */
static readonly GRAPHICS_DOCUMENT = 'graphics-document';
/** ARIA roledescription attribute name */
static readonly ARIA_ROLEDESCRIPTION = 'aria-roledescription';
/** Custom role description announced by screen readers for interactive charts */
static readonly INTERACTIVE_CHART = 'interactive chart';
/** ARIA application role value */
static readonly APPLICATION = 'application';
/** Text string for 'are' with spaces */
static readonly ARE = ' are ';
/** SVG circle element tag name */
Expand Down