Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
248 lines
12 KiB
JavaScript
248 lines
12 KiB
JavaScript
// 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(),
|
||
};
|
||
});
|