// Text entry. // // onInput fires on every keystroke; onCommit fires on blur and Enter. Panels // almost always want onCommit -- a settings value that updates per keystroke // writes nine intermediate values while you type "0.65". import { define } from "../internals/register.js"; import { el } from "../internals/el.js"; import { uid } from "../internals/ids.js"; import { swapping } from "../internals/swapping.js"; function wire(node, { onInput, onCommit }) { if (onInput) node.addEventListener("input", () => onInput(node.value)); if (onCommit) { let last = node.dataset.committed = node.value; // region.stack carries an edit not committed yet across a redraw const commit = () => { if (!swapping.depth && node.value !== last) { last = node.dataset.committed = node.value; onCommit(node.value); } }; node.addEventListener("blur", commit); // And `change`, which is what autofill, a password manager and a script // setting the value all fire without ever taking focus. commit() only acts // on a real difference, so the pair costs nothing when both arrive. node.addEventListener("change", commit); // Enter commits a one-line field. In a TEXTAREA it is a newline, and // stealing it there means a multi-line value cannot be typed at all -- the // prompt included, which is the longest multi-line thing in the app. if (node.tagName !== "TEXTAREA") { node.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } }); } } } function textLike(tag, cls, { value = "", placeholder, onInput, onCommit, disabled, id, type, label } = {}) { const node = el(tag, { cls: [cls, "cx-focusable"], attrs: { placeholder, disabled, id: id || uid("input"), type, "aria-label": label }, }); node.value = value; wire(node, { onInput, onCommit }); return { node, get value() { return node.value; }, setValue(v) { node.value = v ?? ""; if ("committed" in node.dataset) node.dataset.committed = node.value; }, focus: () => node.focus(), destroy: () => node.remove(), }; } for (const size of ["md", "sm"]) { define("input", size, (props = {}) => textLike("input", `cx-input cx-input-${size}`, { type: "text", ...props })); } define("search", "md", (props = {}) => { const handle = textLike("input", "cx-input cx-input-md cx-search", { type: "search", placeholder: "Search", ...props }); return handle; }); define("number", "md", ({ value = 0, min, max, step = 1, precision, unit, snap, onChange, disabled, id, label } = {}) => { const input = el("input", { cls: ["cx-input", "cx-input-md", "cx-number", "cx-focusable"], attrs: { type: "number", min, max, step, disabled, id: id || uid("num"), "aria-label": label }, }); input.value = input.dataset.committed = String(value); // data-committed: region.stack carries an uncommitted edit across a redraw let committed = Number(value); const clamp = (n) => { if (min !== undefined && n < min) return min; if (max !== undefined && n > max) return max; return precision === undefined ? n : Number(n.toFixed(precision)); }; // Clamping on commit rather than on input: clamping mid-typing makes "-" or // a leading "0." impossible to type. // // An emptied or unparseable field reverts to the last committed value rather // than being read as a number. A browser normalises invalid text in a number // input to "", and Number("") is 0 -- which is finite, so it sails past any // isFinite guard and lands on min, silently discarding what the user had. const commit = () => { if (swapping.depth) return; const raw = input.value.trim(); const parsed = raw === "" ? NaN : Number(raw); // `snap`: the values the owner can use (a model's frame grid), so the field shows what is used, not what was typed. const next = Number.isFinite(parsed) ? clamp(snap ? snap(clamp(parsed), committed) : parsed) : committed, changed = next !== committed; committed = next; input.value = input.dataset.committed = String(next); if (changed && onChange) onChange(next); }; // "change" is the browser's commit: each spinner-arrow click, each keyboard arrow, the wheel, and leaving a typed edit. // Typing does not fire it, so mid-typing values stay typeable. A panel redrawing on the commit keeps the caret in the // redrawn field (region.stack), so the next arrow press still lands. A held spinner or arrow key commits once more, on // release: a redraw mid-hold would take the field out from under the mouse, and each step would be its own undo. let held = false, skipped = false; const release = () => { window.removeEventListener("pointerup", release, true); window.removeEventListener("pointercancel", release, true); held = false; if (skipped) { skipped = false; commit(); } }; input.addEventListener("change", () => { if (held) skipped = true; else commit(); }); input.addEventListener("blur", () => { if (swapping.depth) return; held = skipped = false; commit(); }); // ends a hold whose release never came; a redraw's blur does not: the release still commits input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.repeat && /^(ArrowUp|ArrowDown|PageUp|PageDown)$/.test(e.key)) held = true; }); input.addEventListener("keyup", () => { if (held) release(); }); input.addEventListener("pointerdown", (e) => { if (e.button) return; // a right-click's release may go to its menu held = true; skipped = false; window.addEventListener("pointerup", release, true); window.addEventListener("pointercancel", release, true); }); const node = unit ? el("span", { cls: "cx-number-wrap", children: [input, el("span", { cls: "cx-unit", text: unit })] }) : input; return { node, get value() { return committed; }, setValue(v) { const parsed = Number(v); committed = Number.isFinite(parsed) ? clamp(parsed) : committed; input.value = input.dataset.committed = String(committed); }, destroy: () => node.remove(), }; }); define("stepper", "md", ({ value = 0, min, max, step = 1, onChange, label } = {}) => { let current = value; const out = el("input", { cls: ["cx-input", "cx-input-md", "cx-number", "cx-focusable"], attrs: { type: "number", min, max, step, "aria-label": label } }); out.value = String(current); const set = (next) => { if (min !== undefined) next = Math.max(min, next); if (max !== undefined) next = Math.min(max, next); current = Number(next.toFixed(6)); // 0.1 + 0.2 must not become 0.30000000000000004 out.value = String(current); if (onChange) onChange(current); }; const bump = (dir) => el("button", { cls: ["cx-step-btn", "cx-focusable"], text: dir > 0 ? "+" : "−", attrs: { type: "button", "aria-label": `${dir > 0 ? "Increase" : "Decrease"}${label ? ` ${label}` : ""}` }, on: { click: () => set(current + dir * step) }, }); out.addEventListener("change", () => set(Number(out.value))); const node = el("span", { cls: "cx-stepper", children: [bump(-1), out, bump(1)] }); return { node, get value() { return current; }, setValue: set, destroy: () => node.remove() }; }); // `fold`: one line like an input until clicked, then as tall as its text (Enter is a new line); one line again on leaving. define("textarea", "md", ({ value = "", rows = 4, autoGrow = false, fold = false, placeholder, onInput, onCommit, label } = {}) => { const node = el("textarea", { cls: ["cx-textarea", "cx-focusable", autoGrow || fold ? "cx-autogrow" : null, fold ? "cx-fold" : null], attrs: { rows: fold ? 1 : rows, placeholder, "aria-label": label }, }); node.value = value; const grow = () => { node.style.height = "auto"; node.style.height = `${node.scrollHeight}px`; }; if (autoGrow) { node.addEventListener("input", grow); queueMicrotask(grow); } if (fold) { node.addEventListener("focus", grow); node.addEventListener("input", grow); node.addEventListener("blur", () => { node.style.height = ""; node.scrollTop = 0; }); } wire(node, { onInput, onCommit }); return { node, get value() { return node.value; }, setValue(v) { node.value = v ?? ""; if ("committed" in node.dataset) node.dataset.committed = node.value; if (autoGrow || (fold && document.activeElement === node)) grow(); }, destroy: () => node.remove(), }; }); /** * A search box over a list. v4 had seven of these, each with its own filter. * Items are DATA -- {id, label, hint, icon} -- never a render callback, so a * module cannot smuggle markup in through a "just render this" escape hatch. */ const FILTER_ICON_TONES = new Set(["accent", "danger", "warn", "good", "neutral"]); define("filterList", "md", ({ items = [], value, onChange, placeholder = "Search", empty = "Nothing matches", query = "" } = {}) => { let selected = value; const search = el("input", { cls: ["cx-input", "cx-input-sm", "cx-search", "cx-focusable"], attrs: { type: "search", placeholder } }); search.value = query; // a list rebuilt after an edit starts from what was typed const list = el("div", { cls: "cx-filter-list", attrs: { role: "listbox" } }); function draw() { const q = search.value.trim().toLowerCase(); // `keywords` is searched but never drawn -- a way in that does not name // itself in the visible label/hint (a section called "About FunUI" // found by typing "commit", say). const shown = items.filter((i) => !q || `${i.label} ${i.hint || ""} ${i.keywords || ""}`.toLowerCase().includes(q)); list.replaceChildren(); if (!shown.length) { list.append(el("div", { cls: "cx-filter-empty", text: empty })); return; } let before; for (const item of shown) { if (before !== undefined && item.group !== before) list.append(el("div", { cls: "cx-filter-gap", attrs: { "aria-hidden": "true" } })); // a new group starts after a gap before = item.group; // `tone` picks one of a FIXED set of chip colours -- never a raw // colour from data, which is the rule `el()`'s style refusal exists to // hold everywhere else too. Enforced, not just claimed: an unknown // tone throws, the same as button.js's own tone check, rather than // silently rendering an unstyled chip. if (item.tone && !FILTER_ICON_TONES.has(item.tone)) { throw new RangeError(`Unknown filterList icon tone "${item.tone}". Known: ${[...FILTER_ICON_TONES].join(", ")}.`); } const row = el("button", { cls: ["cx-filter-row", "cx-focusable", item.id === selected ? "cx-on" : null], attrs: { type: "button", role: "option", "aria-selected": String(item.id === selected) }, children: [ item.icon ? el("span", { cls: ["cx-filter-icon", item.tone ? `cx-filter-icon-${item.tone}` : null], text: item.icon, }) : null, // A column, not two more flex siblings beside the icon -- a hint // long enough to wrap needs to wrap UNDER its own label, not float // as its own baseline-aligned sibling next to it. el("div", { cls: "cx-filter-text", children: [ el("span", { cls: "cx-filter-label", text: item.label }), item.hint ? el("span", { cls: "cx-filter-hint", text: item.hint }) : null, ].filter(Boolean) }), ].filter(Boolean), }); row.addEventListener("click", () => { selected = item.id; draw(); if (onChange) onChange(item.id); }); list.append(row); } } search.addEventListener("input", draw); draw(); const node = el("div", { cls: "cx-filter", children: [search, list] }); return { node, get value() { return selected; }, get query() { return search.value; }, setValue(v) { selected = v; draw(); }, destroy: () => node.remove(), }; });