Word-style Tab navigation in tables with an accessible exit (#338) #343

Merged
fable-5 merged 1 commits from issue-338-tab-navigation into main 2026-08-16 05:22:18 +02:00
5 changed files with 126 additions and 3 deletions

View File

@ -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 // Inserting into the empty page replaces the placeholder paragraph — the
// table really is the only block, which is the situation of issue #335. // table really is the only block, which is the situation of issue #335.
await expect(content.locator(':scope > p')).toHaveCount(0); 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 // Keyboard only: ArrowUp from the first cell lands on the gap cursor
// before the table; typing there materializes a paragraph. // before the table; typing there materializes a paragraph.
await content.locator('th').first().click(); 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 page.keyboard.press('ArrowUp');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('above'); await page.keyboard.type('above');
await expect(content.locator(':scope > :first-child')).toHaveText('above'); await expect(content.locator(':scope > :first-child')).toHaveText('above');
// Same for the position after the table. // Same for the position after the table.
await content.locator('td').last().click(); 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 page.keyboard.press('ArrowDown');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('below'); await page.keyboard.type('below');
await expect(content.locator(':scope > :last-child')).toHaveText('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 }); const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i });
await expect(mergeButton).toBeDisabled(); await expect(mergeButton).toBeDisabled();
await expect(splitButton).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 // Extending the selection across the cell border turns it into a cell
// selection (prosemirror-tables), which is what merge operates on. // 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(); 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 }) => { test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`); const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);

View File

@ -1,4 +1,6 @@
import { Node } from '@tiptap/core'; 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 type { Node as PMNode, Schema } from 'prosemirror-model';
import { import {
addColumnAfter, addColumnAfter,
@ -8,6 +10,7 @@ import {
deleteColumn, deleteColumn,
deleteRow, deleteRow,
deleteTable, deleteTable,
goToNextCell,
mergeCells, mergeCells,
splitCell, splitCell,
tableEditing, tableEditing,
@ -38,6 +41,8 @@ declare module '@tiptap/core' {
deleteTable: () => ReturnType; deleteTable: () => ReturnType;
mergeCells: () => ReturnType; mergeCells: () => ReturnType;
splitCell: () => ReturnType; splitCell: () => ReturnType;
goToNextCell: () => ReturnType;
goToPreviousCell: () => ReturnType;
toggleHeaderRow: () => ReturnType; toggleHeaderRow: () => ReturnType;
}; };
} }
@ -69,6 +74,37 @@ export const Table = Node.create({
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [tableEditing()]; 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() { addCommands() {
return { return {
insertTable: insertTable:
@ -113,6 +149,14 @@ export const Table = Node.create({
() => () =>
({ state, dispatch }) => ({ state, dispatch }) =>
splitCell(state, dispatch), splitCell(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) =>
goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) =>
goToNextCell(-1)(state, dispatch),
toggleHeaderRow: toggleHeaderRow:
() => () =>
({ state, dispatch }) => ({ state, dispatch }) =>

View File

@ -227,7 +227,9 @@ function PageEditor({
attributes: { attributes: {
role: canEdit ? 'textbox' : 'document', role: canEdit ? 'textbox' : 'document',
'aria-label': t('contentLabel'), 'aria-label': t('contentLabel'),
...(canEdit ? { 'aria-multiline': 'true' } : {}), ...(canEdit
? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' }
: {}),
}, },
}, },
}); });
@ -361,6 +363,13 @@ function PageEditor({
/> />
)} )}
<EditorContent editor={editor} className="editor-content" /> <EditorContent editor={editor} className="editor-content" />
{/* Referenced via aria-describedby in edit mode: Tab is captured
inside tables (#338), so the way out must be discoverable. */}
{canEdit && (
<p id="editor-keyboard-hint" className="visually-hidden">
{t('keyboardHint')}
</p>
)}
{canEdit && <WikilinkAutocomplete editor={editor} />} {canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />} {canEdit && <MentionAutocomplete editor={editor} />}
</div> </div>

View File

@ -194,5 +194,6 @@
"due": "Zieldatum", "due": "Zieldatum",
"start": "Startdatum" "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."
} }

View File

@ -194,5 +194,6 @@
"due": "Due date", "due": "Due date",
"start": "Start 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."
} }