npm.io
0.2.0 • Published 22h ago

@inclunet/mermaid-a11y

Licence
MIT
Version
0.2.0
Deps
0
Vulns
0
Weekly
0

@inclunet/mermaid-a11y

Accessible keyboard navigation for Mermaid diagrams, packaged as a React component.

The component always renders the diagram itself from the chart source. Navigation is driven by Mermaid’s parse result (nodes, edges, direction), not by scraping the SVG. The SVG is only the visual projection: focus, highlight, and ARIA are applied there.

Try it

Live demo (GitHub Pages): https://inclunet.github.io/mermaid-a11y/ — includes an interactive playground, keyboard reference, copy-paste examples, and full props table.

Locally:

npm install
npm run demo

That starts a Vite playground at http://localhost:5173 with sample diagrams, an editable source box, a visible copy of the screen-reader announcement, and inline documentation. Tab to the diagram and use the arrow keys.

Documentation

Unsupported diagram types

Keyboard navigation is built for flowchart graphs today. Other Mermaid types (sequence, class, state, pie, gantt, …) still render normally: the component draws the same SVG Mermaid would without this plugin, skips the navigator, and announces that navigation is unavailable (visually hidden status for assistive tech).

Headless callers can check navigable on the result of renderAccessibleDiagram:

const result = await renderAccessibleDiagram({ chart, mermaid, host });
if (result.navigable) {
  createNavigator({ graph: result.graph, svg: result.svg /* … */ }).attach();
}

Install

mermaid is a mandatory peer dependency. Install it together with this package so missing Mermaid fails at install/build time, not silently at runtime.

npm install @inclunet/mermaid-a11y mermaid

React 18+ is required for the React entry. It is an optional peer if you only import the framework-agnostic core or Mermaid adapter:

import { createNavigator } from '@inclunet/mermaid-a11y/core';
import { renderAccessibleDiagram, resolveMermaid } from '@inclunet/mermaid-a11y/mermaid';

Headless usage (no React)

Render from Mermaid source, then attach the navigator:

import { createNavigator } from '@inclunet/mermaid-a11y/core';
import { renderAccessibleDiagram, resolveMermaid } from '@inclunet/mermaid-a11y/mermaid';

const mermaid = await resolveMermaid();
const host = document.getElementById('svg-host')!;
const { graph, svg } = await renderAccessibleDiagram({
  chart: `flowchart LR\n  A[Start] --> B[Done]`,
  mermaid,
  host,
});

const navigator = createNavigator({
  graph,
  svg,
  container: document.getElementById('diagram')!,
  liveRegion: document.getElementById('live')!,
  instructionsId: 'diagram-help',
  locale: 'en',
});

navigator.attach();

resolveMermaid() loads the peer mermaid package. Pass your own instance when the host already has one: resolveMermaid(mermaid).

Per-diagram config is supported via the optional config argument on renderAccessibleDiagram (same as the React config prop — never calls global mermaid.initialize()).

Host announcer and live region

By default each diagram mounts its own polite live region and plays the Assistente-style boundary bump. Hosts that already own a global announcer (or a shared live region) can take over:

Precedence: onAnnounce → host liveRegion → internal live region.

// Callback: host owns re-announce, priority, and surface arbitration.
<MermaidA11y chart={chart} onAnnounce={(message) => assistente.announce(message)} />

// Or point at an existing live region element (library still does clear-then-set on that node).
<MermaidA11y chart={chart} liveRegion={document.getElementById('app-live')!} />

When either external channel is set, the React shell does not mount an internal aria-live, role="status", or role="alert". Status and render failures are delivered through the same channel.

Boundary feedback uses the existing onBoundary prop for the bump sound only: omit it for the default marimba; provide it (including a no-op) to own the sound. Boundary text still follows the announcement channel above (onAnnounce / liveRegion / internal).

Headless createNavigator accepts the same announce options (onAnnounce and/or liveRegion; at least one is required via the NavigatorOptions type). For interface … extends, use NavigatorOptionsBase and intersect your own announce channel, since NavigatorOptions is a union.

Basic usage

import { MermaidA11y } from '@inclunet/mermaid-a11y';

export function CheckoutFlow() {
  return (
    <MermaidA11y
      chart={`
        flowchart LR
          A[Start] -->|go| B[Process]
          B --> C[Done]
          A -->|fail| D[Error]
      `}
    />
  );
}

Tab to the diagram, then use the arrow keys. A live region announces the current node or connection.

Custom per-diagram config

Pass config for this diagram only. The component never calls mermaid.initialize(), so it will not override the host app’s theme or securityLevel.

<MermaidA11y
  chart="flowchart TD\n  A --> B"
  config={{
    theme: 'neutral',
    flowchart: { htmlLabels: false },
  }}
/>

Multiple Mermaid instances

By default the component uses the peer mermaid package. If the host must keep more than one Mermaid copy, pass the instance as an escape hatch:

import mermaid from 'mermaid';

<MermaidA11y chart={chart} mermaid={mermaid} />

There is no bundled fallback copy of Mermaid.

Custom highlight renderer

The core only sets logical state (data-a11y-focus, aria-activedescendant). How that state is painted is injected:

import { MermaidA11y, type HighlightRenderer } from '@inclunet/mermaid-a11y';

const highlightRenderer: HighlightRenderer = {
  attach({ svg }) {
    const layer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    layer.setAttribute('data-highlight', 'true');
    layer.setAttribute('aria-hidden', 'true');
    svg.appendChild(layer);
  },
  update(target) {
    // Paint from target.element (node <g> or edge <path>).
    // Do not mutate shapes Mermaid generated; add/remove overlay nodes instead.
    void target;
  },
  detach() {
    document.querySelector('[data-highlight="true"]')?.remove();
  },
};

<MermaidA11y chart={chart} highlightRenderer={highlightRenderer} />

The default renderer draws a white halo plus a thicker blue stroke so the indicator does not rely on color alone (WCAG 1.4.1) and meets a visible focus appearance (WCAG 2.4.11). CSS outline is not used on SVG shapes.

Language

Announcements, keyboard instructions, and ARIA names ship in English, Spanish, and Portuguese.

Pass locale to set the language from the host app. If you omit it (or pass an unsupported tag), the component detects the user language from navigator.languages, then navigator.language, then document.documentElement.lang. Tags like pt-BR and es-MX map to Portuguese and Spanish. Anything else falls back to English.

<MermaidA11y chart={chart} locale="pt-BR" />
<MermaidA11y chart={chart} locale="es" />
<MermaidA11y chart={chart} />

Keyboard map

This model is not the ARIA APG tree or grid pattern. Instructions are exposed with aria-describedby so the keys are discoverable.

Key Action
Down Arrow Next outgoing connection of the current node
Up Arrow Previous outgoing connection (bumps on the first)
Right Arrow Follow the selected connection outward (source → target)
Left Arrow Go back to the previous node on the current path
D Hear this node’s description
Space Repeat this node’s description and the current connection
T Toggle navigation tips on or off
Home Jump to the first node
End Jump to the last node

After moving outward with Left/Right, the live region speaks the full node description (same as D) followed by the selected connection. Up/Down announce only the connection itself — an edge label such as go., or the target name when there is no label. With tips on (default), each move also appends short keyboard hints; press T to toggle tips off for a quieter flow. Press D to hear the node description again. Press Space to repeat that detail plus the current connection. At either end a bump sound plays (the same marimba used on Inclunet Assistente toolbars), and the live region announces the boundary. Hosts can replace the sound with onBoundary.

Up/Down never reconstruct the graph from SVG geometry; they walk logical outgoing edges. Left/Right use the navigation path and the selected edge’s source/target from the parse result.

Public API

React
<MermaidA11y
  chart={string}                 // required: Mermaid source
  config?={MermaidConfig}        // per-diagram only
  mermaid?={MermaidInstance}     // optional escape hatch
  locale?={string}               // en | es | pt, or a BCP 47 tag
  onAnnounce?={(message) => void} // host announcer; skips internal live region
  liveRegion?={HTMLElement}      // host live region when onAnnounce is omitted
  onBoundary?={() => void}       // bump sound only; text uses announce channel
  focusStrategy?={'activedescendant' | 'roving'}  // default: 'activedescendant'
  highlightRenderer?={HighlightRenderer}
  className?={string}
  aria-label?={string}
/>
Mermaid adapter (@inclunet/mermaid-a11y/mermaid)
Export Description
renderAccessibleDiagram({ chart, mermaid, host, config? }) Render SVG + build GraphModel from the parse result. Sets navigable: false (SVG kept) when the type is unsupported or extraction fails
resolveMermaid(instance?) Load the peer mermaid package or validate a host instance
RenderAccessibleOptions, RenderAccessibleResult Types for the render call (navigable on the result)

Also re-exported from the main entry for convenience.

Core (@inclunet/mermaid-a11y/core)

createNavigator, traversal helpers, announcements, focus strategies, and highlight renderer factory — framework-agnostic.

The public surface is intentionally small.

Accessibility model

Focus: Model B (default)
  • One focusable container (tabindex="0").
  • Arrow keys move a logical cursor.
  • aria-activedescendant points at the current node or edge id.
  • DOM focus stays on the container, so :focus on nodes does not fire. Highlight is driven by data-a11y-focus="true".
  • The container has its own visible :focus-visible ring (WCAG 2.4.11).

focusStrategy="roving" is implemented behind the same traversal machine for hosts that need roving tabindex. It is not the default.

Role

The widget uses role="group", not role="application".

application is reserved for widgets that must intercept most keys and pull the user out of screen-reader browse mode. This component only handles arrow keys (and Home/End) while the container is focused. group plus aria-label / aria-describedby is the conservative, more predictable choice.

Nodes and edges keep role="img" and aria-label for the active item only. In the default activedescendant strategy, every other shape is aria-hidden="true", so browse mode does not expose a long list of diagram images — navigation happens through the focusable container, the live region, and arrow keys. Use Tab to focus the diagram group; individual shapes are not meant to be visited one by one in browse mode.

focusStrategy="roving" leaves all items in the accessibility tree and moves DOM focus between them for hosts that need that model.

Screen-reader caveat

aria-activedescendant pointing at SVG <g> / <path> children is less tested than the same pattern in HTML. This library degrades by also updating a polite live region on every move, so the user still hears the new node or connection if the descendant id is ignored.

Tested screen readers

Please verify in your environment and add findings via PR. The combinations we design against:

Screen reader Browser OS Notes
NVDA Firefox, Chrome Windows Live region + aria-activedescendant; browse vs focus mode when the group is focused
JAWS Chrome, Edge Windows Same as NVDA; confirm the live region if SVG descendants are silent
VoiceOver Safari macOS Confirm aria-activedescendant on SVG; live region is the fallback

If a combination fails, the live region and aria-current="true" on the logical target are the fallback path. Please file an issue with SR / browser / OS versions.

Architecture

Three layers, with a hard boundary between Mermaid and the a11y core:

  1. Mermaid adapterchart in; { nodes, edges, direction, svgId map } out. Coupled to Mermaid’s parse API and versioned with it. Graph structure always comes from the parse result (getData / getVertices / getEdges). SVG ids are resolved at render time.
  2. Navigation core — framework-agnostic TypeScript. Traversal state machine, keyboard map, focus strategy, ARIA, live-region text. Unit-tested without React.
  3. React shell — maps props → parse + render → createNavigator. Re-installs the core when chart / config change. Removes listeners and highlight overlays on unmount.

Do not attach this library to a third-party pre-rendered SVG. There is no enhancer mode and no MutationObserver on foreign markup.

Branch protection

main is protected so everyone except the repository admin/owner contributes through reviewed pull requests with passing CI. See CONTRIBUTING.md and docs/github-ruleset.json.

Publishing

GitHub Actions publishes to npm on a published GitHub Release (or a v* tag). Add an NPM_TOKEN repository secret. The workflow runs npm publish --access public --provenance.

License

MIT

Keywords