Files
threadcount-community/components/ui.tsx
T
ThreadCount cbf533d90b ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 12:12:58 +10:00

326 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useId, useRef } from "react";
import Link from "next/link";
import { label, longLabel, type Item, type Snapshot } from "@/lib/compute";
/* The menu used to be numbered 0111 and every page eyebrow repeated its number back — "01 —
Overview". The rail's icons carry that ordering now, so the digits were only a second thing to
read on a screen already being read from across a linen room. The word stays.
Every screen now passes the bare word, so this is a floor rather than the mechanism: one page
that came back with its number would be the only page in the app wearing one. */
const EYEBROW_NUMBER = /^\s*\d{1,2}\s*[—–-]\s*/;
export function PageHead({ eyebrow, title, sub, children, below }: { eyebrow: string; title: React.ReactNode; sub?: React.ReactNode; children?: React.ReactNode; below?: React.ReactNode }) {
return (
<header className="page-head">
<div>
<div className="eyebrow">{eyebrow.replace(EYEBROW_NUMBER, "")}</div>
<h1 className="h1">{title}</h1>
{sub && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: "var(--space-1)" }}>{sub}</div>}
{below}
</div>
{children && <div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap", justifyContent: "flex-end" }}>{children}</div>}
</header>
);
}
export function Sec({ children, right, style }: { children: React.ReactNode; right?: React.ReactNode; style?: React.CSSProperties }) {
return (
<div className="sec" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8, ...style }}>
<div>{children}</div>
{right && <div style={{ fontSize: 12, fontWeight: 400, letterSpacing: 0, textTransform: "none", color: "var(--color-neutral-700)" }}>{right}</div>}
</div>
);
}
/** The props a Field hands its control. Spread them onto the input/select/textarea. */
export type FieldControl = { id: string; "aria-describedby": string | undefined; "aria-invalid": true | undefined };
/* A labelled form control.
*
* The pattern this replaces — `<div className="field"><label>Sets</label><input className="input" …/></div>`
* — draws a label and leaves it a sibling of the box it names, so nothing connects the two: a
* screen reader reaching the input announces "edit text, blank", and clicking the label does not
* put the cursor in the field. Field generates one id per instance and wires it as the label's
* htmlFor and the control's id, so every consumer gets the association for free rather than having
* to invent an id at each of the ninety-odd fields in the product.
*
* The control comes in as a function because only the consumer knows which element is the one the
* label names — some fields draw a button or a hint alongside the input. Grouped controls (a Seg,
* a set of radios) are not Fields: a single label cannot name several controls, and they want a
* fieldset or role="group" instead.
*
* The markup is the same div.field the stylesheet already targets, so the visual result is
* unchanged; `hint` and `error` only appear when a consumer asks for them, and both are wired into
* aria-describedby so they are read out as part of the field rather than as loose text. */
export function Field({ label, hint, error, className, style, children }: {
label: React.ReactNode; hint?: React.ReactNode; error?: string; className?: string; style?: React.CSSProperties;
children: (control: FieldControl) => React.ReactNode;
}) {
const base = useId();
const id = base + "c";
const hintId = hint ? base + "h" : undefined;
const errId = error ? base + "e" : undefined;
const describedBy = [hintId, errId].filter(Boolean).join(" ") || undefined;
return (
<div className={"field" + (className ? " " + className : "")} style={style}>
<label htmlFor={id}>{label}</label>
{children({ id, "aria-describedby": describedBy, "aria-invalid": error ? true : undefined })}
{hint && <div id={hintId} style={{ fontSize: 11, color: "var(--color-neutral-700)" }}>{hint}</div>}
{/* Red on its own says nothing here — the accent is already the primary button an inch below
this line. The mark is what carries at a glance; the words are what carry the meaning, so
the mark is decoration and stays out of the reading. */}
<LiveRegion id={errId} tone="alert" style={{ fontSize: 12, fontWeight: 700, color: "var(--color-accent-700)" }}
msg={error ? <><span className="tc-mark" aria-hidden="true" />{error}</> : undefined} />
</div>
);
}
/* Everything the keyboard can reach inside a dialog, in tab order. getClientRects() is the
visibility test rather than offsetParent because a fixed-position control inside the dialog has
no offset parent and would otherwise drop out of the cycle. */
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusables(root: HTMLElement) {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.getClientRects().length > 0);
}
/* Drawn here rather than pulled off a CDN, and drawn the way the rail's icons are: 2px strokes,
square caps, mitred joins. A rounded × would be the only soft corner in an app built out of 2px
square borders. */
const CLOSE_ICON = (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false">
<path d="M5 5l14 14M19 5L5 19" />
</svg>
);
/* A dialog is chrome, so it wears the chrome's ink: a dark head with the accent rule under it, the
same band the rail and the page header carry. What the dialog is *about* — the garment rows, the
counted figures, the invoice costs — stays on paper below it, for the same reason the page
content did: those numbers get read at arm's length under ward lighting.
*
* `foot` is the row of buttons. It is a prop rather than the last thing in `children` so the head
* and the buttons stay put and only the middle scrolls: Receive delivery on a fourteen-line order
* used to push Receive off the bottom of the screen, and someone had to scroll a dialog they had
* just finished filling in to find out where the button went. */
export function Dialog({ title, width = 560, onClose, children, sub, foot }: { title: React.ReactNode; width?: number; onClose: () => void; children: React.ReactNode; sub?: React.ReactNode; foot?: React.ReactNode }) {
const box = useRef<HTMLDivElement>(null);
const titleId = useId();
const subId = useId();
// Whoever had focus when the dialog was opened gets it back when it closes. Read during render,
// not in the effect: by the time an effect runs the dialog is in the page and a field with
// autoFocus may already have taken focus off the button that opened it.
const opener = useRef<Element | null>(null);
if (opener.current === null && typeof document !== "undefined") opener.current = document.activeElement;
useEffect(() => {
const node = box.current;
if (!node) return;
const opened = opener.current;
// "Modal" has to mean something to the keyboard and the screen reader, not just to the eye.
// Marking every ancestor's other children `inert` takes the page behind the overlay out of the
// tab order and out of the accessibility tree, which is what the dim layer only implies. Doing
// it by walking the ancestors keeps the dialog where it is rendered — moving it to a portal
// would change which React tree its events bubble through.
const off: HTMLElement[] = [];
for (let el: HTMLElement | null = node.parentElement; el && el !== document.body && el.parentElement; el = el.parentElement) {
for (const sib of Array.from(el.parentElement.children)) {
if (sib !== el && sib instanceof HTMLElement && !sib.inert) { sib.inert = true; off.push(sib); }
}
}
// Focus the dialog itself rather than its first control: the name and the contents get read
// out, and nothing is armed by accident. The two dialogs that autoFocus a field have already
// moved focus inside by now, so leave those alone.
if (!node.contains(document.activeElement)) node.focus();
return () => {
for (const el of off) el.inert = false;
if (opened instanceof HTMLElement && opened.isConnected) opened.focus();
};
}, []);
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") { onClose(); return; }
if (e.key !== "Tab") return;
const node = box.current;
if (!node) return;
// Wrap the tab cycle inside the dialog. `inert` already stops the page behind it from taking
// focus, but on a browser without inert this is what keeps Tab from walking out, and it is
// what returns Tab at the last control to the first rather than to the browser chrome.
const f = focusables(node);
if (f.length === 0) { e.preventDefault(); node.focus(); return; }
const at = document.activeElement;
if (e.shiftKey && (at === f[0] || at === node)) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && at === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
return (
<div className="overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
{/* The stylesheet's .dialog is a padded box that scrolls as a whole. Overridden here to a
column that clips, so the three bands below can decide for themselves what scrolls —
88vh and the 2px frame still come off the class. */}
<div ref={box} className="dialog" style={{ maxWidth: width, padding: 0, display: "flex", flexDirection: "column", overflow: "hidden" }} role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={sub ? subId : undefined} tabIndex={-1}>
{/* --tc-* are literal values that no scope remaps, unlike the --color-* tokens the page
head reassigns — a dialog can be rendered inside one, and this band has to stay ink
either way. */}
<div style={{ flex: "0 0 auto", display: "flex", alignItems: "flex-start", gap: "var(--space-3)", background: "var(--tc-ink)", borderBottom: "4px solid var(--color-accent)", padding: "var(--space-4) var(--space-6)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="dialog-title" id={titleId} style={{ color: "var(--tc-on-ink)" }}>{title}</div>
{sub && <div id={subId} style={{ fontSize: 12, color: "var(--tc-ink-muted)", marginTop: "var(--space-1)" }}>{sub}</div>}
</div>
{/* The rail's collapse button wears this class: it is the product's one piece of
ink-on-ink chrome, and a dialog head is the same material. Escape and a click outside
already close, but neither is discoverable on a shared linen-room PC. */}
<button type="button" className="tc-rail-toggle" onClick={onClose} aria-label="Close" title="Close">{CLOSE_ICON}</button>
</div>
{/* Light on top: every dialog's first element already brings its own top margin — they had
to, sitting directly under a title in the old box — and a full gutter here on top of
that opens a hole under the accent rule. Enough that a future dialog without one is not
printed against the band. */}
<div style={{ flex: "1 1 auto", minHeight: 0, overflow: "auto", padding: "var(--space-2) var(--space-6) var(--space-5)" }}>{children}</div>
{foot && <div style={{ flex: "0 0 auto", display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "var(--space-2)", flexWrap: "wrap", padding: "var(--space-3) var(--space-6)", borderTop: "2px solid var(--color-text)" }}>{foot}</div>}
</div>
</div>
);
}
export function Empty({ children, pad = 6 }: { children: React.ReactNode; pad?: number }) {
return <div style={{ padding: `var(--space-${pad}) 0`, fontSize: 13, color: "var(--color-neutral-700)" }}>{children}</div>;
}
/* Something the app has to say back: a save that failed, a save that went through.
*
* A message that is only painted is silent — nothing about a div that turns up mid-page reaches
* anyone who is not looking at it, which is why a failed login used to leave a screen reader user
* with an apparently unchanged page. `tone` chooses how much it interrupts: "alert" cuts into
* whatever is being read (a failure that has to be acted on), "status" waits for a gap (a result).
*
* The element is rendered only when there is something to say. A live region that is inserted
* along with its text is announced by current screen readers, and keeping an empty one mounted
* would count as a flex item wherever one of these sits in a column and open a gap in the layout. */
export function LiveRegion({ msg, tone = "status", id, className, style }: { msg?: React.ReactNode; tone?: "status" | "alert"; id?: string; className?: string; style?: React.CSSProperties }) {
if (!msg) return null;
return <div id={id} className={className} style={style} role={tone} aria-live={tone === "alert" ? "assertive" : "polite"} aria-atomic="true">{msg}</div>;
}
/* A save that would not go through. Marked three ways over — the rule down the left edge, the
heavier type, and the mark — because this sits under a form whose primary button is already the
same red, and two reds a metre apart across a linen room is a guess rather than a signal. */
export function ErrorLine({ msg }: { msg: string }) {
if (!msg) return null;
return (
<LiveRegion tone="alert" className="tc-flag" style={{ marginTop: "var(--space-3)", padding: "var(--space-2) var(--space-3)", fontSize: 13, color: "var(--color-accent-700)", fontWeight: 700 }}
msg={<><span className="tc-mark" aria-hidden="true" />{msg}</>} />
);
}
export function Notice({ msg }: { msg: string }) {
return <LiveRegion msg={msg} className="notice" />;
}
/** Segmented control.
*
* Which option is chosen was carried by the btn-primary class alone — a fill and a colour, and
* nothing at all in the accessibility tree. Read aloud, every segment was an ordinary button and
* the one already in force was indistinguishable from the four that would change the screen.
* aria-pressed is the honest role for a control that stays down: these swap what the list below
* shows rather than navigating anywhere, which is a toggle, not a tab. Every caller already wraps
* the row in a labelled role="group". */
export function Seg<T extends string>({ opts, value, onChange, style }: { opts: readonly T[]; value: T; onChange: (v: T) => void; style?: React.CSSProperties }) {
return <div className="seg" style={style}>{opts.map((o) => <button key={o} aria-pressed={value === o} className={"seg-opt" + (value === o ? " btn-primary" : "")} onClick={() => onChange(o)}>{o}</button>)}</div>;
}
/** Inventory / Stock take sub-tabs shown under the Inventory heading. */
export function InvTabs({ active }: { active: "stock" | "take" }) {
return (
<div className="seg" style={{ marginTop: "var(--space-3)" }}>
{/* Links, not buttons — so the one you are on is aria-current, not aria-pressed. Same defect
as Seg's: without it the current tab was a fill and nothing more. */}
<Link href="/app/stock" aria-current={active === "stock" ? "page" : undefined} className={"seg-opt" + (active === "stock" ? " btn-primary" : "")} style={{ textDecoration: "none", display: "inline-flex", alignItems: "center" }}>Stock on hand</Link>
<Link href="/app/stocktake" aria-current={active === "take" ? "page" : undefined} className={"seg-opt" + (active === "take" ? " btn-primary" : "")} style={{ textDecoration: "none", display: "inline-flex", alignItems: "center" }}>Stock take</Link>
</div>
);
}
/* `flag` is how a screen says a figure needs attention — sizes out of stock, a gap that has to be
explained before the count can be filed. It never means "paint it red": a flagged tile gets the
rule down its edge, the heavier accent-700 figure and the mark, all three off .tc-flag, because
the accent is already the primary button on the same screen.
A caller that reaches for the accent through `color` instead meant the same thing, so it lands in
the same place rather than as the red-only treatment this design is trying to get rid of. */
const flagged = (color?: string, flag?: boolean) => flag ?? /accent/.test(color || "");
/** The figures under a page head. Grid is auto-fit, so three tiles and five both fill the row. */
export function KpiStrip({ items }: { items: { val: React.ReactNode; label: string; color?: string; note?: string; flag?: boolean }[] }) {
return (
<div className="tc-tiles" style={{ marginTop: "var(--space-4)" }}>
{items.map((k) => {
const on = flagged(k.color, k.flag);
return (
<div key={k.label} className={"tc-tile" + (on ? " tc-flag" : "")}>
<div className="tc-figure" style={on ? undefined : { color: k.color || "var(--color-text)" }}>{on && <span className="tc-mark" aria-hidden="true" />}{k.val}</div>
<div className="tc-tile-label">{k.label}</div>
{k.note && <div className="tc-tile-note">{k.note}</div>}
</div>
);
})}
</div>
);
}
/** A single figure under a top rule, for a row of them that is not a bordered tile strip. Flagged,
* it gains a left rule as well — an L around the figure, which reads as a mark on the page rather
* than as a border. */
export function Stat({ label, val, color, flag }: { label: string; val: React.ReactNode; color?: string; flag?: boolean }) {
const on = flagged(color, flag);
return (
<div className={on ? "tc-flag" : undefined} style={{ borderTop: "2px solid var(--color-text)", paddingTop: "var(--space-2)", paddingLeft: on ? "var(--space-2)" : undefined }}>
<div className="tc-meta">{label}</div>
<div className="tc-figure" style={on ? { fontSize: 26 } : { fontSize: 26, color: color || "var(--color-text)" }}>{on && <span className="tc-mark" aria-hidden="true" />}{val}</div>
</div>
);
}
export function itemOptions(s: Snapshot) {
return s.catalog.filter((i) => !i.archived).map((it) => ({ v: it.id, label: longLabel(it) + (it.sku ? " · " + it.sku : "") }));
}
/** Item select + one button per size. */
/* The sizes default to outlined boxes rather than underlined text. They sit immediately beside the
2px select they belong to, they are the thing being aimed at, and two of the four callers had
already overridden the underline away — this makes the odd two out the same as the rest. */
export function ItemSizePicker({ s, itemId, onItem, onSize, placeholder = "Choose an item…", btnClass = "btn btn-secondary", maxWidth = 320 }: {
s: Snapshot; itemId: string; onItem: (id: string) => void; onSize: (it: Item, si: number) => void; placeholder?: string; btnClass?: string; maxWidth?: number;
}) {
const it = s.catalog.find((x) => x.id === itemId);
return (
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap" }}>
<select className="input" style={{ maxWidth }} value={itemId} onChange={(e) => onItem(e.target.value)}>
<option value="">{placeholder}</option>
{itemOptions(s).map((o) => <option key={o.v} value={o.v}>{o.label}</option>)}
</select>
{it && it.sizes.map((sz, si) => (
<button key={si} className={btnClass} style={{ minHeight: 26, padding: "2px 8px", justifyContent: "center" }} onClick={() => onSize(it, si)} title={label(it) + " " + sz}>{sz}</button>
))}
</div>
);
}
/* Two square boxes with the count between them. They were ghost buttons, which means .btn-ghost's
underline ran under the and the + and flush-left alignment pushed both glyphs off centre in
their own boxes. Outlined at 2px, they match the quantity inputs they sit in a row with. */
export function Stepper({ value, onDec, onInc, width = 24 }: { value: number; onDec: () => void; onInc: () => void; width?: number }) {
const btn: React.CSSProperties = { padding: "0 9px", minHeight: 26, justifyContent: "center" };
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
<button className="btn btn-secondary" style={btn} onClick={onDec} aria-label="decrease"></button>
<span style={{ width, textAlign: "center", fontFamily: "var(--font-heading)", fontWeight: 800 }}>{value}</span>
<button className="btn btn-secondary" style={btn} onClick={onInc} aria-label="increase">+</button>
</span>
);
}
export const th = (t: string, right = false, extra: React.CSSProperties = {}) => <th key={t} style={{ textAlign: right ? "right" : "left", ...extra }}>{t}</th>;