Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/website/app/fixtures/tab-wrap-rows/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import { PretableSurface, type PretableColumn } from "@pretable/react";
import { useMemo } from "react";

/**
* Test fixture for `apps/website/e2e/grid-tab-wrap-rows.spec.ts`.
*
* `tabBehavior="wrap-rows"` was a WCAG 2.1.2 keyboard trap: it consumed Tab
* and Shift+Tab unconditionally and clamped at the two corners, so 120
* consecutive presses never left the grid in either engine. The fix was to
* RELEASE at the corners. Every claim the docs make about that release — that
* Tab eventually leaves forward, that Shift+Tab leaves backward, that a header
* cell leaves in one press — is a claim about a real browser's sequential
* focus order, and nothing on the docs site renders a `wrap-rows` grid to
* drive it in one.
*
* jsdom cannot stand in. It has no sequential focus order at all: `Tab` is an
* ordinary keydown there and nothing traverses unless a handler moves it by
* hand, so `packages/react/src/__tests__/tab-behavior.test.tsx` can only assert
* which presses the surface calls `preventDefault()` on. "Not prevented" and
* "focus actually left the grid" are different statements, and only the second
* one is the absence of a trap.
*
* ## Why it is laid out this way
*
* **Text inputs, not buttons, as the sentinels either side.** The exit tests
* assert *which element* focus landed on, and a bare `<button>` cannot carry
* that: macOS keeps bare buttons out of Safari's sequential tab order unless
* Full Keyboard Access is on, while Playwright's Linux WebKit in CI includes
* them — a button sentinel would pin somebody's operating system rather than
* this grid (the same split that failed CI in
* `grid-keyboard-a11y.spec.ts`). A text field is a tab stop in every engine
* under every setting.
*
* **A landing element at all, rather than "focus is no longer in the grid".**
* A tab walk wraps the document — WebKit laps a page several times in 30
* presses — so a counter that only asks "outside yet?" cannot tell a
* one-stop exit from a full lap back around. Naming the sentinel makes the
* lap visible.
*
* **Three columns and four rows.** A wrap-rows exit costs up to rows ×
* columns presses, so the shape is what makes the bound a number instead of
* "eventually": 12 cells means the walk from the entry cell to the release
* corner is exactly 12 presses, and a regression that clamps again cannot hide
* inside a generous limit. It is deliberately not the 140-row demo grid the
* keyboard docs use — that grid's corner is 1,000+ presses away, which is only
* assertable as a timeout.
*
* **Every row is inside `viewportHeight`.** Each press of the walk moves the
* focus address, and each move runs scroll-into-view; a shape taller than its
* viewport would put a scroll and a re-render between consecutive presses for
* no gain, since the corner release has nothing to do with virtualization.
*
* Deliberately not part of the product surface, and kept out of search engines:
* `wrap-rows` is not the default and this page is not advice.
*/

interface WrapRow {
id: string;
alpha: string;
bravo: string;
charlie: number;
}

// The next two are the shape the spec's press counts are derived from — four
// rows by three columns — and the ids it addresses cells by. They are spelled
// out again there rather than imported: a Next page module may not export
// anything but its component and route config, so changing either here means
// changing the constants at the top of grid-tab-wrap-rows.spec.ts too.
const ROW_IDS = ["r1", "r2", "r3", "r4"] as const;

const COLUMNS: PretableColumn<WrapRow>[] = [
{ id: "alpha", header: "Alpha", widthPx: 120 },
{ id: "bravo", header: "Bravo", widthPx: 120 },
{ id: "charlie", header: "Charlie", type: "number", widthPx: 120 },
];

function makeRows(): WrapRow[] {
return ROW_IDS.map((id, i) => ({
id,
alpha: `A${i + 1}`,
bravo: i % 2 === 0 ? "West" : "East",
charlie: (i + 1) * 7,
}));
}

export default function TabWrapRowsFixturePage() {
const rows = useMemo(() => makeRows(), []);
const columns = useMemo(() => COLUMNS, []);
return (
<main style={{ padding: 24 }}>
<h1 style={{ fontSize: 18, marginBottom: 12 }}>Tab wrap-rows fixture</h1>
<p style={{ marginBottom: 12 }}>
<label htmlFor="before-grid">Before the grid </label>
<input id="before-grid" name="before-grid" type="text" />
</p>
<PretableSurface<WrapRow>
ariaLabel="Tab wrap-rows fixture grid"
columns={columns}
getRowId={(row) => row.id}
rows={rows}
tabBehavior="wrap-rows"
viewportHeight={320}
/>
<p style={{ marginTop: 12 }}>
<label htmlFor="after-grid">After the grid </label>
<input id="after-grid" name="after-grid" type="text" />
</p>
</main>
);
}
17 changes: 11 additions & 6 deletions apps/website/content/docs/grid/keyboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Full keyboard contract — 2D arrow nav, shift-extend, cmd-jump, T
nav: Grid
---

`<PretableSurface>` ships the full ARIA grid keyboard pattern out of the box. The grid is a single tab stop — **header included**: one Tab enters it, the arrow keys navigate inside it, and one more Tab leaves. Nothing below needs additional wiring. `Cmd/Ctrl` is treated as either platform's primary modifier.
`<PretableSurface>` ships the full ARIA grid keyboard pattern out of the box. The grid is a single tab stop — **header included**: one Tab enters it, and the arrow keys navigate inside it. On the default `tabBehavior="exit"` one more Tab leaves. [`"wrap-rows"`](#tabbehavior-config) spends Tab on the walk to a release corner instead, so from a body cell leaving costs up to rows × columns presses — though from the header it is one press either way, whichever behavior is set. Nothing below needs additional wiring. `Cmd/Ctrl` is treated as either platform's primary modifier.

<Callout type="note">
The header is part of the same roving group as the body, so the per-column
Expand Down Expand Up @@ -62,7 +62,7 @@ The header is a row of the same focus model, reached with `↑` from the first d
| `Shift + Enter` | Add this column to a multi-column sort, as shift-clicking does. |
| `Alt + ↓` | Open this column's [filter](/docs/grid/filtering) popover. `Esc` closes it and returns focus here. |
| `Shift + F10` | Open this column's menu, where one is offered. `ContextMenu` does the same. |
| `Tab` / `Shift + Tab` | Leave the grid, in one press, whatever `tabBehavior` is set to. |
| `Tab` / `Shift + Tab` | Leave the grid in one press, `"wrap-rows"` included — the wrap walk is a body behavior. |

`Alt + ↓` is scoped to the header: on a data cell `Alt + ↓` is still an ordinary move-down, and no binding in the table above uses `Alt`.

Expand Down Expand Up @@ -91,12 +91,17 @@ Resizing and column reordering stay pointer affordances — there is no key for
```

<Callout type="warning">
`"wrap-rows"` has to walk to a corner before it can release, which is up to
rows × columns presses. That is not a keyboard trap, but it is a long walk —
prefer the default on anything larger than a small, form-like grid.
`"wrap-rows"` has to walk to a corner before it can release, and that walk is
what Tab costs. Leaving **forward** from the top-left cell is exactly rows ×
columns presses — every cell in the grid — because that corner is the furthest
point from the forward release; leaving **backward** from the same cell is one
press, because it is the backward release corner. Every cell between the two
is somewhere in between. That is not a keyboard trap, but on anything larger
than a small, form-like grid it is a long enough walk to feel like one —
prefer the default there.
</Callout>

Both behaviors keep the grid a single tab stop, and both preserve the focused cell across a round trip: once Tab leaves, the grid remembers where it was, and coming back via Shift+Tab restores focus to that exact cell. From the header, `Tab` always releases — a header cell has no wrap walk to do.
Both behaviors keep the grid a single tab stop, and both preserve the focused cell across a round trip: once Tab leaves, the grid remembers where it was, and coming back via Shift+Tab restores focus to the cell it left **from**. Under `"wrap-rows"` that is the release corner rather than wherever the walk started, since every press in between moved the cursor. From the header, `Tab` releases in one press in either direction, under both behaviors — a header cell has no wrap walk to do.

## Single tab stop / focus model

Expand Down
Loading