diff --git a/examples/test-role-switch.html b/examples/test-role-switch.html new file mode 100644 index 00000000..9af09713 --- /dev/null +++ b/examples/test-role-switch.html @@ -0,0 +1,133 @@ + + + + + Test: role="img" to role="application" switch + + + + + + +

Screen Reader Role Switch Test

+

+ 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. +

+ + +
+

Plot 1: Bar Chart (Tips by Day)

+ + + + + + + Day + +
+ + +
+

Plot 2: Bar Chart (Sales by Region)

+ + + + + + Region + +
+ + +
+

Plot 3: Line Chart (Temperature)

+ + + Month + +
+ +

Test Instructions

+
    +
  1. Before focus (browse mode): Press "g" in NVDA to jump between graphics. All 3 plots should be found.
  2. +
  3. On focus: Tab to a plot or click it. The role should change to "application". Press "g" — it should open the Go To Extrema dialog.
  4. +
  5. Other keys: While focused, press "b" (braille), "t" (text), "s" (sonification), "r" (review) — all should work.
  6. +
  7. On blur: Tab away. The role should revert to "img". Press "g" to find graphics again.
  8. +
+ + diff --git a/src/maidr-component.tsx b/src/maidr-component.tsx index 85f7b289..52cdcdd6 100644 --- a/src/maidr-component.tsx +++ b/src/maidr-component.tsx @@ -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'; @@ -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 @@ -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 (
-
+
{children}
{contextValue && plotRef.current && ( diff --git a/src/service/display.ts b/src/service/display.ts index 2443c964..5f2f2b12 100644 --- a/src/service/display.ts +++ b/src/service/display.ts @@ -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; } /** @@ -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; diff --git a/src/util/constant.ts b/src/util/constant.ts index 60e9ff24..82549962 100644 --- a/src/util/constant.ts +++ b/src/util/constant.ts @@ -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 */