Skip to the content

Capsid menu

components/switch-reason/switch-reason.ts

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

Functions

The functions of switch-reason.ts
FunctionLinesComplexityCoveredCRAPMutation
verbFor31 to 332No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
reasonLabel35 to 371No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
failedMessage39 to 424No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
partsOf54 to 706No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
enhance75 to 1895No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
verb83 to 833No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
setBusy90 to 1012No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
open105 to 1132No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
close114 to 1201No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onSwitchClick123 to 1271No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onKey130 to 1353No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onCancel136 to 1382No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onSubmit139 to 1745No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
waitUntil151 to 1511No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
done153 to 1561No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:164>164 to 1671No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:168>168 to 1721No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:180>180 to 1861No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:188>188 to 1881No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:188>188 to 1881No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports components/field/field.ts, components/switch/switch.ts. Imported by components/switch-reason/switch-reason.react.tsx.

Source

// The automation switch's behaviour. Clicking the switch, or Space on it, does not move it:
// it opens a one-line reason beside it. Enter applies (a reason is required), Esc cancels
// and returns focus to the switch. On apply it dispatches `cap:switch-applied` with
// { checked, reason, waitUntil }, and the switch moves: at once, or, when a listener calls
// waitUntil(promise), once that promise resolves. The app writes its audit row there and
// offers Undo through the message region.
//
// The flow is the Capsid Portal's ui/Switch.tsx (AutomationSwitch and ReasonForm, #216),
// without its API calls: the app's own listener does the sending.

import { clearError, showError } from "../field/field.ts";
import { setBusy as setSwitchBusy, setSwitch } from "../switch/switch.ts";

export { setSwitch };

export const EVENT = "cap:switch-applied";
export const MISSING = "Type a reason. It is recorded with the change.";

export interface SwitchAppliedDetail {
  // The state the switch is moving to.
  checked: boolean;
  reason: string;
  // Hold the switch until the app has saved the change. A rejection keeps the reason open
  // with the error beside it.
  waitUntil(promise: Promise<unknown>): void;
}

// What the reason is for, as the label's first words: "Turning seat start on". The label
// reads "<verb>. Reason:". An app with a better verb ("Pausing sample-d") sets data-verb-on
// and data-verb-off on the root.
export function verbFor(name: string, checked: boolean): string {
  return `Turning ${name} ${checked ? "on" : "off"}`;
}

export function reasonLabel(verb: string): string {
  return `${verb}. Reason:`;
}

export function failedMessage(name: string, checked: boolean, err: unknown): string {
  const why = err instanceof Error && err.message ? ` ${err.message}` : "";
  return `Could not turn ${name} ${checked ? "on" : "off"}.${why} Try again, or press Esc to cancel.`;
}

interface Parts {
  sw: HTMLInputElement;
  label: HTMLElement;
  form: HTMLFormElement;
  reason: HTMLInputElement;
  ask: HTMLElement;
  apply: HTMLButtonElement | null;
  cancel: HTMLElement | null;
}

function partsOf(root: HTMLElement): Parts | null {
  const sw = root.querySelector<HTMLInputElement>("input[role='switch']");
  const label = sw?.closest<HTMLElement>(".cap-switch");
  const form = root.querySelector<HTMLFormElement>("form.cap-switch-reason-form");
  const reason = form?.querySelector<HTMLInputElement>("input.cap-input");
  const ask = form?.querySelector<HTMLElement>(".cap-switch-reason-label");
  if (!sw || !label || !form || !reason || !ask) return null;
  return {
    sw,
    label,
    form,
    reason,
    ask,
    apply: form.querySelector<HTMLButtonElement>("button[type='submit']"),
    cancel: form.querySelector<HTMLElement>("[data-cap-part='cancel']"),
  };
}

// Attaches to every [data-cap="switch-reason"] under root that is not attached yet.
// data-name on the root is the thing switched, as it reads in the label ("the improve
// loop"); without it, the switch's label is used. Returns a function that detaches them all.
export function enhance(root: ParentNode = document): () => void {
  const undo: Array<() => void> = [];
  for (const el of root.querySelectorAll<HTMLElement>("[data-cap='switch-reason']:not([data-cap-ready])")) {
    const p = partsOf(el);
    if (!p) continue;
    el.dataset.capReady = "";
    const { sw, label, form, reason, ask, apply, cancel } = p;
    const name = el.dataset.name ?? label.textContent?.replace(/\s+(On|Off)\s*$/, "").trim() ?? "this";
    const verb = (next: boolean) => (next ? el.dataset.verbOn : el.dataset.verbOff) ?? verbFor(name, next);
    form.noValidate = true;
    let busy = false;
    // The state the flip asks for, fixed when the field opens, so a poll that lands
    // meanwhile and moves the switch cannot turn "off" into "on".
    let target: boolean | null = null;

    const setBusy = (on: boolean) => {
      busy = on;
      reason.readOnly = on;
      setSwitchBusy(label, on);
      if (on) {
        form.setAttribute("aria-busy", "true");
        apply?.setAttribute("aria-busy", "true");
      } else {
        form.removeAttribute("aria-busy");
        apply?.removeAttribute("aria-busy");
      }
    };
    // `next` is where the switch would move. A checkbox flips before its click handlers run
    // and is put back when one cancels the click, so inside the handler sw.checked already
    // holds the next state.
    const open = (next: boolean) => {
      if (!form.hidden) return reason.focus();
      target = next;
      reason.value = "";
      clearError(reason);
      ask.textContent = reasonLabel(verb(next));
      form.hidden = false;
      reason.focus();
    };
    const close = () => {
      form.hidden = true;
      target = null;
      reason.value = "";
      clearError(reason);
      sw.focus();
    };

    // A click (and Space, which clicks) would move the switch: stop it and ask instead.
    const onSwitchClick = (e: MouseEvent) => {
      const next = sw.checked;
      e.preventDefault();
      open(next);
    };
    // Held while the request runs, so its answer is not hidden. Stopped here either way, so
    // an Esc that cancels the reason does not also close a panel the switch sits in.
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      e.preventDefault();
      e.stopPropagation();
      if (!busy) close();
    };
    const onCancel = () => {
      if (!busy) close();
    };
    const onSubmit = (e: SubmitEvent) => {
      e.preventDefault();
      if (busy) return;
      // Read from the field, so a value filled in without an input event counts.
      const text = reason.value.trim();
      if (!text) {
        showError(reason, MISSING, true);
        reason.focus();
        return;
      }
      const next = target ?? !sw.checked;
      const held: Array<Promise<unknown>> = [];
      const detail: SwitchAppliedDetail = { checked: next, reason: text, waitUntil: (promise) => void held.push(promise) };
      el.dispatchEvent(new CustomEvent<SwitchAppliedDetail>(EVENT, { bubbles: true, detail }));
      const done = () => {
        setSwitch(label, next);
        close();
      };
      if (held.length === 0) {
        done();
        return;
      }
      clearError(reason);
      setBusy(true);
      Promise.all(held).then(
        () => {
          setBusy(false);
          done();
        },
        (err: unknown) => {
          setBusy(false);
          showError(reason, failedMessage(name, next, err), true);
          reason.focus();
        },
      );
    };

    sw.addEventListener("click", onSwitchClick);
    form.addEventListener("keydown", onKey);
    form.addEventListener("submit", onSubmit);
    cancel?.addEventListener("click", onCancel);
    undo.push(() => {
      sw.removeEventListener("click", onSwitchClick);
      form.removeEventListener("keydown", onKey);
      form.removeEventListener("submit", onSubmit);
      cancel?.removeEventListener("click", onCancel);
      delete el.dataset.capReady;
    });
  }
  return () => undo.forEach((f) => f());
}