Skip to the content

Capsid menu

components/menu/menu.ts

58 lines and 10 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of menu.ts
FunctionLinesComplexityCoveredCRAPMutation
buttonsOf9 to 91100%OkCRAP 1No data: mutation testing has not run on this repo yet
stepIndex12 to 16420%WatchCRAP 12.2No data: mutation testing has not run on this repo yet
enhance18 to 62210%OkCRAP 4.9No data: mutation testing has not run on this repo yet
close23 to 2620%WatchCRAP 6No data: mutation testing has not run on this repo yet
onKey27 to 44110%CriticalCRAP 132No data: mutation testing has not run on this repo yet
onOutside45 to 4740%WatchCRAP 20No data: mutation testing has not run on this repo yet
onFocusOut48 to 5040%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:54>54 to 5910%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:61>61 to 6110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:61>61 to 611100%OkCRAP 1No 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());
}