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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| verbFor | 31 to 33 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| reasonLabel | 35 to 37 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| failedMessage | 39 to 42 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| partsOf | 54 to 70 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| enhance | 75 to 189 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| verb | 83 to 83 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| setBusy | 90 to 101 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| open | 105 to 113 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| close | 114 to 120 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onSwitchClick | 123 to 127 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onKey | 130 to 135 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onCancel | 136 to 138 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onSubmit | 139 to 174 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| waitUntil | 151 to 151 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| done | 153 to 156 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:164> | 164 to 167 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:168> | 168 to 172 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:180> | 180 to 186 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:188> | 188 to 188 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:188> | 188 to 188 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No 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());
}