# Accessibility

> How @toolbox-web/grid implements WAI-ARIA grid patterns, keyboard navigation, screen reader support, and high contrast mode.

`@toolbox-web/grid` follows the [WAI-ARIA Grid Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) to provide an accessible data grid experience. This page documents the ARIA attributes, keyboard interactions, and best practices.

## Our accessibility commitment

We target **WCAG 2.1 Level AA** out of the box, with no configuration required. That means:

- **Every interactive operation** (sort, filter, select, edit, expand, reorder, resize, group, paste) is reachable from the keyboard alone — no mouse-only paths.
- **Every state change** (sort applied, filter cleared, row selected, edit committed) is announced to assistive technology via the grid's built-in `aria-live` region.
- **Every visual signal** (focus, selection, error state, sort direction) has a non-color cue (icon, text, ARIA attribute) so the grid works without color perception.
- **Windows High Contrast / forced-colors** is supported automatically — the grid maps every themable surface to system colors via `@media (forced-colors: active)`.
- **Reduced motion** is respected — animations, expand/collapse transitions, and scroll smoothing disable under `prefers-reduced-motion: reduce`.
- **Focus is never lost** — after editing, deleting, sorting, or filtering, the grid restores focus to a sensible cell.

See the [full WCAG 2.1 AA conformance table](#wcag-compliance-checklist) below for the criteria we test against.

> **Filing accessibility issues:** If you find an a11y bug, please open an issue with the screen reader / browser / OS you used and what was announced (or wasn't). A11y bugs are treated as P0.

## ARIA Roles & Attributes

The grid applies the following ARIA roles and attributes automatically:

### Grid Structure

| Element | Role | Attributes |
|---------|------|-----------|
| `<tbw-grid>` | `grid` (or `treegrid` when [Tree](/grid/plugins/tree.md) or [Row Grouping](/grid/plugins/grouping-rows.md) is active) | `aria-rowcount`, `aria-colcount`, `aria-multiselectable` |
| Header container | `rowgroup` | — |
| Header row | `row` | `aria-rowindex="1"` |
| Header cell | `columnheader` | `aria-sort`, `aria-colindex` |
| Body container | `rowgroup` | — |
| Data row | `row` | `aria-rowindex`, `aria-selected`, `aria-expanded`, `aria-level` / `aria-setsize` / `aria-posinset` (under `treegrid`) |
| Data cell | `gridcell` | `aria-colindex`, `aria-selected`, `aria-readonly` |

### Dynamic Attributes

| Attribute | Applied When | Values |
|-----------|-------------|--------|
| `aria-sort` | Column is sorted | `ascending`, `descending`, `none` |
| `aria-selected` | Row or cell is selected | `true`, `false` |
| `aria-expanded` | Row grouping/tree is active (rows with children) | `true`, `false` |
| `aria-label` | `gridAriaLabel` is set, or `shell.header.title` provides a fallback (suppressed when `gridAriaLabelledBy` is set) | string |
| `aria-labelledby` | `gridAriaLabelledBy` is set | id-ref |
| `aria-describedby` | `gridAriaDescribedBy` is set | id-ref |
| `aria-roledescription` | `gridAriaRoleDescription` is set (overrides the AT-announced role name; use sparingly — value should still describe a grid widget) | string |
| `aria-level` | [Tree](/grid/plugins/tree.md) or [Row Grouping](/grid/plugins/grouping-rows.md) plugin is active (1-based hierarchical depth) | `1`, `2`, … |
| `aria-setsize` | [Tree](/grid/plugins/tree.md) or [Row Grouping](/grid/plugins/grouping-rows.md) plugin is active (sibling count at this level) | integer |
| `aria-posinset` | [Tree](/grid/plugins/tree.md) or [Row Grouping](/grid/plugins/grouping-rows.md) plugin is active (1-based position among siblings) | integer |
| `aria-multiselectable` | Selection plugin allows multi-select | `true` |
| `aria-readonly` | Cell is not editable | `true` |
| `aria-rowindex` | Always (1-based) | Row position in full dataset |
| `aria-colindex` | Always (1-based) | Column position |

## Keyboard Navigation

The grid implements full keyboard navigation following the WAI-ARIA grid pattern:

### Basic Navigation

| Key | Action |
|-----|--------|
| <kbd>↑</kbd> / <kbd>↓</kbd> | Move focus between rows |
| <kbd>←</kbd> / <kbd>→</kbd> | Move focus between cells |
| <kbd>Home</kbd> | Move to first cell in row |
| <kbd>End</kbd> | Move to last cell in row |
| <kbd>Ctrl</kbd> + <kbd>Home</kbd> | Move to first cell in grid |
| <kbd>Ctrl</kbd> + <kbd>End</kbd> | Move to last cell in grid |
| <kbd>PgUp</kbd> | Scroll up one viewport |
| <kbd>PgDn</kbd> | Scroll down one viewport |
| <kbd>⇥ Tab</kbd> | Move to next cell (wraps to next row) |
| <kbd>⇧ Shift</kbd> + <kbd>⇥ Tab</kbd> | Move to previous cell (wraps to previous row) |

### Plugin-Specific Shortcuts

Keyboard shortcuts that depend on a plugin being installed are documented on each plugin's own page — that's the source of truth and stays in sync with the implementation:

- [**Selection**](/grid/plugins/selection.md#keyboard-shortcuts) — <kbd>Space</kbd>, <kbd>Shift</kbd> + arrows / <kbd>PgUp</kbd> / <kbd>PgDn</kbd> / <kbd>Ctrl</kbd> + <kbd>Home</kbd>/<kbd>End</kbd>, <kbd>Ctrl</kbd> + <kbd>A</kbd>, <kbd>Esc</kbd>
- [**Editing**](/grid/plugins/editing.md#keyboard-shortcuts-row-mode) — <kbd>Enter</kbd> (start row edit / commit), <kbd>F2</kbd> (single-cell edit), <kbd>Tab</kbd> / <kbd>Shift</kbd> + <kbd>Tab</kbd>, <kbd>Esc</kbd> (cancel)
- [**Clipboard**](/grid/plugins/clipboard.md#keyboard-shortcuts) — <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>C</kbd> / <kbd>X</kbd> / <kbd>V</kbd>
- [**Context Menu**](/grid/plugins/context-menu.md) — <kbd>Shift</kbd> + <kbd>F10</kbd> or the dedicated <kbd>☰ Menu</kbd> key opens the menu at the focused cell; <kbd>↑</kbd>/<kbd>↓</kbd> navigates, <kbd>Enter</kbd>/<kbd>Space</kbd> activates, <kbd>Esc</kbd> closes
- [**Row Grouping**](/grid/plugins/grouping-rows.md) — <kbd>Space</kbd> toggles expand/collapse on a group or tree node

## Focus Management

### Focus Indicators

The grid uses visible focus indicators that meet WCAG 2.1 Level AA requirements:

```css
tbw-grid {
  /* Customize focus ring */
  --tbw-color-focus-ring: #2563eb;
}
```

The focus ring is 2px solid and uses `outline` (not `border`) so it doesn't affect layout.

### Focus Trapping

When editing a cell, focus is trapped within the editor until the user commits (Enter/Tab) or cancels (Escape). Overlay editors (date pickers, dropdowns) use `registerExternalFocusContainer()` to extend the focus trap.

### Roving Tabindex

The grid uses a roving tabindex pattern:
- Only the currently focused cell has `tabindex="0"`
- All other cells have `tabindex="-1"`
- This means **Tab** moves focus out of the grid, and **Shift+Tab** moves focus back to the last focused cell

This matches the [WAI-ARIA grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) — a grid is *one* tab stop in the page's tab order. Once focus enters, arrow keys navigate.

### Focus restoration

After every operation that destroys and recreates row DOM (sort, filter, edit-commit, row removal, expand/collapse, virtualization scroll-back), the grid restores focus to the most logical cell — the same row by identity if it still exists, otherwise the same row index, otherwise the nearest surviving row in the same column. You do not need to do anything for this to work — it's automatic.

### External focus containers

Overlay UIs that float *outside* the grid's DOM (date pickers, dropdowns, autocompletes used by editors or custom renderers) need to be registered so the grid's focus trap and outside-click handling treat them as part of the grid:

```ts
const cleanup = grid.registerExternalFocusContainer(popoverEl);
// ...later, when the popover closes:
cleanup();
```

Without this, clicking inside the popover commits the active edit and closes it. With it, the popover is treated as an extension of the focused cell.

## Screen Reader Support

### Labels and Descriptions

Provide accessible labels for screen readers:

```html
<tbw-grid aria-label="Employee directory">
```

Or reference a visible heading:

```html
<h2 id="grid-heading">Employees</h2>
<tbw-grid aria-labelledby="grid-heading">
```

### Live Regions

The grid uses `aria-live` regions to announce dynamic changes to screen readers:
- Sort changes ("Sorted by Name, ascending")
- Filter changes ("Filter applied on Name", "All filters cleared")
- Group expand/collapse ("Group Engineering expanded, 5 rows")
- Selection changes ("3 rows selected")
- Editing lifecycle ("Editing row 1", "Row 1 saved")

### Configuring Announcements

You can disable or customize live region announcements via the [`a11y`](/grid/api/core/interfaces/a11yconfig.md) config — see [`A11yMessages`](/grid/api/core/interfaces/a11ymessages.md) for the full list of overridable messages and their signatures:

```typescript
// Disable all announcements
grid.gridConfig = {
  a11y: { announcements: false },
};

// Override messages for internationalization
grid.gridConfig = {
  a11y: {
    messages: {
      sortApplied: (col, dir) => `Trié par ${col}, ${dir}`,
      sortCleared: () => 'Tri effacé',
      filterApplied: (col) => `Filtre appliqué sur ${col}`,
      filterCleared: (col) => `Filtre effacé de ${col}`,
      allFiltersCleared: () => 'Tous les filtres effacés',
      groupExpanded: (name, count) => `Groupe ${name} développé, ${count} lignes`,
      groupCollapsed: (name) => `Groupe ${name} réduit`,
      selectionChanged: (count) => `${count} lignes sélectionnées`,
      editingStarted: (rowIndex) => `Édition de la ligne ${rowIndex + 1}`,
      editingCommitted: (rowIndex) => `Ligne ${rowIndex + 1} enregistrée`,
      dataLoaded: (count) => `${count} lignes chargées`,
    },
  },
};
```

Only override the messages you need — defaults (English) are used for the rest.

### Column Headers

Column headers include:
- Column name via text content
- Sort direction via `aria-sort`
- Filter state via `aria-description` ("Filtered")

### Testing with screen readers

The grid is tested against NVDA, VoiceOver, and JAWS in CI-adjacent environments. When testing in your own app, here's what a screen-reader user should hear for the core flows. If your output diverges substantially from this, that's likely an a11y bug worth filing.

**NVDA (Windows, Firefox or Chrome):**
1. Download [NVDA](https://www.nvaccess.org/download/) (free) and start it.
2. Tab into the grid — you should hear `"<grid label>, grid, <N> rows, <M> columns"` followed by the focused cell.
3. Press <kbd>↓</kbd> — `"<value>, column <name>, row <n> of <total>"`.
4. Press <kbd>Enter</kbd> on a header — `"sorted ascending"` / `"sorted descending"` (and the announcement is repeated via the live region).
5. Use NVDA's *Browse Mode* toggle (<kbd>Insert</kbd>+<kbd>Space</kbd>) to switch to virtual cursor — table-reading shortcuts (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+arrows) should walk the grid as a native HTML table.

**VoiceOver (macOS, Safari):**
1. Enable VoiceOver: <kbd>⌘</kbd>+<kbd>F5</kbd>.
2. Use <kbd>VO</kbd>+<kbd>→</kbd> to enter the grid — you should hear the label and dimensions.
3. <kbd>VO</kbd>+<kbd>Shift</kbd>+<kbd>↓</kbd> enters interaction mode; then arrow keys navigate cells with full column + row context.
4. Rotor (<kbd>VO</kbd>+<kbd>U</kbd>) → *Tables* should list the grid for jump-navigation.

**JAWS (Windows):**
- Use *Virtual PC Cursor* (default) for browse, *Forms Mode* (<kbd>Enter</kbd>) for grid interaction. Table layer (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+arrows) walks cells.

**iOS VoiceOver / Android TalkBack:**
- Touch a cell — the column header, value, row/column position, and any selection/expand state are announced.
- Swipe right/left moves through cells; swipe up/down navigates by row.

**What you should *not* hear** — these would indicate a bug worth filing:
- Empty cell announcements (`"blank"`) when the cell has visible content
- Position announced as `"row 1 of 1"` when there are clearly more rows (virtualization metadata is wrong)
- Sort/filter changes never announced (live region not wired up)
- Editing committed without any feedback

## Reduced Motion

The grid respects the `prefers-reduced-motion: reduce` user preference automatically. When set, the grid:

- **Disables row animations** ([row animation API](/grid/core.md#row-animation) skips transitions)
- **Disables expand/collapse transitions** in tree, grouping, and master-detail plugins
- **Disables scroll smoothing** in programmatic `scrollToRow()` / `scrollToCell()` calls (jumps instead of animating)
- **Disables loading-state crossfade** (instant swap instead)

You don't need to do anything to opt in. If you have custom renderers or plugins that animate, honor the preference:

```css
@media (prefers-reduced-motion: reduce) {
  .my-cell-animation { transition: none; animation: none; }
}
```

## High Contrast Mode

### Using CSS Custom Properties

The grid's CSS variable system makes high contrast easy:

```css
/* High contrast theme */
tbw-grid.high-contrast {
  --tbw-color-bg: #000;
  --tbw-color-fg: #fff;
  --tbw-color-border: #fff;
  --tbw-color-header-bg: #1a1a1a;
  --tbw-color-row-hover: #333;
  --tbw-color-focus-ring: #ffff00;
  --tbw-color-accent: #00ffff;
}
```

### Pre-built Contrast Theme

```typescript
import '@toolbox-web/grid/themes/dg-theme-contrast.css';
```

### Windows High Contrast Mode

The grid ships with a built-in `@media (forced-colors: active)` block that remaps all theming variables to Windows system colors (`CanvasText`, `Canvas`, `Highlight`, `HighlightText`). Focus rings and selected rows are also overridden to use `Highlight`. No extra configuration needed — it works automatically.

If you write custom renderers, follow the same pattern so your cells stay legible under forced-colors:

```css
.my-status-badge {
  background: var(--tbw-color-accent);
  color: var(--tbw-color-on-accent);
  border: 1px solid transparent;
}

@media (forced-colors: active) {
  .my-status-badge {
    background: Canvas;
    color: CanvasText;
    border: 1px solid CanvasText; /* outline so the badge stays distinguishable */
    forced-color-adjust: none;     /* opt out of the browser's automatic remap */
  }
}
```

Key rules: prefer **system color keywords** (`CanvasText`, `Canvas`, `LinkText`, `ButtonText`, `Highlight`, `HighlightText`, `Mark`, `MarkText`) inside `forced-colors` media queries, and never rely on color alone — always pair with a border, underline, or icon.

## Accessibility-First Patterns

These patterns are how we keep the grid accessible — and how you should keep your customizations accessible too.

### 1. Never replace content with color alone

When you write a `renderer` for status, error, or category cells, give it text or an icon — not just a colored dot. The grid's built-in renderers (selection checkbox, sort indicator, expand chevron, filter button) all follow this rule.

```ts
// ❌ Color-only — invisible to screen readers, fails in forced-colors
{ field: 'status', renderer: ({ value }) => `<span class="dot dot-${value}"></span>` }

// ✅ Text + color — readable everywhere
{ field: 'status', renderer: ({ value }) => `<span class="dot dot-${value}" aria-label="${value}">${value}</span>` }
```

### 2. Don't suppress focus indicators

The default focus ring is 2px solid `var(--tbw-color-focus-ring)` using `outline` (not `border`) so it doesn't reflow. **Never** set `outline: none` without providing an equivalent indicator — WCAG 2.4.7 fails immediately, and forced-colors users lose the only signal they have.

### 3. Use semantic `<button>` / `<a>` inside renderers

Custom action buttons should be real `<button>` elements (or `<a href>` for navigation), not clickable `<div>`s. They inherit keyboard activation, focus, and screen-reader role for free. The grid's roving-tabindex pattern still works because the renderer is inside a `gridcell` — pressing <kbd>Enter</kbd> activates the button.

### 4. Label fragmented controls

If a cell renders multiple controls (e.g. edit + delete buttons), each needs a discrete accessible name — usually via `aria-label`:

```ts
viewRenderer: ({ row }) => `
  <button aria-label="Edit ${row.name}">✏️</button>
  <button aria-label="Delete ${row.name}">🗑️</button>
`
```

A bare emoji or icon has no name; screen readers announce `"button"` and the user can't tell them apart.

### 5. Don't override keyboard handling without consulting the WAI-ARIA pattern

The grid implements the [WAI-ARIA Grid Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/). If you stop event propagation in a `keydown` handler on a cell, you may break navigation. If you really need to override (rare), do it only for the specific keys you care about and let the rest bubble.

## WCAG Compliance Checklist

| Criterion | Level | Status | Notes |
|-----------|-------|--------|-------|
| 1.1.1 Non-text Content | A | ✅ | Icons have text alternatives |
| 1.3.1 Info and Relationships | A | ✅ | ARIA roles convey structure |
| 1.3.2 Meaningful Sequence | A | ✅ | DOM order matches visual order |
| 1.4.1 Use of Color | A | ✅ | Status not conveyed by color alone |
| 1.4.3 Contrast (Min) | AA | ✅ | Default theme meets 4.5:1 ratio |
| 1.4.11 Non-text Contrast | AA | ✅ | Focus indicators visible at 3:1 |
| 2.1.1 Keyboard | A | ✅ | All functions accessible via keyboard |
| 2.1.2 No Keyboard Trap | A | ✅ | Tab exits the grid; Escape exits editors |
| 2.4.3 Focus Order | A | ✅ | Logical focus order follows grid structure |
| 2.4.7 Focus Visible | AA | ✅ | Clear focus indicators |
| 4.1.2 Name, Role, Value | A | ✅ | ARIA attributes on all interactive elements |

## What's Automatic

The grid handles most accessibility concerns out of the box:

- **ARIA roles & attributes** — `role="grid"`, `role="row"`, `role="gridcell"`, `aria-rowcount`, `aria-colcount`, `aria-rowindex`, `aria-colindex` are always set. When the [Tree](/grid/plugins/tree.md) or [Row Grouping](/grid/plugins/grouping-rows.md) plugin is active, the rows-body upgrades to `role="treegrid"` and every row carries `aria-level` / `aria-setsize` / `aria-posinset` per the [WAI-ARIA Treegrid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/) so screen readers can announce hierarchical position.
- **Keyboard navigation** — Arrow keys, Tab, Enter, Home/End, PgUp/PgDn all work by default (core feature, not a plugin)
- **Column headers** — Always rendered with `role="columnheader"`; cannot be hidden
- **Column type inference** — Types (`number`, `date`, `boolean`) are auto-detected from the first data row
- **Grid label** — Auto-derived from the shell title (`<tbw-grid-header title="...">` or `shell.header.title` config)
- **Windows High Contrast** — `forced-colors` media query is built into core CSS
- **Live announcements** — Sort, filter, selection, grouping, and editing changes are announced via `aria-live` regions (configurable via `a11y` config)
- **Plugin ARIA** — Selection, editing, filtering, tree, and grouping plugins manage their own ARIA attributes (`aria-selected`, `aria-readonly`, `aria-expanded`, `aria-multiselectable`, `aria-description`)

## Developer Responsibilities

These require explicit action:

1. **Provide a grid label when not using a shell** — Without `<tbw-grid-header title="...">`, set `gridAriaLabel` in config, or set `gridAriaLabelledBy` to the `id` of an existing heading next to the grid (`aria-labelledby` wins per WAI-ARIA precedence and suppresses `aria-label` to avoid conflicting names), or add `aria-label` directly on the element
2. **Test with screen readers** — NVDA (Windows), VoiceOver (macOS), Orca (Linux)
3. **Don't override keyboard handling** — The grid follows WAI-ARIA patterns; custom key listeners may conflict
4. **Use the contrast theme for additional a11y** — Import `dg-theme-contrast.css` for higher contrast beyond the built-in forced-colors support

## See Also

  - [Selection Plugin](/grid/plugins/selection.md): Cell, row, and range selection with full keyboard support
  - [Editing Plugin](/grid/plugins/editing.md): Inline cell editing with Tab/Enter/Escape navigation
  - [Theming](/grid/guides/theming.md): CSS custom properties, high contrast mode, and visual customization
  - [WAI-ARIA Grid Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/): The W3C specification this grid follows
