From c17ab41a3399f8f116e64926734a7bc8c22981b7 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 15 Aug 2026 21:06:07 +0200 Subject: [PATCH] Word-style Tab navigation in tables with an accessible exit (#338) Tab used to fall through to the browser's focus navigation everywhere. Inside tables it now moves cell-wise (Shift-Tab backwards) and appends a new row from the last cell, Word-style. Outside tables every branch returns false, so Tab keeps leaving the editor. Capturing Tab inside tables needs a documented way out (WCAG 2.1.2): Escape places the cursor after the table -- unlike the arrow keys, which reach the gap cursor (#335) only from the table's edge cells, it works from every cell, including from a cell selection. When no textblock follows the table it falls back to the gap cursor position. The mechanism is announced to assistive tech via an aria-describedby hint on the editor surface (visually hidden, de+en). e2e: cell round trip per Tab/Shift-Tab with typed markers, row append from the last cell, and the full keyboard-only exit (Escape, then Tab leaves the editor). The table specs now settle briefly after the insert -- right after it the collab sync can swallow a click's selection update, which had the markers landing in stale selections. Closes #338 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9 --- apps/web/e2e/editor.spec.ts | 68 +++++++++++++++++++++++++++ apps/web/src/editor/nodes/table.ts | 44 +++++++++++++++++ apps/web/src/pages/PageEditorPage.tsx | 11 ++++- packages/shared/i18n/de/editor.json | 3 +- packages/shared/i18n/en/editor.json | 3 +- 5 files changed, 126 insertions(+), 3 deletions(-) diff --git a/apps/web/e2e/editor.spec.ts b/apps/web/e2e/editor.spec.ts index fe84d6b..c4e018b 100644 --- a/apps/web/e2e/editor.spec.ts +++ b/apps/web/e2e/editor.spec.ts @@ -79,17 +79,28 @@ test('gap cursor reaches positions before and after a lone table (issue #335)', // Inserting into the empty page replaces the placeholder paragraph — the // table really is the only block, which is the situation of issue #335. await expect(content.locator(':scope > p')).toHaveCount(0); + // Right after the insert the collab sync can still swallow a click's + // selection update; interact only against a settled editor (established + // pattern, see a11y.spec.ts). The typed markers below verify each click + // really placed the cursor where the locator points. + await page.waitForTimeout(500); // Keyboard only: ArrowUp from the first cell lands on the gap cursor // before the table; typing there materializes a paragraph. await content.locator('th').first().click(); + await page.keyboard.type('in'); + await expect(content.locator('th').first()).toHaveText('in'); await page.keyboard.press('ArrowUp'); + await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1); await page.keyboard.type('above'); await expect(content.locator(':scope > :first-child')).toHaveText('above'); // Same for the position after the table. await content.locator('td').last().click(); + await page.keyboard.type('z'); + await expect(content.locator('td').last()).toHaveText('z'); await page.keyboard.press('ArrowDown'); + await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1); await page.keyboard.type('below'); await expect(content.locator(':scope > :last-child')).toHaveText('below'); @@ -115,6 +126,8 @@ test('cells can be merged and split from the toolbar (issue #337)', async ({ bro const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i }); await expect(mergeButton).toBeDisabled(); await expect(splitButton).toBeDisabled(); + // Settle before clicking into cells — see the gap cursor test. + await page.waitForTimeout(500); // Extending the selection across the cell border turns it into a cell // selection (prosemirror-tables), which is what merge operates on. @@ -141,6 +154,61 @@ test('cells can be merged and split from the toolbar (issue #337)', async ({ bro await context.close(); }); +test('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({ + browser, +}) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + const status = page.locator('.editor-connection'); + await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 }); + + const content = page.locator('.ProseMirror'); + await content.click(); + await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click(); + const rows = content.locator('tr'); + await expect(rows).toHaveCount(3); + // Settle before clicking into cells — see the gap cursor test. + await page.waitForTimeout(500); + + // Tab moves to the next cell, Shift+Tab back. Typed markers prove where + // the cursor really is (the typing assertions also settle the editor + // between keypresses — see the merge test on click/key races). + await content.locator('th').first().click(); + await page.keyboard.type('one'); + await expect(content.locator('th').first()).toHaveText('one'); + await page.keyboard.press('Tab'); + await page.keyboard.type('two'); + await expect(content.locator('th').nth(1)).toHaveText('two'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.type('back'); + await expect(content.locator('th').first()).toContainText('back'); + + // Tab in the last cell appends a row and moves into it (Word behavior). + const lastCell = rows.nth(2).locator('td').nth(2); + await lastCell.click(); + await page.keyboard.type('z'); + await expect(lastCell).toHaveText('z'); + await page.keyboard.press('Tab'); + await expect(rows).toHaveCount(4); + await page.keyboard.type('new'); + await expect(rows.nth(3).locator('td').first()).toHaveText('new'); + + // No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap + // cursor is only reachable per arrow key from edge cells) and places the + // cursor after the table; once outside, Tab leaves the editor entirely. + // The mechanism is announced via the editor's aria-describedby hint. + await page.keyboard.press('Escape'); + await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1); + await page.keyboard.press('Tab'); + await expect(content).not.toBeFocused(); + + await context.close(); +}); + test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`); diff --git a/apps/web/src/editor/nodes/table.ts b/apps/web/src/editor/nodes/table.ts index e61868a..8aabe90 100644 --- a/apps/web/src/editor/nodes/table.ts +++ b/apps/web/src/editor/nodes/table.ts @@ -1,4 +1,6 @@ import { Node } from '@tiptap/core'; +import { GapCursor } from '@tiptap/pm/gapcursor'; +import { Selection } from '@tiptap/pm/state'; import type { Node as PMNode, Schema } from 'prosemirror-model'; import { addColumnAfter, @@ -8,6 +10,7 @@ import { deleteColumn, deleteRow, deleteTable, + goToNextCell, mergeCells, splitCell, tableEditing, @@ -38,6 +41,8 @@ declare module '@tiptap/core' { deleteTable: () => ReturnType; mergeCells: () => ReturnType; splitCell: () => ReturnType; + goToNextCell: () => ReturnType; + goToPreviousCell: () => ReturnType; toggleHeaderRow: () => ReturnType; }; } @@ -69,6 +74,37 @@ export const Table = Node.create({ addProseMirrorPlugins() { return [tableEditing()]; }, + addKeyboardShortcuts() { + return { + // Word-style navigation (issue #338): Tab moves cell-wise and appends + // a new row from the last cell. Outside a table every branch returns + // false, so Tab keeps its browser default (focus moves on) and the + // editor is no keyboard trap — from inside a table the arrow keys + // lead out via the gap cursor (#335), then Tab leaves the editor. + Tab: () => { + if (this.editor.commands.goToNextCell()) return true; + if (!this.editor.can().addRowAfter()) return false; + return this.editor.chain().addRowAfter().goToNextCell().run(); + }, + 'Shift-Tab': () => this.editor.commands.goToPreviousCell(), + // The documented exit (aria-describedby hint, #338): the gap cursor is + // only reachable per arrow key from the table's edge cells, so Escape + // is the exit that works from EVERY cell. Falls back to a gap cursor + // when no textblock follows the table (#335 guarantees the position). + Escape: () => + this.editor.commands.command(({ state, dispatch }) => { + const { $head } = state.selection; + for (let depth = $head.depth; depth > 0; depth -= 1) { + if ($head.node(depth).type.spec.tableRole !== 'table') continue; + const $after = state.doc.resolve($head.after(depth)); + const selection = Selection.findFrom($after, 1, true) ?? new GapCursor($after); + if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView()); + return true; + } + return false; + }), + }; + }, addCommands() { return { insertTable: @@ -113,6 +149,14 @@ export const Table = Node.create({ () => ({ state, dispatch }) => splitCell(state, dispatch), + goToNextCell: + () => + ({ state, dispatch }) => + goToNextCell(1)(state, dispatch), + goToPreviousCell: + () => + ({ state, dispatch }) => + goToNextCell(-1)(state, dispatch), toggleHeaderRow: () => ({ state, dispatch }) => diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 3fbdd8f..ccfc9fa 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -227,7 +227,9 @@ function PageEditor({ attributes: { role: canEdit ? 'textbox' : 'document', 'aria-label': t('contentLabel'), - ...(canEdit ? { 'aria-multiline': 'true' } : {}), + ...(canEdit + ? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' } + : {}), }, }, }); @@ -361,6 +363,13 @@ function PageEditor({ /> )} + {/* Referenced via aria-describedby in edit mode: Tab is captured + inside tables (#338), so the way out must be discoverable. */} + {canEdit && ( +

+ {t('keyboardHint')} +

+ )} {canEdit && } {canEdit && } diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index b7420bb..b4b0295 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -194,5 +194,6 @@ "due": "Zieldatum", "start": "Startdatum" }, - "contentLabel": "Seiteninhalt" + "contentLabel": "Seiteninhalt", + "keyboardHint": "In Tabellen wechselt die Tabulatortaste zur nächsten Zelle und legt in der letzten Zelle eine neue Zeile an; Umschalt+Tab geht zurück. Escape stellt den Cursor hinter die Tabelle; außerhalb von Tabellen verlässt die Tabulatortaste den Editor." } diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index aee4a86..76013f8 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -194,5 +194,6 @@ "due": "Due date", "start": "Start date" }, - "contentLabel": "Page content" + "contentLabel": "Page content", + "keyboardHint": "Inside tables, Tab moves to the next cell and creates a new row from the last cell; Shift+Tab moves back. Escape places the cursor after the table; outside tables, Tab leaves the editor." }