Repository navigation
feat: Tables a11y (BLO-1230) (BLO-1231) #3215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: a11y-testing
Are you sure you want to change the base?
Changes from all commits
e9d5397
01defda
c141d42
8321c4f
f1c7ada
776aefd
18ca5f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { Node as PMNode } from "prosemirror-model"; | ||
| import { Plugin, PluginKey } from "prosemirror-state"; | ||
| import { Decoration, DecorationSet } from "prosemirror-view"; | ||
| import { | ||
| applyCellHeaders, | ||
| applyHeaderId, | ||
| applyHeaderScope, | ||
| } from "./cellAttributes.js"; | ||
|
|
||
| export function TableAccessibilityPlugin() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm confused on why we are doing this via decorations and serializers, why are we not just adding the attributes to the table cells/headers
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To get the value for |
||
| const accessibilityKey = new PluginKey<DecorationSet>("tableAccessibility"); | ||
|
|
||
| function createAccessibilityDecorations(doc: PMNode) { | ||
| const decorations: Decoration[] = []; | ||
| doc.descendants((table, tablePosition, parent) => { | ||
| if (table.type.spec.tableRole !== "table") { | ||
| return; | ||
| } | ||
| const tableId = parent!.attrs.id; | ||
| table.forEach((row, rowOffset) => { | ||
| row.forEach((cell, cellOffset) => { | ||
| // TableMap positions start at table content; decorations use | ||
| // absolute document positions, including the table's opening token. | ||
| const position = rowOffset + cellOffset + 1; | ||
| const attributes: Record<string, string> = {}; | ||
| if (cell.type.spec.tableRole === "header_cell") { | ||
| applyHeaderScope(attributes, table, position); | ||
| applyHeaderId(attributes, table, tableId, position); | ||
| } else { | ||
| applyCellHeaders(attributes, table, tableId, position); | ||
| } | ||
| const from = tablePosition + 1 + position; | ||
| decorations.push( | ||
| Decoration.node(from, from + cell.nodeSize, attributes), | ||
| ); | ||
| }); | ||
| }); | ||
| return false; | ||
| }); | ||
| return DecorationSet.create(doc, decorations); | ||
| } | ||
|
|
||
| return new Plugin<DecorationSet>({ | ||
| key: accessibilityKey, | ||
| state: { | ||
| init: (_, state) => createAccessibilityDecorations(state.doc), | ||
| // Recompute relationships after edits; selection changes reuse them. | ||
| apply: (transaction, decorations) => | ||
| transaction.docChanged | ||
| ? createAccessibilityDecorations(transaction.doc) | ||
| : decorations, | ||
| }, | ||
| props: { | ||
| decorations: (state) => accessibilityKey.getState(state), | ||
| }, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { TableMap } from "prosemirror-tables"; | ||
| import { Node } from "prosemirror-model"; | ||
|
|
||
| function getHeaderScope(table: Node, position: number) { | ||
| const map = TableMap.get(table); | ||
| const { top, left } = map.findCell(position); | ||
| const isHeader = (position: number) => | ||
| table.nodeAt(position)?.type.spec.tableRole === "header_cell"; | ||
|
|
||
| // A full header row identifies column headers, including intersections with | ||
| // header columns. TableMap includes slots covered by merged cells. | ||
| if (map.map.slice(top * map.width, (top + 1) * map.width).every(isHeader)) { | ||
| return "col"; | ||
| } | ||
| for (let row = 0; row < map.height; row++) { | ||
| if (!isHeader(map.map[row * map.width + left])) { | ||
| return undefined; | ||
| } | ||
| } | ||
| return "row"; | ||
| } | ||
|
|
||
| function getHeaderId( | ||
| tableId: string, | ||
| scope: "row" | "col", | ||
| row: number, | ||
| col: number, | ||
| ) { | ||
| // Both coordinates distinguish headers in multiple header rows or columns. | ||
| return `bn-table-${tableId}-${scope}-header-${row}-${col}`; | ||
| } | ||
|
|
||
| export function applyCellHeaders( | ||
| attributes: Record<string, string>, | ||
| table: Node, | ||
| tableId: string, | ||
| cellPosition: number, | ||
| ) { | ||
| const ids = new Set<string>(); | ||
| const map = TableMap.get(table); | ||
| const rect = map.findCell(cellPosition); | ||
|
|
||
| function addHeader(position: number, scope: "row" | "col") { | ||
| if ( | ||
| table.nodeAt(position)?.type.spec.tableRole !== "header_cell" || | ||
| getHeaderScope(table, position) !== scope | ||
| ) { | ||
| return; | ||
| } | ||
| const { top, left } = map.findCell(position); | ||
| ids.add(getHeaderId(tableId, scope, top, left)); | ||
| } | ||
|
|
||
| // Search every header row/column along the cell's covered grid indices. | ||
| // A merged header may occupy several slots; the Set keeps one reference. | ||
| for (let row = 0; row < map.height; row++) { | ||
| for (let col = rect.left; col < rect.right; col++) { | ||
| addHeader(map.map[row * map.width + col], "col"); | ||
| } | ||
| } | ||
| for (let row = rect.top; row < rect.bottom; row++) { | ||
| for (let col = 0; col < map.width; col++) { | ||
| addHeader(map.map[row * map.width + col], "row"); | ||
| } | ||
| } | ||
| if (ids.size) { | ||
| attributes.headers = [...ids].join(" "); | ||
| } else { | ||
| delete attributes.headers; | ||
| } | ||
| } | ||
|
|
||
| export function applyHeaderScope( | ||
| attributes: Record<string, string>, | ||
| table: Node, | ||
| position: number, | ||
| ) { | ||
| const scope = getHeaderScope(table, position); | ||
| if (scope) { | ||
| attributes.scope = scope; | ||
| } else { | ||
| delete attributes.scope; | ||
| } | ||
| } | ||
|
|
||
| export function applyHeaderId( | ||
| attributes: Record<string, string>, | ||
| table: Node, | ||
| tableId: string, | ||
| position: number, | ||
| ) { | ||
| const scope = getHeaderScope(table, position); | ||
| if (scope) { | ||
| const { top, left } = TableMap.get(table).findCell(position); | ||
| attributes.id = getHeaderId(tableId, scope, top, left); | ||
| } else { | ||
| delete attributes.id; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { DOMSerializer, Node } from "prosemirror-model"; | ||
| import { | ||
| applyCellHeaders, | ||
| applyHeaderId, | ||
| applyHeaderScope, | ||
| } from "./cellAttributes.js"; | ||
|
|
||
| /** Give cell serializers the table position that ProseMirror's toDOM omits. */ | ||
| export function createTableSerializer( | ||
| serializer: DOMSerializer, | ||
| table: Node, | ||
| tableId: string, | ||
| document: Document, | ||
| ) { | ||
| const positions = new Map<Node, number>(); | ||
| table.forEach((row, rowOffset) => { | ||
| row.forEach((cell, cellOffset) => { | ||
| positions.set(cell, rowOffset + cellOffset + 1); | ||
| }); | ||
| }); | ||
|
|
||
| function renderCell(cell: Node) { | ||
| const position = positions.get(cell); | ||
| if (position === undefined) { | ||
| throw new Error("Cannot serialize a cell outside its table"); | ||
| } | ||
| const attributes: Record<string, string> = {}; | ||
| if (cell.type.spec.tableRole === "header_cell") { | ||
| applyHeaderScope(attributes, table, position); | ||
| applyHeaderId(attributes, table, tableId, position); | ||
| } else { | ||
| applyCellHeaders(attributes, table, tableId, position); | ||
| } | ||
|
|
||
| // Preserve the existing renderer's spans, styles, and content hole. The | ||
| // serializer will fill that hole with the cell's content after this returns. | ||
| const output = DOMSerializer.renderSpec( | ||
| document, | ||
| serializer.nodes[cell.type.name](cell), | ||
| ); | ||
| if (!(output.dom instanceof HTMLElement)) { | ||
| throw new Error("Table cells must render an HTML element"); | ||
| } | ||
| for (const [name, value] of Object.entries(attributes)) { | ||
| output.dom.setAttribute(name, value); | ||
| } | ||
| return output; | ||
| } | ||
|
|
||
| return new DOMSerializer( | ||
| { ...serializer.nodes, tableHeader: renderCell, tableCell: renderCell }, | ||
| serializer.marks, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused why this changed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This and the above change you commented on fix a bug with converting to/from TipTap nodes. Check commit c141d42 which adds a test case for it. When either of the changes is reverted, the test fails on round trip.