From 60057e4777dc1b626812e3a9048c42bb388abfde Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 15 Aug 2026 18:49:36 -0700 Subject: [PATCH 1/3] test(website): drive tabBehavior="wrap-rows" in real engines, and correct the Tab claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wrap-rows` was a WCAG 2.1.2 keyboard trap — Tab and Shift+Tab consumed unconditionally, clamped at both corners, 120 presses never out. The fix was to RELEASE at the corners, and until now that fix rested on a jsdom test. jsdom has no sequential focus order at all, so it can only say which presses the surface calls `preventDefault()` on; "the grid did not consume the press" and "focus left the grid" are different statements, and only the second is the absence of a trap. No page on the site rendered a `wrap-rows` grid, so nobody had ever driven the one configuration that used to trap. Adds `/fixtures/tab-wrap-rows` (3 columns x 4 rows, `wrap-rows`, bracketed by two text inputs) and a spec that asserts EXACT press counts in Chromium and WebKit. Text inputs rather than buttons as sentinels: macOS keeps bare buttons out of Safari's tab order while CI's Linux WebKit includes them, so a button sentinel pins an operating system. Naming the landing element is what distinguishes a one-press release from a lap of the document. Measured, identical in both engines: - Tab from the top-left cell: 12 presses out — exactly rows x columns. - Tab from mid-grid (r2,bravo): 8 presses out. - Shift+Tab from the top-left cell: 1 press out. - Shift+Tab from (r3,charlie): 9 presses out. - Tab / Shift+Tab from a header cell, first column or last: 1 press, both directions. So the header table's "in one press, whatever `tabBehavior` is set to" is TRUE — it is scoped to the header, and the surface hands Tab back on a header cell before it looks at the column index. What was false is the page's opening summary, which promised "one more Tab leaves" without qualification: under `wrap-rows` that is 8 and 12 presses on a grid of twelve cells. That sentence now names the default and points at the walk, the header row names its own scope, and the round-trip sentence says the grid restores the cell it left FROM — the release corner, not where the walk started. Mutation-proved by restoring the corner clamp in `pretable-surface.tsx`: 10 of the 18 tests fail (5 per engine, `presses: null`), and the four header tests per engine keep passing, which is correct — the header release is a different branch. Reverted; no `packages/` change ships here. Co-Authored-By: Claude Opus 5 --- .../app/fixtures/tab-wrap-rows/page.tsx | 112 ++++++++ apps/website/content/docs/grid/keyboard.mdx | 17 +- apps/website/e2e/grid-tab-wrap-rows.spec.ts | 261 ++++++++++++++++++ 3 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 apps/website/app/fixtures/tab-wrap-rows/page.tsx create mode 100644 apps/website/e2e/grid-tab-wrap-rows.spec.ts diff --git a/apps/website/app/fixtures/tab-wrap-rows/page.tsx b/apps/website/app/fixtures/tab-wrap-rows/page.tsx new file mode 100644 index 00000000..513143d0 --- /dev/null +++ b/apps/website/app/fixtures/tab-wrap-rows/page.tsx @@ -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 `