components/menu/menu.ts
58 lines and 10 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| buttonsOf | 9 to 9 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| stepIndex | 12 to 16 | 4 | 20% | WatchCRAP 12.2 | No data: mutation testing has not run on this repo yet |
| enhance | 18 to 62 | 2 | 10% | OkCRAP 4.9 | No data: mutation testing has not run on this repo yet |
| close | 23 to 26 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| onKey | 27 to 44 | 11 | 0% | CriticalCRAP 132 | No data: mutation testing has not run on this repo yet |
| onOutside | 45 to 47 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| onFocusOut | 48 to 50 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| <anonymous:54> | 54 to 59 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:61> | 61 to 61 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:61> | 61 to 61 | 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/menu/menu.react.tsx, components/people-roles/people-roles.ts.
Source
// The form menu's behaviour: a <details> of submit buttons (and links) that already works with no script.
// With script it also closes on Esc (focus back on the summary), closes on a press or focus
// outside it, and moves between the buttons with the arrow keys; ArrowDown on the summary opens
// it and lands on the first button. The Base UI menu needs none of this; it is React only.
const READY = "data-cap-ready";
// The items the arrow keys move through: the submit buttons that are not disabled, and link items.
const buttonsOf = (d: HTMLElement) => Array.from(d.querySelectorAll<HTMLElement>(".cap-menu > :is(button:not(:disabled), a[href])"));
// Moves to the next or previous button, wrapping; `from` is the focused one, or -1.
export function stepIndex(count: number, from: number, dir: 1 | -1): number {
if (count === 0) return -1;
if (from < 0) return dir === 1 ? 0 : count - 1;
return (from + dir + count) % count;
}
export function enhance(root: ParentNode = document): () => void {
const undo: Array<() => void> = [];
for (const d of Array.from(root.querySelectorAll<HTMLDetailsElement>("details[data-cap='menu-form']:not([data-cap-ready])"))) {
d.setAttribute(READY, "");
const summary = d.querySelector<HTMLElement>(":scope > summary");
const close = (focus: boolean) => {
d.open = false;
if (focus) summary?.focus();
};
const onKey = (e: KeyboardEvent) => {
const items = buttonsOf(d);
if (e.key === "Escape" && d.open) {
e.preventDefault();
close(true);
} else if (e.key === "ArrowDown" && e.target === summary && !d.open) {
e.preventDefault();
d.open = true;
items[0]?.focus();
} else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && d.open) {
const at = items.indexOf(document.activeElement as HTMLElement);
const next = items[stepIndex(items.length, at, e.key === "ArrowDown" ? 1 : -1)];
if (next) {
e.preventDefault();
next.focus();
}
}
};
const onOutside = (e: Event) => {
if (d.open && e.target instanceof Node && !d.contains(e.target)) close(false);
};
const onFocusOut = (e: FocusEvent) => {
if (d.open && e.relatedTarget instanceof Node && !d.contains(e.relatedTarget)) close(false);
};
d.addEventListener("keydown", onKey);
d.addEventListener("focusout", onFocusOut);
document.addEventListener("pointerdown", onOutside);
undo.push(() => {
d.removeEventListener("keydown", onKey);
d.removeEventListener("focusout", onFocusOut);
document.removeEventListener("pointerdown", onOutside);
d.removeAttribute(READY);
});
}
return () => undo.forEach((f) => f());
}