dorfteich/apps/web/src/editor/nodes/table.ts
Claude Fable 5 753338f1be
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m56s
CI / Auth e2e pack (pull_request) Failing after 2m26s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 5m47s
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
2026-08-15 21:06:07 +02:00

199 lines
6.2 KiB
TypeScript

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,
addColumnBefore,
addRowAfter,
addRowBefore,
deleteColumn,
deleteRow,
deleteTable,
goToNextCell,
mergeCells,
splitCell,
tableEditing,
toggleHeaderRow,
} from 'prosemirror-tables';
import {
attributesFromSpec,
extendWithTableRole,
nodeSpec,
passthroughNodeIO,
} from '../spec-utils';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
documentTable: {
insertTable: (options?: {
rows?: number;
cols?: number;
withHeaderRow?: boolean;
}) => ReturnType;
addColumnBefore: () => ReturnType;
addColumnAfter: () => ReturnType;
deleteColumn: () => ReturnType;
addRowBefore: () => ReturnType;
addRowAfter: () => ReturnType;
deleteRow: () => ReturnType;
deleteTable: () => ReturnType;
mergeCells: () => ReturnType;
splitCell: () => ReturnType;
goToNextCell: () => ReturnType;
goToPreviousCell: () => ReturnType;
toggleHeaderRow: () => ReturnType;
};
}
}
/** `type.createAndFill()` picks a default child (an empty paragraph) that
* satisfies the cell's `block+` content expression. */
function buildTableRow(schema: Schema, cols: number, header: boolean): PMNode {
const cellType = schema.nodes[header ? 'table_header' : 'table_cell']!;
const cells = Array.from({ length: cols }, () => cellType.createAndFill()!);
return schema.nodes.table_row!.create(null, cells);
}
function buildTable(schema: Schema, rows: number, cols: number, withHeaderRow: boolean): PMNode {
const rowNodes = Array.from({ length: rows }, (_, index) =>
buildTableRow(schema, cols, withHeaderRow && index === 0),
);
return schema.nodes.table!.create(null, rowNodes);
}
const tableSpec = nodeSpec('table');
export const Table = Node.create({
name: 'table',
group: tableSpec.group,
content: tableSpec.content,
isolating: tableSpec.isolating,
...passthroughNodeIO(tableSpec),
...extendWithTableRole('table', 'table'),
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:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ chain, editor }) =>
chain()
.insertContent(buildTable(editor.schema, rows, cols, withHeaderRow).toJSON())
.run(),
addColumnBefore:
() =>
({ state, dispatch }) =>
addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) =>
addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) =>
deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) =>
addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) =>
addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) =>
deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) =>
deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) =>
mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) =>
splitCell(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) =>
goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) =>
goToNextCell(-1)(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) =>
toggleHeaderRow(state, dispatch),
};
},
});
const tableRowSpec = nodeSpec('table_row');
export const TableRow = Node.create({
name: 'table_row',
content: tableRowSpec.content,
...passthroughNodeIO(tableRowSpec),
...extendWithTableRole('table_row', 'row'),
});
const tableCellSpec = nodeSpec('table_cell');
export const TableCell = Node.create({
name: 'table_cell',
content: tableCellSpec.content,
isolating: tableCellSpec.isolating,
addAttributes() {
return attributesFromSpec(tableCellSpec);
},
...passthroughNodeIO(tableCellSpec),
...extendWithTableRole('table_cell', 'cell'),
});
const tableHeaderSpec = nodeSpec('table_header');
export const TableHeader = Node.create({
name: 'table_header',
content: tableHeaderSpec.content,
isolating: tableHeaderSpec.isolating,
addAttributes() {
return attributesFromSpec(tableHeaderSpec);
},
...passthroughNodeIO(tableHeaderSpec),
...extendWithTableRole('table_header', 'header_cell'),
});