feat(web): mobile-first tokens and controls
44px controls, a separate 24px chip height, one row height, a gutter at every width, min-width breakpoints, and the shared control and row vocabulary in ui.ts. check-tokens now also rejects var() names that nothing defines. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
460b82f039
commit
ce6d38d044
@@ -2,6 +2,11 @@
|
||||
// Design-token gate (issue #45): components consume tokens from :root in
|
||||
// web/src/style.css, never literal colour or spacing values.
|
||||
//
|
||||
// Every `var(--x)` must name a custom property something defines — a token in
|
||||
// `:root`, a component-local one in the CSS, or one the TS sets with
|
||||
// `setProperty` — so a mistyped or deleted token fails here rather than
|
||||
// silently falling back to the browser default.
|
||||
//
|
||||
// Allowed colour literals: inside a `:root` block (token definitions) and
|
||||
// inside `@font-face`. Relative colour derived from a token —
|
||||
// `oklch(from var(--x) …)` — counts as consuming the token and is allowed
|
||||
@@ -114,16 +119,37 @@ function checkPlain(source, rel) {
|
||||
}
|
||||
|
||||
const files = [join(webRoot, "index.html"), ...walk(join(webRoot, "src"))];
|
||||
for (const file of files) {
|
||||
const rel = relative(webRoot, file);
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (file.endsWith(".css")) {
|
||||
const sources = files.map((file) => ({
|
||||
rel: relative(webRoot, file),
|
||||
source: readFileSync(file, "utf8"),
|
||||
}));
|
||||
for (const { rel, source } of sources) {
|
||||
if (rel.endsWith(".css")) {
|
||||
checkCss(source, rel);
|
||||
} else {
|
||||
checkPlain(source, rel);
|
||||
}
|
||||
}
|
||||
|
||||
const defined = new Set();
|
||||
for (const { source } of sources) {
|
||||
for (const match of source.matchAll(/(--[\w-]+)\s*:/g)) {
|
||||
defined.add(match[1]);
|
||||
}
|
||||
for (const match of source.matchAll(/setProperty\(\s*["'](--[\w-]+)["']/g)) {
|
||||
defined.add(match[1]);
|
||||
}
|
||||
}
|
||||
for (const { rel, source } of sources) {
|
||||
source.split("\n").forEach((line, index) => {
|
||||
for (const match of line.matchAll(/var\(\s*(--[\w-]+)/g)) {
|
||||
if (!defined.has(match[1])) {
|
||||
errors.push(`${rel}:${index + 1}: unknown token ${match[1]} — nothing defines it`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("check-tokens: components must consume design tokens:\n");
|
||||
for (const error of errors) {
|
||||
|
||||
+40
-22
@@ -40,14 +40,36 @@ const ICON_PATHS = {
|
||||
],
|
||||
chevronUp: ["M3 10.2l5-4.6 5 4.6"],
|
||||
chevronDown: ["M3 5.8l5 4.6 5-4.6"],
|
||||
chevronRight: ["M6 3l4.6 5L6 13"],
|
||||
back: ["M13 8H3.4", "M7.4 3.8L3.2 8l4.2 4.2"],
|
||||
play: ["M4.6 2.9v10.2c0 .4.4.6.7.4l7.8-5.1c.3-.2.3-.6 0-.8L5.3 2.5c-.3-.2-.7 0-.7.4Z"],
|
||||
more: [
|
||||
"M3.2 7.3a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4Z",
|
||||
"M8 7.3a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4Z",
|
||||
"M12.8 7.3a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4Z",
|
||||
],
|
||||
plus: ["M8 3v10", "M3 8h10"],
|
||||
grid: [
|
||||
"M2.5 2.5h4.4v4.4H2.5z",
|
||||
"M9.1 2.5h4.4v4.4H9.1z",
|
||||
"M2.5 9.1h4.4v4.4H2.5z",
|
||||
"M9.1 9.1h4.4v4.4H9.1z",
|
||||
],
|
||||
list: ["M5.6 4h8", "M5.6 8h8", "M5.6 12h8", "M2.4 4h.01", "M2.4 8h.01", "M2.4 12h.01"],
|
||||
library: ["M2.5 2.5h3v11h-3z", "M6.5 2.5h3v11h-3z", "M10.3 3.1l2.9-.8 2.3 10.6-2.9.7z"],
|
||||
close: ["M3.6 3.6l8.8 8.8", "M12.4 3.6l-8.8 8.8"],
|
||||
info: ["M8 1.8a6.2 6.2 0 1 1 0 12.4A6.2 6.2 0 0 1 8 1.8Z", "M8 7.2v4", "M8 4.8h.01"],
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICON_PATHS;
|
||||
|
||||
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
|
||||
export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
|
||||
export function icon(name: IconName): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 16 16");
|
||||
svg.setAttribute("class", "icon");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.dataset.icon = name;
|
||||
for (const d of ICON_PATHS[name]) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", d);
|
||||
@@ -57,24 +79,27 @@ export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* How much room the control has. A settings row can spell the confirmation
|
||||
* out; a chip cannot, and says the same thing in one glyph.
|
||||
* How much room the control has, and where the armed state explains itself.
|
||||
*/
|
||||
export interface ArmedDeleteOptions {
|
||||
/**
|
||||
* Chip scale: the control plate comes off and the armed state swaps the
|
||||
* trash for a check instead of rendering the word "confirm", which a chip
|
||||
* has no width for (§9.6 keeps a chip to one row). Everything else — the
|
||||
* two clicks, the amber armed state, the 4-second disarm, the label — is
|
||||
* the same control, so a chip and a settings row arm alike.
|
||||
* Chip scale: the control plate comes off (§9.6 keeps a chip to one row).
|
||||
* The two clicks, the amber armed state and the 4-second disarm are the
|
||||
* same control either way.
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* The row's status slot. Arming writes "tap again to …" here and disarming
|
||||
* clears it, so the button itself never changes size — the explanation
|
||||
* lives beside it, not inside it.
|
||||
*/
|
||||
explain?: (text: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings arm-then-confirm, icon-only: a trash glyph resting, a visible
|
||||
* change of what the next click does while armed, so an accidental hit never
|
||||
* destroys.
|
||||
* The arm-then-confirm, icon-only: a trash glyph resting, an amber check
|
||||
* while armed, in the same square either way, so an accidental hit never
|
||||
* destroys and arming never moves its neighbours.
|
||||
*/
|
||||
export function armedDeleteIcon(
|
||||
label: string,
|
||||
@@ -83,12 +108,9 @@ export function armedDeleteIcon(
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = options.compact
|
||||
? "control control-quiet control-icon control-micro"
|
||||
: "control control-quiet control-icon";
|
||||
button.className = options.compact ? "control-micro" : "control control-icon";
|
||||
button.dataset.kind = "delete";
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
button.replaceChildren(icon("trash"));
|
||||
let armed = false;
|
||||
let resetTimer: number | undefined;
|
||||
@@ -97,8 +119,8 @@ export function armedDeleteIcon(
|
||||
window.clearTimeout(resetTimer);
|
||||
delete button.dataset.armed;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
button.replaceChildren(icon("trash"));
|
||||
options.explain?.(null);
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
if (armed) {
|
||||
@@ -109,13 +131,9 @@ export function armedDeleteIcon(
|
||||
}
|
||||
armed = true;
|
||||
button.dataset.armed = "true";
|
||||
if (options.compact) {
|
||||
button.replaceChildren(icon("check"));
|
||||
} else {
|
||||
button.textContent = "confirm";
|
||||
}
|
||||
button.replaceChildren(icon("check"));
|
||||
button.setAttribute("aria-label", `confirm — ${label}`);
|
||||
button.title = `confirm — ${label}`;
|
||||
options.explain?.(`tap again to ${label}`);
|
||||
resetTimer = window.setTimeout(disarm, 4000);
|
||||
});
|
||||
button.addEventListener("blur", () => {
|
||||
|
||||
+2344
-2276
File diff suppressed because it is too large
Load Diff
+434
@@ -0,0 +1,434 @@
|
||||
// The shared control and row vocabulary (Master Control, revision 2). Every
|
||||
// list in the app is one row primitive with a fixed action column, and every
|
||||
// button is one of five controls. A view that cannot fit them grows a variant
|
||||
// here; it does not keep a private copy.
|
||||
|
||||
import { type IconName, icon } from "./icons";
|
||||
|
||||
/* ---- controls ------------------------------------------------------------ */
|
||||
|
||||
export interface ControlOptions {
|
||||
/** Secondary actions: ink on a hairline, never a toggle. */
|
||||
quiet?: boolean;
|
||||
/** A leading glyph beside the word. */
|
||||
glyph?: IconName;
|
||||
/** Fixed width, so a label that changes (GRAB → SENDING → SENT) never moves the row. */
|
||||
fixed?: boolean;
|
||||
}
|
||||
|
||||
/** A word control: the primary action of a header or panel, or a quiet secondary one. */
|
||||
export function control(text: string, options: ControlOptions = {}): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "control";
|
||||
if (options.quiet) {
|
||||
button.classList.add("control-quiet");
|
||||
}
|
||||
if (options.fixed) {
|
||||
button.classList.add("control-fixed");
|
||||
}
|
||||
setControlText(button, text, options.glyph);
|
||||
return button;
|
||||
}
|
||||
|
||||
/** Relabels a word control, keeping its glyph. */
|
||||
export function setControlText(button: HTMLElement, text: string, glyph?: IconName) {
|
||||
const word = document.createElement("span");
|
||||
word.className = "control-word";
|
||||
word.textContent = text;
|
||||
button.replaceChildren(...(glyph ? [icon(glyph), word] : [word]));
|
||||
}
|
||||
|
||||
/** A 44px square glyph control. The label is the accessible name and nothing else. */
|
||||
export function iconControl(glyph: IconName, label: string): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "control control-icon";
|
||||
button.setAttribute("aria-label", label);
|
||||
button.append(icon(glyph));
|
||||
return button;
|
||||
}
|
||||
|
||||
/** An external link wearing the icon control's skin. */
|
||||
export function iconLink(content: Node, label: string, href: string): HTMLAnchorElement {
|
||||
const link = document.createElement("a");
|
||||
link.className = "control control-icon";
|
||||
link.href = href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.setAttribute("aria-label", label);
|
||||
link.append(content);
|
||||
return link;
|
||||
}
|
||||
|
||||
export interface LampToggle {
|
||||
button: HTMLButtonElement;
|
||||
set: (pressed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule that is on or off: a lamp plus the word, never a border colour
|
||||
* alone (the Word-Beside-The-Lamp rule). The state word has a fixed width,
|
||||
* so flipping it never moves anything.
|
||||
*/
|
||||
export function lampToggle(label: string, pressed: boolean): LampToggle {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "toggle";
|
||||
const lamp = document.createElement("span");
|
||||
lamp.className = "toggle-lamp";
|
||||
lamp.setAttribute("aria-hidden", "true");
|
||||
const name = document.createElement("span");
|
||||
name.className = "toggle-label";
|
||||
name.textContent = label;
|
||||
const state = document.createElement("span");
|
||||
state.className = "toggle-state";
|
||||
state.setAttribute("aria-hidden", "true");
|
||||
button.append(lamp, name, state);
|
||||
const set = (next: boolean) => {
|
||||
button.setAttribute("aria-pressed", String(next));
|
||||
state.textContent = next ? "on" : "off";
|
||||
};
|
||||
set(pressed);
|
||||
return { button, set };
|
||||
}
|
||||
|
||||
export interface Disclosure {
|
||||
button: HTMLButtonElement;
|
||||
/** Repaints the label from the current count; the width is the longest label's. */
|
||||
paint: (count: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show / hide at full control height. The verb swaps but the plate keeps
|
||||
* one width, and the count is tabular, so opening never moves the head.
|
||||
*/
|
||||
export function disclosure(noun: string, target: HTMLElement): Disclosure {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "control control-quiet disclose";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
const word = document.createElement("span");
|
||||
word.className = "control-word";
|
||||
button.append(word, icon("chevronDown"));
|
||||
let last = 0;
|
||||
const paint = (count: number) => {
|
||||
last = count;
|
||||
const open = !target.hidden;
|
||||
word.textContent = `${open ? "hide" : "show"} ${count}${noun === "" ? "" : ` ${noun}`}`;
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
target.hidden = !target.hidden;
|
||||
paint(last);
|
||||
});
|
||||
return { button, paint };
|
||||
}
|
||||
|
||||
/**
|
||||
* The arm-then-confirm as a labelled control, for the ⋯ tray where a phone
|
||||
* spells the action out. Full width there, so the armed label can be longer.
|
||||
*/
|
||||
export function armedDeleteWord(label: string, execute: () => void): HTMLButtonElement {
|
||||
const button = control(label, { quiet: true, glyph: "trash" });
|
||||
button.dataset.kind = "delete";
|
||||
let armed = false;
|
||||
let timer: number | undefined;
|
||||
const disarm = () => {
|
||||
armed = false;
|
||||
window.clearTimeout(timer);
|
||||
delete button.dataset.armed;
|
||||
setControlText(button, label, "trash");
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
if (armed) {
|
||||
disarm();
|
||||
button.disabled = true;
|
||||
execute();
|
||||
return;
|
||||
}
|
||||
armed = true;
|
||||
button.dataset.armed = "true";
|
||||
setControlText(button, `tap again to ${label}`, "check");
|
||||
timer = window.setTimeout(disarm, 4000);
|
||||
});
|
||||
button.addEventListener("blur", () => {
|
||||
if (armed) {
|
||||
disarm();
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/* ---- status -------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Writes a status slot. The slot is always laid out — an empty one keeps its
|
||||
* line — so a message arriving or leaving never pushes the content below it.
|
||||
*/
|
||||
export function setSlot(
|
||||
slot: HTMLElement,
|
||||
text: string | null,
|
||||
tone?: "fault" | "warn",
|
||||
busy = false,
|
||||
) {
|
||||
slot.dataset.empty = String(text === null);
|
||||
if (busy && text !== null) {
|
||||
const lamp = document.createElement("span");
|
||||
lamp.className = "lamp";
|
||||
lamp.dataset.state = "probing";
|
||||
lamp.setAttribute("aria-hidden", "true");
|
||||
slot.replaceChildren(lamp, document.createTextNode(text));
|
||||
} else {
|
||||
slot.textContent = text ?? "";
|
||||
}
|
||||
if (tone) {
|
||||
slot.dataset.tone = tone;
|
||||
} else {
|
||||
delete slot.dataset.tone;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the row --------------------------------------------------------------- */
|
||||
|
||||
/** One cell in a row's action column. An empty cell still takes its width. */
|
||||
export interface ActionSlot {
|
||||
node: HTMLElement | null;
|
||||
/** The width class: a glyph square, a lamp toggle, or a fixed word control. */
|
||||
width: "icon" | "toggle" | "word";
|
||||
/** Shown from 40rem up; on a phone this action lives in the ⋯ tray instead. */
|
||||
wide?: boolean;
|
||||
/** Shown below 40rem only — an empty cell that keeps a phone column aligned. */
|
||||
narrow?: boolean;
|
||||
}
|
||||
|
||||
export interface RowParts {
|
||||
/** `data-row`: which kind of row, for the few rules that differ by kind. */
|
||||
kind: string;
|
||||
lead?: HTMLElement | null;
|
||||
title?: HTMLElement | string | null;
|
||||
meta?: (HTMLElement | null)[];
|
||||
sub?: HTMLElement | string | null;
|
||||
actions?: ActionSlot[];
|
||||
/** The phone tray: labelled, full-width versions of the wide-only actions. */
|
||||
more?: HTMLElement[];
|
||||
/** The whole row opens something: a stretched button under the controls. */
|
||||
open?: { label: string; run: (origin: HTMLElement) => void; chevron?: boolean };
|
||||
}
|
||||
|
||||
export interface RowDom {
|
||||
item: HTMLLIElement;
|
||||
body: HTMLElement;
|
||||
meta: HTMLElement;
|
||||
sub: HTMLElement | null;
|
||||
act: HTMLElement;
|
||||
/** Full-width area under the line: notes, the ⋯ actions, inline panels. */
|
||||
tray: HTMLElement;
|
||||
link: HTMLButtonElement | null;
|
||||
/** The row's status slot, in the tray. Null clears it. */
|
||||
say: (text: string | null, tone?: "fault" | "warn") => void;
|
||||
}
|
||||
|
||||
/** A chip, the §9.3 column unit. */
|
||||
export function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
|
||||
const span = document.createElement("span");
|
||||
span.className = "chip readout";
|
||||
span.textContent = text;
|
||||
extra?.(span);
|
||||
return span;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one row: `lead | body | actions`, plus a full-width tray. The body is
|
||||
* `minmax(0, 1fr)`, so a long title or a run of chips is clipped inside it
|
||||
* and can never push the actions onto a second line.
|
||||
*/
|
||||
export function row(parts: RowParts): RowDom {
|
||||
const item = document.createElement("li");
|
||||
item.className = "r";
|
||||
item.dataset.row = parts.kind;
|
||||
|
||||
if (parts.lead) {
|
||||
const lead = document.createElement("div");
|
||||
lead.className = "r-lead";
|
||||
lead.append(parts.lead);
|
||||
item.append(lead);
|
||||
} else {
|
||||
item.dataset.nolead = "";
|
||||
}
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "r-body";
|
||||
if (parts.title !== undefined && parts.title !== null) {
|
||||
const title = document.createElement("div");
|
||||
title.className = "r-title";
|
||||
if (typeof parts.title === "string") {
|
||||
title.textContent = parts.title;
|
||||
} else {
|
||||
title.append(parts.title);
|
||||
}
|
||||
body.append(title);
|
||||
}
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "r-meta";
|
||||
for (const piece of parts.meta ?? []) {
|
||||
if (piece !== null) {
|
||||
meta.append(piece);
|
||||
}
|
||||
}
|
||||
body.append(meta);
|
||||
let sub: HTMLElement | null = null;
|
||||
if (parts.sub !== undefined && parts.sub !== null) {
|
||||
sub = document.createElement("div");
|
||||
sub.className = "r-sub readout";
|
||||
if (typeof parts.sub === "string") {
|
||||
sub.textContent = parts.sub;
|
||||
} else {
|
||||
sub.append(parts.sub);
|
||||
}
|
||||
body.append(sub);
|
||||
}
|
||||
item.append(body);
|
||||
|
||||
const act = document.createElement("div");
|
||||
act.className = "r-act";
|
||||
const tray = document.createElement("div");
|
||||
tray.className = "r-tray";
|
||||
const note = document.createElement("p");
|
||||
note.className = "r-note readout";
|
||||
note.setAttribute("role", "status");
|
||||
note.hidden = true;
|
||||
tray.append(note);
|
||||
|
||||
for (const slot of parts.actions ?? []) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "r-slot";
|
||||
cell.dataset.width = slot.width;
|
||||
if (slot.wide) {
|
||||
cell.dataset.wide = "";
|
||||
}
|
||||
if (slot.narrow) {
|
||||
cell.dataset.narrow = "";
|
||||
}
|
||||
if (slot.node) {
|
||||
cell.append(slot.node);
|
||||
}
|
||||
act.append(cell);
|
||||
}
|
||||
|
||||
if (parts.more && parts.more.length > 0) {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "r-morepanel";
|
||||
panel.hidden = true;
|
||||
panel.append(...parts.more);
|
||||
const more = iconControl("more", "more actions");
|
||||
more.setAttribute("aria-expanded", "false");
|
||||
more.addEventListener("click", () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
more.setAttribute("aria-expanded", String(!panel.hidden));
|
||||
});
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "r-slot";
|
||||
cell.dataset.width = "icon";
|
||||
cell.dataset.narrow = "";
|
||||
cell.append(more);
|
||||
act.append(cell);
|
||||
tray.append(panel);
|
||||
}
|
||||
|
||||
let link: HTMLButtonElement | null = null;
|
||||
if (parts.open) {
|
||||
const open = parts.open;
|
||||
link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "r-link";
|
||||
link.setAttribute("aria-label", open.label);
|
||||
const target = link;
|
||||
link.addEventListener("click", () => open.run(target));
|
||||
item.prepend(link);
|
||||
if (open.chevron !== false && (parts.actions ?? []).length === 0) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "r-slot r-chevron";
|
||||
cell.dataset.width = "icon";
|
||||
cell.setAttribute("aria-hidden", "true");
|
||||
cell.append(icon("chevronRight"));
|
||||
act.append(cell);
|
||||
}
|
||||
}
|
||||
|
||||
item.append(act, tray);
|
||||
|
||||
const say = (text: string | null, tone?: "fault" | "warn") => {
|
||||
note.hidden = text === null;
|
||||
note.textContent = text ?? "";
|
||||
if (tone) {
|
||||
note.dataset.tone = tone;
|
||||
} else {
|
||||
delete note.dataset.tone;
|
||||
}
|
||||
};
|
||||
|
||||
return { item, body, meta, sub, act, tray, link, say };
|
||||
}
|
||||
|
||||
/** A row-shaped placeholder: same height and columns as the row it stands for. */
|
||||
export function skeletonRows(count: number, withLead = true): HTMLLIElement[] {
|
||||
return Array.from({ length: count }, () => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "r r-skel";
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
if (withLead) {
|
||||
const lead = document.createElement("div");
|
||||
lead.className = "r-lead";
|
||||
lead.append(skeletonBar("skel-lead"));
|
||||
item.append(lead);
|
||||
} else {
|
||||
item.dataset.nolead = "";
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "r-body";
|
||||
body.append(skeletonBar("skel-line"), skeletonBar("skel-line skel-short"));
|
||||
const act = document.createElement("div");
|
||||
act.className = "r-act";
|
||||
item.append(body, act);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function skeletonBar(className: string): HTMLSpanElement {
|
||||
const bar = document.createElement("span");
|
||||
bar.className = `skel ${className}`;
|
||||
return bar;
|
||||
}
|
||||
|
||||
/* ---- tap to explain -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A chip whose meaning would otherwise live in a tooltip. Touch has no hover,
|
||||
* so the chip becomes a button that writes its sentence into the row's own
|
||||
* status slot, and a second tap takes it away.
|
||||
*/
|
||||
export function explainable(
|
||||
target: HTMLElement,
|
||||
text: string,
|
||||
say: (text: string | null, tone?: "fault" | "warn") => void,
|
||||
tone?: "fault" | "warn",
|
||||
) {
|
||||
target.setAttribute("role", "button");
|
||||
target.tabIndex = 0;
|
||||
target.dataset.explain = "";
|
||||
target.setAttribute("aria-expanded", "false");
|
||||
const toggle = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const open = target.getAttribute("aria-expanded") !== "true";
|
||||
target.setAttribute("aria-expanded", String(open));
|
||||
say(open ? text : null, tone);
|
||||
};
|
||||
target.addEventListener("click", toggle);
|
||||
target.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
toggle(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user