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({
/>
)}
+ {t('keyboardHint')} +
+ )} {canEdit &&