components/row-list/row-list.ts
83 lines and 15 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| rowTargets | 7 to 11 | 1 | 25% | OkCRAP 1.4 | No data: mutation testing has not run on this repo yet |
| <anonymous:9> | 9 to 9 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| moveRowFocus | 19 to 30 | 6 | 9% | CriticalCRAP 33 | No data: mutation testing has not run on this repo yet |
| <anonymous:23> | 23 to 23 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| isTyping | 33 to 38 | 8 | 17% | CriticalCRAP 45 | No data: mutation testing has not run on this repo yet |
| isTypingKey | 44 to 50 | 10 | 14% | CriticalCRAP 72.9 | No data: mutation testing has not run on this repo yet |
| singleKeysOff | 54 to 56 | 1 | 33% | OkCRAP 1.3 | No data: mutation testing has not run on this repo yet |
| listOwnsKeys | 60 to 67 | 6 | 13% | CriticalCRAP 30.1 | No data: mutation testing has not run on this repo yet |
| attachRowList | 71 to 84 | 2 | 25% | OkCRAP 3.7 | No data: mutation testing has not run on this repo yet |
| <anonymous:72> | 72 to 72 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| onKey | 74 to 78 | 10 | 0% | CriticalCRAP 110 | No data: mutation testing has not run on this repo yet |
| <anonymous:80> | 80 to 83 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| enhance | 88 to 91 | 1 | 33% | OkCRAP 1.3 | No data: mutation testing has not run on this repo yet |
| <anonymous:90> | 90 to 90 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:90> | 90 to 90 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports no other module of this repo. Imported by components/attention-list/attention-list.ts, components/draft-compare/draft-compare.ts, components/flag-list/flag-list.react.tsx, components/flag-list/flag-list.ts, components/history-list/history-list.ts, components/mentions-list/mentions-list.react.tsx, components/moderation-queue/moderation-queue.ts, components/publish-gate/publish-gate.react.tsx, components/publish-gate/publish-gate.ts, components/row-list/row-list.react.tsx.
Source
// The row list's behaviour: j and k move keyboard focus between the rows' title links.
// Focus itself moves (there is no second selection), so Enter follows the link, the
// browser scrolls the row into view, and a screen reader reads the row it lands on.
// No framework; the React wrapper uses the same functions.
// Every row's one link or button, in order, skipping rows hidden or disabled.
export function rowTargets(list: ParentNode): HTMLElement[] {
return Array.from(list.querySelectorAll<HTMLElement>(".cap-row-title :is(a[href], button:not([disabled]))")).filter(
(el) => el.getClientRects().length > 0,
);
}
// Moves focus to the next (1) or previous (-1) row's title. From outside the list, j starts
// at the first row and k at the last (the Portal's rule, App.tsx "step"). At either end
// focus stays where it is. Focus moves without the browser's own scroll, then the row is
// scrolled the least distance that shows it ("nearest"), as the Portal does: a row already
// in view does not jump, and the scroller's scroll-padding keeps it clear of a sticky bar.
// Returns the element focused, or null when nothing moved.
export function moveRowFocus(list: ParentNode, step: 1 | -1): HTMLElement | null {
const targets = rowTargets(list);
if (targets.length === 0) return null;
const active = document.activeElement;
const current = targets.findIndex((el) => el === active || el.closest(".cap-row")?.contains(active));
const next = current === -1 ? (step === 1 ? 0 : targets.length - 1) : current + step;
const el = targets[next];
if (!el || next === current) return null;
el.focus({ preventScroll: true });
el.scrollIntoView({ block: "nearest" });
return el;
}
// A key typed into a field, or with a modifier, is never a list shortcut.
export function isTyping(e: KeyboardEvent): boolean {
if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return true;
const t = e.target as HTMLElement | null;
if (!t) return false;
return t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.closest("[role='combobox'], [role='textbox']") !== null;
}
// The same check for a list whose rows hold their own checkboxes (the moderation queue, the
// history list): a checkbox, a radio or a button is not typing, so a list key works from a box
// a person has just ticked.
const TEXT_INPUT = /^(text|search|email|url|tel|password|number|date|datetime-local|month|week|time)$/;
export function isTypingKey(e: KeyboardEvent): boolean {
if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return true;
const t = e.target as HTMLElement | null;
if (!t) return false;
if (t instanceof HTMLInputElement) return TEXT_INPUT.test(t.type || "text");
return t.isContentEditable || /^(TEXTAREA|SELECT)$/.test(t.tagName) || t.closest("[role='combobox'], [role='textbox']") !== null;
}
// Single-key shortcuts can be switched off (DEFAULTS.md). The shortcuts registry records the
// choice as data-cap-single-keys="off" on <html>.
export function singleKeysOff(): boolean {
return document.documentElement.dataset.capSingleKeys === "off";
}
// Whether j and k should act on this list now: focus is in it, or it is the page's primary
// list and focus is in no other list and no open dialog.
export function listOwnsKeys(list: HTMLElement): boolean {
const active = document.activeElement;
if (active && list.contains(active)) return true;
if (!list.hasAttribute("data-cap-primary")) return false;
if (active?.closest("[data-cap='row-list']")) return false;
if (active?.closest("dialog[open], [role='dialog'], [role='menu'], [role='listbox']")) return false;
return !list.closest("[inert]");
}
// Attaches j and k to one list. Returns a function that detaches them. A list already
// attached (by enhance or by the React wrapper) is left alone, so keys never act twice.
export function attachRowList(list: HTMLElement): () => void {
if (list.dataset.capReady !== undefined) return () => {};
list.dataset.capReady = "";
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || (e.key !== "j" && e.key !== "k")) return;
if (isTyping(e) || singleKeysOff() || !listOwnsKeys(list)) return;
if (moveRowFocus(list, e.key === "j" ? 1 : -1) || list.contains(document.activeElement)) e.preventDefault();
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
delete list.dataset.capReady;
};
}
// Attaches to every [data-cap="row-list"] under root that is not attached yet. Returns a
// function that detaches them all.
export function enhance(root: ParentNode = document): () => void {
const undo = Array.from(root.querySelectorAll<HTMLElement>("[data-cap='row-list']:not([data-cap-ready])")).map(attachRowList);
return () => undo.forEach((f) => f());
}