sveltelean Table
Versionv0.2.0 GitHub

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
<div class="slean-table" id="members">
	<div class="slean-table-toolbar">
		<input class="slean-table-search" type="search" aria-label="Search rows" …>
		<span class="slean-table-selected" aria-live="polite">2 selected</span>
	</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> … <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. 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.

KeyWhenResult
Tab/Shift+TabanywhereMoves through search, header buttons, resize handles, filters, row controls, the body (one stop in grid mode) and the pager.
Enter/Spaceon a sort buttonCycles the sort; with Shift, adds the column to a multi-sort.
ArrowLeft/ArrowRighton a resize handleNarrows or widens the column by ten pixels.
Spaceon a row checkboxToggles the row (native).
Enter/Spaceon a focused row, with onRowClickRuns 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/ArrowDownon a row radioMoves the single selection (native radio group; needs the id prop).
Arrow keyson a cell in grid modeMoves between data cells by structure; mirrored in RTL.
Enter/F2on an editable cellOpens the editor; Enter saves and Escape cancels inside it.
Arrow keys/Page keyson the height-limited regionScrolls 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
<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

NameOfTypeDefaultDescription
labelPresentationstring'Data table'Accessible name of the scroll region and the <table>.
captionPresentationstringundefinedVisible native <caption>.
idData and identitystringundefinedid 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.
messagesPresentationPartial<TableMessages>enOverrides of the visible and accessible strings; missing keys fall back to en.
heightPresentationstringundefinedmax-height of the scroll region; with it the region is a Tab stop.
selectRowTableMessagesstring'Select row'Prefix of each row control label (followed by the row id).
selectAllTableMessagesstring'Select all eligible rows'Label of the header checkbox.
selectionHeaderTableMessagesstring'Selection'Visually hidden header text of the selection column when it has no select-all checkbox.
expandHeaderTableMessagesstring'Details'Visually hidden header text of the expand column (expandColumn).
pageNumberTableMessages(page: number) => string(page) => `Page ${page}`Accessible name of a numbered page button.
rowLabelControls(row: T) => stringthe row idAccessible name of a row in its selection, expand and move controls.
onRowClickEvents and composition(row: T, event: MouseEvent | KeyboardEvent, index: number) => voidundefinedRuns 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.
sortOrderTableMessagesstring'Sort order'Read to assistive technology before the multi-sort precedence number.
pinTableMessagesstring'Pin'Prefix of the pin select label.
filterTableMessages(column: string) => string(column) => `Filter ${column}`Label of a column filter input.