Files
olivv-cs-ComfyUI-FunPack/app/composer/elements/input.js
T
digital-garbage ecbd9e8519 app: two findings from the adversarial pass on the Settings rewrite
An outside click on the Settings window closed it unconditionally --
pipeline_window.js's own standalone modal has always refused to close
that way (a group edit's draft is the only copy of it), and hosting it
inside Settings silently dropped that guarantee, with no way left for a
real user to reach the protected version (boot.js has no other caller
of pipeline_window.js any more). Settings now holds the door the same
way, for every section: an accidental backdrop click during any
half-finished edit no longer costs it.

filterList's icon `tone` claimed to be a closed set, the same rule
el()'s style refusal already holds -- but nothing checked it, so a
typo would have rendered a silent, unstyled chip instead of failing
loudly the way button.js's own tone check already does for the same
mistake. Now it throws, matching that sibling pattern.
2026-09-07 12:23:36 +03:00

214 lines
8.8 KiB
JavaScript
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.
// 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";
function wire(node, { onInput, onCommit }) {
if (onInput) node.addEventListener("input", () => onInput(node.value));
if (onCommit) {
let last = node.value;
const commit = () => { if (node.value !== last) { last = 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 ?? ""; },
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, 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 = String(value);
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 = () => {
const raw = input.value.trim();
const parsed = raw === "" ? NaN : Number(raw);
const next = Number.isFinite(parsed) ? clamp(parsed) : committed;
committed = next;
input.value = String(next);
if (onChange) onChange(next);
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } });
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 = 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() };
});
define("textarea", "md", ({ value = "", rows = 4, autoGrow = false, placeholder, onInput, onCommit, label } = {}) => {
const node = el("textarea", {
cls: ["cx-textarea", "cx-focusable", autoGrow ? "cx-autogrow" : null],
attrs: { 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); }
wire(node, { onInput, onCommit });
return {
node,
get value() { return node.value; },
setValue(v) { node.value = v ?? ""; if (autoGrow) 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" } = {}) => {
let selected = value;
const search = el("input", { cls: ["cx-input", "cx-input-sm", "cx-search", "cx-focusable"],
attrs: { type: "search", placeholder } });
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 FunPack"
// 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; }
for (const item of shown) {
// `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; },
setValue(v) { selected = v; draw(); },
destroy: () => node.remove(),
};
});