# Accessibility

> Native table semantics, aria-sort, selection announcements, keyboard paths, what the application must supply, and how it is tested.

Page: https://svelte-lean.vercel.app/table/accessibility · Markdown: https://svelte-lean.vercel.app/table/accessibility.md · Index: https://svelte-lean.vercel.app/llms.txt

The renderer emits a native table with the semantics assistive technology already understands, adds ARIA only where HTML has no answer, and is tested for behavior in a browser. The application supplies the names.

- **Source**: data-table.sveltestyle.css
- **Tests**: data-table.dom.test.tsdata-table.ssr.test.tstable.spec.ts (browser)aria.ts (axe in the playground)
- **Fixture**: fixtures/table-basic

## Native semantics

Output is `<table>`, `<caption>`, `<colgroup>`, `<thead>`, `<tbody>` and `<tfoot>`. Header cells carry `scope="col"`, group headers `scope="colgroup"`, group rows are a header cell with `scope="row"`. Sort controls are `<button>`s, selection controls are native checkboxes and radios, the editor is a native input or select. Because the elements are native, focus, activation keys, form participation and the disabled state come from the browser. What follows is the shape of the rendered markup with the accessible names filled in.

`rendered shape`

```html
<div class="slean-table" id="members">
	<div class="slean-table-toolbar">
		<input class="slean-table-search" type="search" aria-label="Search rows" …>
	</div>
	<div class="slean-table-scroll" role="region" aria-label="Members" tabindex="0">   <!-- tabindex only with height -->
		<table aria-label="Members" aria-busy="false">
			<caption>Members by team</caption>
			<colgroup>…</colgroup>
			<thead>
				<tr>
					<th scope="col" rowspan="1" class="slean-table-selection"><input type="checkbox" data-slean="checkbox" aria-label="Select all eligible rows"></th>
					<th scope="col" data-column="name" aria-sort="ascending"><button type="button" class="slean-table-sort">Member …</button></th>
					<th scope="colgroup" colspan="2">Organization</th>
				</tr>
			</thead>
			<tbody>
				<tr data-row-id="1" data-selected="true">
					<td class="slean-table-selection"><input type="checkbox" data-slean="checkbox" aria-label="Select row: Amara Okafor" checked></td>
					<td data-cell data-column="name">Amara Okafor</td>
				</tr>
			</tbody>
			<tfoot>…</tfoot>
		</table>
	</div>
	<div class="slean-table-pagination"><span aria-live="polite">Page 1 of 3 · 24 rows · <span class="slean-table-selected">2 selected</span></span> … <button aria-label="Page 1" aria-current="page">1</button> …</div>
</div>
```

## ARIA

- `aria-sort` on the sorted header; with several rules the precedence number is preceded by the visually hidden `sortOrder` message.
- `aria-label` on the scroll region and the table (`label`), on every control (from the messages), on the pin select ("Pin: Team", not one of its options).
- `aria-live="polite"` on the selected count and the pager status; `role="status"` on the loading line; `role="alert"` on the error and the editor's validation message.
- `aria-expanded` on row and group toggles; `aria-busy` on the table while loading or fetching, never while only `refreshing` (a quiet refresh is not announced). Skeleton rows are `aria-hidden`; the loading message is announced instead.
- The selection and expand columns are never empty headers: without a select-all checkbox the selection header holds the visually hidden `selectionHeader` message, and the expand column the `expandHeader` message.
- Row controls are named after the row: `rowLabel` returns the name ("Select row: Amara Okafor"); without it the row id is used.
- `aria-current="page"` on the current numbered page. In manual mode with a known `rowCount`, `aria-rowcount` on the table and `aria-rowindex` on rows give the position within the whole result.
- No `role="grid"`, no `aria-selected` on rows: the native inputs already convey selection, and `data-selected` exists for styling.

## Keyboard

In a plain table every control is an ordinary Tab stop. With editing or range selection the body follows the grid pattern of the ARIA Authoring Practices: one Tab stop, arrow keys inside, focus returned to the cell after an edit.

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | anywhere | Moves through search, header buttons, resize handles, filters, row controls, the body (one stop in grid mode) and the pager. |
| `Enter`/`Space` | on a sort button | Cycles the sort; with Shift, adds the column to a multi-sort. |
| `ArrowLeft`/`ArrowRight` | on a resize handle | Narrows or widens the column by ten pixels. |
| `Space` | on a row checkbox | Toggles the row (native). |
| `Enter`/`Space` | on a focused row, with onRowClick | Runs onRowClick, as a click would. Rows are Tab stops only outside grid mode; keys on a control inside the row stay with the control. |
| `ArrowUp`/`ArrowDown`/`Home`/`End` | on a row grip, with onRowMove | Moves the row one place up or down, or to the first or last place on the page; focus stays on its grip. |
| `Escape` | while a row is dragged | Cancels the drag; every row slides back. |
| `ArrowUp`/`ArrowDown` | on a row radio | Moves the single selection (native radio group; needs the id prop). |
| `Arrow keys` | on a cell in grid mode | Moves between data cells by structure; mirrored in RTL. |
| `Enter`/`F2` | on an editable cell | Opens the editor; Enter saves and Escape cancels inside it. |
| `Arrow keys`/`Page keys` | on the height-limited region | Scrolls the region (it is a Tab stop only when a height is set). |

## What the application supplies

The table cannot invent names. It needs a `label` that says what the rows are, a `caption` when the context is not obvious, column `header` text that stands on its own as a control label ("Filter Team", "Pin: Team"), an `id` when single selection is used, and messages in the user's language. Content inside `cell` snippets and the `empty` snippet is the application's markup and must be accessible on its own: an icon needs a label, a control needs a name.

`what to pass`

```svelte
<DataTable
	label="Open invoices"                 <!-- the name of the region and the table -->
	caption="Invoices due this month"     <!-- a visible caption -->
	id="invoices"                         <!-- required with selection="single" -->
	rowLabel={(row) => row.customer}      <!-- "Select row: Acme" instead of "Select row: 42" -->
	messages={de}                         <!-- every string in the user's language -->
	…
/>
```

## Visual

- Focus uses `:focus-visible` with the shared focus ring; it is never removed.
- Selected rows and cell ranges use `Highlight` and `HighlightText` under `forced-colors: active`; disabled controls use the platform's disabled rendering.
- Motion is limited to short transitions on buttons and stops under `prefers-reduced-motion`; the table has no animation of its own.
- Nothing is conveyed by color alone: sort direction is an icon plus `aria-sort`, selection is a checked input, validation is text.

## How it is tested

The DOM suite (`data-table.dom.test.ts`) mounts the component under happy-dom and checks behavior: sort on header click and the multi-sort precedence exposed to assistive technology, checkbox and header selection, single-selection radios sharing the table id, one Tab stop in the body with edit buttons excluded, focus returning to the cell after Escape, Cancel and Save, row-based navigation over spans and detail rows, direction following the document, and the scroll region being a Tab stop only when height-limited. The server suite renders the table without a DOM and guards against browser-global access. The playground runs the same interactions in a real browser and an axe scan; serious and critical violations fail the suite. Screen-reader runs are not part of the suite and are not claimed.

## API

| Name | Of | Type | Default | Description |
| --- | --- | --- | --- | --- |
| [`label`](https://svelte-lean.vercel.app/table/api.md#prop-label) | Presentation | `string` | `'Data table'` | Accessible name of the scroll region and the \<table\>. |
| [`caption`](https://svelte-lean.vercel.app/table/api.md#prop-caption) | Presentation | `string` | `undefined` | Visible native \<caption\>. |
| [`id`](https://svelte-lean.vercel.app/table/api.md#prop-id) | Data and identity | `string` | `undefined` | id of the root element. With selection: 'single' it also names the row radios (\<id\>-selection) so they form one native radio group. Authored, never generated. |
| [`messages`](https://svelte-lean.vercel.app/table/api.md#prop-messages) | Presentation | `Partial<TableMessages>` | `en` | Overrides of the visible and accessible strings; missing keys fall back to en. |
| [`height`](https://svelte-lean.vercel.app/table/api.md#prop-height) | Presentation | `string` | `undefined` | max-height of the scroll region; with it the region is a Tab stop. |
| [`selectRow`](https://svelte-lean.vercel.app/table/api.md#message-selectrow) | TableMessages | `string` | `'Select row'` | Prefix of each row control label (followed by the row id). |
| [`selectAll`](https://svelte-lean.vercel.app/table/api.md#message-selectall) | TableMessages | `string` | `'Select all eligible rows'` | Label of the header checkbox. |
| [`selectionHeader`](https://svelte-lean.vercel.app/table/api.md#message-selectionheader) | TableMessages | `string` | `'Selection'` | Visually hidden header text of the selection column when it has no select-all checkbox. |
| [`expandHeader`](https://svelte-lean.vercel.app/table/api.md#message-expandheader) | TableMessages | `string` | `'Details'` | Visually hidden header text of the expand column (expandColumn). |
| [`pageNumber`](https://svelte-lean.vercel.app/table/api.md#message-pagenumber) | TableMessages | `(page: number) => string` | `` (page) => `Page ${page}` `` | Accessible name of a numbered page button. |
| [`rowLabel`](https://svelte-lean.vercel.app/table/api.md#prop-rowlabel) | Controls | `(row: T) => string` | `the row id` | Accessible name of a row in its selection, expand and move controls. |
| [`onRowClick`](https://svelte-lean.vercel.app/table/api.md#prop-onrowclick) | Events and composition | `(row: T, event: MouseEvent \| KeyboardEvent, index: number) => void` | `undefined` | Runs for clicks outside controls (button, a, input, select, textarea, label, summary, contenteditable, menuitem, \[data-action\], \[data-no-row-click\]). Rows become Tab stops answering Enter and Space unless the table has cell navigation. |
| [`sortOrder`](https://svelte-lean.vercel.app/table/api.md#message-sortorder) | TableMessages | `string` | `'Sort order'` | Read to assistive technology before the multi-sort precedence number. |
| [`pin`](https://svelte-lean.vercel.app/table/api.md#message-pin) | TableMessages | `string` | `'Pin'` | Prefix of the pin select label. |
| [`filter`](https://svelte-lean.vercel.app/table/api.md#message-filter) | TableMessages | `(column: string) => string` | `` (column) => `Filter ${column}` `` | Label of a column filter input. |

## Related

- [Accessibility policy](https://svelte-lean.vercel.app/docs/architecture/accessibility.md)
- [Data Grid](https://svelte-lean.vercel.app/table/data-grid.md)
- [Localization & RTL](https://svelte-lean.vercel.app/table/localization-rtl.md)
