Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
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
2026-10-25 13:11:03 +00:00

248 lines
12 KiB
JavaScript
Raw Permalink 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.
// 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(),
};
});