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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
This commit is contained in:
parent
d76c731473
commit
753338f1be
@ -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()}`);
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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({
|
||||
/>
|
||||
)}
|
||||
<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 && <MentionAutocomplete editor={editor} />}
|
||||
</div>
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user