Files
webmail/components/email/list-a11y.ts
T
Linus Rath 8e201954b4 fix: make the message list one Tab stop that screen readers read row by row
The list was a stack of clickable divs. Screen readers heard nothing when
j or k moved the selection, every row added several Tab stops (Compose
sat 253 Tabs from the top), and a deleted message left the focus on the
page.

The list is now a treegrid named "Messages": one Tab stop, rows named in
one sentence (unread, sender, subject, date, flags, and the preview when
that setting is on), conversations with aria-expanded and their
messages a level down, position and list size on every row. j, k and the
arrows move the focus with the selection, Space ticks a row, Enter opens
it, Shift+F10 or the menu key opens its actions, and after a delete the
focus lands on the next row. With the actions button setting on, each
row shows a button for the same menu, for pointers that can't hover or
right-click.
2026-10-29 18:53:05 +01:00

163 lines
7.2 KiB
TypeScript

"use client";
import { useEffect, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
/**
* The message list as one widget for keyboards and screen readers (SR-01,
* SR-02): a treegrid with one Tab stop, whose rows are named in one sentence
* (unread, sender, subject, date, flags); a conversation row expands into
* its messages (aria-expanded, aria-level). A treegrid rather than a
* listbox because rows hold controls (checkbox, star, hover actions), which
* an option may not. j/k and the arrows move
* the selection; the focus follows it, so a screen reader reads each row as
* it gets there. The controls inside a row (checkbox, star, hover actions)
* stay for the mouse but leave the Tab order: Space ticks the row, Enter
* opens it, Shift+F10 or the menu key opens its actions.
*/
export const ROW_KEY_ATTR = "data-row-key";
/** One sentence that names a row, from the parts that apply, in reading order. */
export function rowLabel(parts: ReadonlyArray<string | false | null | undefined>): string {
return parts.filter((p): p is string => typeof p === "string" && p.trim().length > 0).join(", ");
}
interface RowA11yOptions {
rowKey: string;
label: string;
/** The open message's row. */
selected: boolean;
/** A conversation row: whether it shows its messages. */
expanded?: boolean;
/** 2 for a message inside an expanded conversation. */
level?: number;
/** Ticked for a batch action; only set while something is ticked. */
checked?: boolean;
selectionMode: boolean;
onToggleCheck: () => void;
/** Opens the row's actions menu, as a right-click would. */
onOpenMenu?: (e: ReactMouseEvent) => void;
}
/** Props for a row's outer element. */
export function rowA11yProps({ rowKey, label, selected, expanded, level = 1, checked, selectionMode, onToggleCheck, onOpenMenu }: RowA11yOptions) {
return {
role: "row" as const,
// The list moves the single Tab stop (useListboxFocus); React leaves an
// attribute it didn't change alone, so -1 here doesn't undo that.
tabIndex: -1,
[ROW_KEY_ATTR]: rowKey,
"aria-label": label,
// Ticked rows are the selection while something is ticked; otherwise the
// open message is. aria-current marks the open message either way.
"aria-selected": selectionMode ? !!checked : selected ? true : undefined,
"aria-current": selected ? ("true" as const) : undefined,
"aria-expanded": expanded,
"aria-level": level,
onKeyDown: (e: ReactKeyboardEvent<HTMLElement>) => {
// Keys from a control inside the row are that control's.
if (e.target !== e.currentTarget) return;
if (e.key === " ") {
e.preventDefault();
onToggleCheck();
} else if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
e.currentTarget.click();
} else if (onOpenMenu && (e.key === "ContextMenu" || (e.key === "F10" && e.shiftKey))) {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
onOpenMenu({
clientX: rect.left + Math.min(48, rect.width / 2),
clientY: rect.top + rect.height / 2,
preventDefault: () => {},
stopPropagation: () => {},
currentTarget: e.currentTarget,
target: e.currentTarget,
} as unknown as ReactMouseEvent);
}
},
};
}
const FOCUSABLE_IN_ROW = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function rowFor(list: HTMLElement, key: string | undefined): HTMLElement | null {
if (!key) return null;
const k = CSS.escape(key);
// A message's own row, else the collapsed conversation that holds it.
return list.querySelector<HTMLElement>(`[role="row"][${ROW_KEY_ATTR}="${k}"]`)
?? list.querySelector<HTMLElement>(`[role="row"][data-thread-keys~="${k}"]`);
}
/** Writes each rendered row's place in the whole list onto it. */
function applyPositions(list: HTMLElement): void {
list.querySelectorAll<HTMLElement>("[data-pos]").forEach((wrapper) => {
const row = wrapper.querySelector<HTMLElement>('[role="row"]');
const setSize = wrapper.closest<HTMLElement>("[data-setsize]")?.dataset.setsize;
if (!row || !setSize) return;
if (row.getAttribute("aria-posinset") !== wrapper.dataset.pos) row.setAttribute("aria-posinset", wrapper.dataset.pos ?? "");
if (row.getAttribute("aria-setsize") !== setSize) row.setAttribute("aria-setsize", setSize);
});
}
/**
* Keeps one Tab stop in the list (the open message's row, else the first
* row), moves the focus with the selection while the focus is in the list,
* and takes the controls inside rows out of the Tab order, also the ones
* that mount later (hover actions appear on pointer-over).
*/
export function useListboxFocus(listRef: RefObject<HTMLElement | null>, activeKey: string | undefined, renderVersion: unknown): void {
// Inner controls out of the Tab order.
useEffect(() => {
const list = listRef.current;
if (!list) return;
const demote = (root: ParentNode) => {
root.querySelectorAll<HTMLElement>('[role="row"] ' + FOCUSABLE_IN_ROW.split(", ").join(', [role="row"] ')).forEach((el) => {
if (el.getAttribute("tabindex") !== "-1") el.setAttribute("tabindex", "-1");
});
};
demote(list);
const observer = new MutationObserver((records) => {
for (const r of records) {
if (r.type === "childList" && r.addedNodes.length) { demote(list); return; }
if (r.type === "attributes" && r.target instanceof HTMLElement && r.target.closest('[role="row"]') && r.target.getAttribute("role") !== "row") {
// Only change what isn't -1 yet: writing the same value again is a
// mutation too, and would call this observer forever.
if (r.target.matches(FOCUSABLE_IN_ROW) && r.target.getAttribute("tabindex") !== "-1") r.target.setAttribute("tabindex", "-1");
}
}
});
observer.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ["tabindex"] });
// The list itself takes the focus from the skip link and from the focus
// rescue after a delete; hand it on to the row that holds the Tab stop.
const onFocus = (e: FocusEvent) => {
if (e.target !== list) return;
list.querySelector<HTMLElement>('[role="row"][tabindex="0"]')?.focus();
};
list.addEventListener("focus", onFocus);
return () => {
observer.disconnect();
list.removeEventListener("focus", onFocus);
};
}, [listRef]);
// The single Tab stop, and the focus following the selection.
useEffect(() => {
const list = listRef.current;
if (!list) return;
applyPositions(list);
const target = rowFor(list, activeKey) ?? list.querySelector<HTMLElement>('[role="row"]');
list.querySelectorAll<HTMLElement>('[role="row"][tabindex="0"]').forEach((el) => {
if (el !== target) el.setAttribute("tabindex", "-1");
});
if (!target) return;
target.setAttribute("tabindex", "0");
const active = document.activeElement;
// Only while the keyboard is working the list: never pull the focus out
// of the reader, the composer or a dialog.
if (active && active !== target && list.contains(active)) {
target.focus({ preventScroll: false });
}
}, [listRef, activeKey, renderVersion]);
}