One declaration does three jobs: renders the panel, carries the defaults, names
the values that come back. A module writes no DOM and reads no DOM.
Validation refuses rather than repairs -- unknown type, a renderer its type does
not have, a label that is not text, a default outside its own bounds, a when
naming a key the module never declared. A module that is wrong does not get a
best-effort panel; it does not load, and says why once.
Ordering is by declared relation and stage, never priority numbers, and a cycle
drops only the modules in it. Compatibility is by trait, never model name.
The shell hands a module { composer, values, on, shell } and nothing else: no
node, no document, no kit internals. It cannot style anything because it is
never given anything that could.
catalogue/hide.html puts one good module and five broken ones through the real
shell and shows the result: one panel, five absent, no placeholders. The lint
fixtures make the scanner prove it catches each rule, so a clean scan means
something.
First slice: modules/system/appearance. Two settings, an optional ui.js, and
clicking Light actually turns the app light.
226 lines
8.7 KiB
JavaScript
226 lines
8.7 KiB
JavaScript
// Choosing: on/off, one-of, several-of.
|
|
//
|
|
// The checkbox is drawn rather than native. v4 used the OS control with only
|
|
// accent-color, which meant its size, focus ring and disabled look were the
|
|
// browser's opinion, not the kit's -- and it needed `width: auto` undone in four
|
|
// places because a global `input { width: 100% }` had stretched it.
|
|
|
|
import { define } from "../internals/register.js";
|
|
import { el } from "../internals/el.js";
|
|
import { uid } from "../internals/ids.js";
|
|
import { roving } from "../internals/focus.js";
|
|
|
|
// `ariaLabel` is passed only by the bare checkbox, which has no visible text of
|
|
// its own. In a checkbox ROW the adjacent text is the label (via `for`), and
|
|
// adding aria-label there would override what the user can actually see.
|
|
function checkboxNode({ checked = false, indeterminate = false, disabled, id, ariaLabel, describedBy }) {
|
|
const input = el("input", {
|
|
cls: "cx-check-input",
|
|
attrs: { type: "checkbox", id, disabled, checked: checked || undefined,
|
|
"aria-describedby": describedBy, "aria-label": ariaLabel },
|
|
});
|
|
input.checked = Boolean(checked);
|
|
input.indeterminate = Boolean(indeterminate);
|
|
const box = el("span", { cls: "cx-check-box", attrs: { "aria-hidden": "true" } });
|
|
return { input, box };
|
|
}
|
|
|
|
define("checkbox", "default", ({ checked, indeterminate, disabled, onChange, label } = {}) => {
|
|
if (!label) throw new TypeError("checkbox needs a label: with no visible text it is its only accessible name. Use checkboxRow for a labelled row.");
|
|
const id = uid("check");
|
|
const { input, box } = checkboxNode({ checked, indeterminate, disabled, id, ariaLabel: label });
|
|
if (onChange) input.addEventListener("change", () => onChange(input.checked));
|
|
const node = el("span", { cls: ["cx-check", "cx-focusable"], children: [input, box] });
|
|
return {
|
|
node,
|
|
get value() { return input.checked; },
|
|
setValue(v) { input.checked = Boolean(v); input.indeterminate = false; },
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|
|
|
|
define("checkboxRow", "default", ({ label, hint, checked, disabled, onChange } = {}) => {
|
|
const id = uid("check");
|
|
const hintId = hint ? uid("hint") : undefined;
|
|
const { input, box } = checkboxNode({ checked, disabled, id, describedBy: hintId });
|
|
if (onChange) input.addEventListener("change", () => onChange(input.checked));
|
|
|
|
const text = el("span", { cls: "cx-check-text", children: [
|
|
el("span", { cls: "cx-check-label", text: label }),
|
|
hint ? el("span", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
|
|
].filter(Boolean) });
|
|
|
|
// The input is a SIBLING of its label, not a child of it.
|
|
//
|
|
// Both forms are valid, and browsers guard a nested control against being
|
|
// activated twice by its own label. Keeping them separate means never relying
|
|
// on that guard -- the CSS reaches the box through the label instead of past
|
|
// it, which costs one selector and removes a whole class of subtlety.
|
|
const node = el("div", { cls: "cx-check-row", children: [
|
|
input,
|
|
el("label", { cls: "cx-check-face", attrs: { for: id }, children: [box, text] }),
|
|
] });
|
|
return {
|
|
node,
|
|
get value() { return input.checked; },
|
|
setValue(v) { input.checked = Boolean(v); },
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|
|
|
|
define("checklist", "default", ({ items = [], values = [], onChange, label } = {}) => {
|
|
const chosen = new Set(values);
|
|
const node = el("div", { cls: "cx-checklist", attrs: { role: "group", "aria-label": label } });
|
|
for (const item of items) {
|
|
const id = uid("check");
|
|
const { input, box } = checkboxNode({ checked: chosen.has(item.value), id, disabled: item.disabled });
|
|
input.addEventListener("change", () => {
|
|
input.checked ? chosen.add(item.value) : chosen.delete(item.value);
|
|
if (onChange) onChange([...chosen]);
|
|
});
|
|
node.append(el("div", { cls: "cx-check-row", children: [
|
|
input,
|
|
el("label", { cls: "cx-check-face", attrs: { for: id }, children: [
|
|
box, el("span", { cls: "cx-check-label", text: item.label }),
|
|
] }),
|
|
] }));
|
|
}
|
|
return {
|
|
node,
|
|
get value() { return [...chosen]; },
|
|
setValue(next) {
|
|
chosen.clear();
|
|
for (const v of next || []) chosen.add(v);
|
|
node.querySelectorAll(".cx-check-input").forEach((input, i) => {
|
|
input.checked = chosen.has(items[i].value);
|
|
});
|
|
},
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|
|
|
|
define("radioGroup", "default", ({ options = [], value, onChange, orientation = "vertical", label } = {}) => {
|
|
const name = uid("radio");
|
|
let current = value;
|
|
const node = el("div", {
|
|
cls: ["cx-radio-group", `cx-radio-${orientation}`],
|
|
attrs: { role: "radiogroup", "aria-label": label },
|
|
});
|
|
|
|
const inputs = options.map((option) => {
|
|
const id = uid("radio-opt");
|
|
const input = el("input", { cls: "cx-radio-input",
|
|
attrs: { type: "radio", name, id, disabled: option.disabled } });
|
|
input.checked = option.value === current;
|
|
input.addEventListener("change", () => {
|
|
if (!input.checked) return;
|
|
current = option.value;
|
|
if (onChange) onChange(current);
|
|
});
|
|
node.append(el("div", { cls: "cx-radio", children: [
|
|
input,
|
|
el("label", { cls: "cx-check-face", attrs: { for: id }, children: [
|
|
el("span", { cls: "cx-radio-dot", attrs: { "aria-hidden": "true" } }),
|
|
el("span", { cls: "cx-check-text", children: [
|
|
el("span", { cls: "cx-check-label", text: option.label }),
|
|
option.hint ? el("span", { cls: "cx-hint", text: option.hint }) : null,
|
|
].filter(Boolean) }),
|
|
] }),
|
|
] }));
|
|
return { option, input };
|
|
});
|
|
|
|
return {
|
|
node,
|
|
get value() { return current; },
|
|
setValue(v) { current = v; for (const { option, input } of inputs) input.checked = option.value === v; },
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|
|
|
|
for (const size of ["md", "sm"]) {
|
|
define("select", size, ({ options = [], value, onChange, disabled, label, id } = {}) => {
|
|
const node = el("select", {
|
|
cls: ["cx-select", `cx-select-${size}`, "cx-focusable"],
|
|
attrs: { disabled, "aria-label": label, id: id || uid("select") },
|
|
});
|
|
for (const option of options) {
|
|
const o = el("option", { text: option.label, attrs: { value: option.value, disabled: option.disabled } });
|
|
node.append(o);
|
|
}
|
|
if (value !== undefined) node.value = value;
|
|
if (onChange) node.addEventListener("change", () => onChange(node.value));
|
|
return {
|
|
node,
|
|
get value() { return node.value; },
|
|
setValue(v) { node.value = v; },
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|
|
}
|
|
|
|
for (const size of ["md", "sm"]) {
|
|
define("segmented", size, ({ options = [], value, onChange, label } = {}) => {
|
|
let current = value ?? options[0]?.value;
|
|
const node = el("div", {
|
|
cls: ["cx-segmented", `cx-segmented-${size}`],
|
|
attrs: { role: "radiogroup", "aria-label": label },
|
|
});
|
|
|
|
const buttons = options.map((option) => {
|
|
const b = el("button", {
|
|
cls: ["cx-segment", "cx-focusable"],
|
|
text: option.label,
|
|
attrs: { type: "button", role: "radio", "aria-checked": String(option.value === current) },
|
|
});
|
|
b.classList.toggle("cx-on", option.value === current);
|
|
b.addEventListener("click", () => { current = option.value; sync(); if (onChange) onChange(current); });
|
|
node.append(b);
|
|
return { option, b };
|
|
});
|
|
|
|
function sync() {
|
|
for (const { option, b } of buttons) {
|
|
const on = option.value === current;
|
|
b.classList.toggle("cx-on", on);
|
|
b.setAttribute("aria-checked", String(on));
|
|
}
|
|
}
|
|
|
|
const stopRoving = roving(node);
|
|
return {
|
|
node,
|
|
get value() { return current; },
|
|
setValue(v) { current = v; sync(); },
|
|
destroy() { stopRoving(); node.remove(); },
|
|
};
|
|
});
|
|
}
|
|
|
|
define("toggle", "default", ({ label, hint, checked, disabled, onChange } = {}) => {
|
|
const id = uid("toggle");
|
|
const input = el("input", { cls: "cx-toggle-input",
|
|
attrs: { type: "checkbox", id, disabled, role: "switch" } });
|
|
input.checked = Boolean(checked);
|
|
if (onChange) input.addEventListener("change", () => onChange(input.checked));
|
|
|
|
const node = el("div", { cls: "cx-toggle-row", children: [
|
|
input,
|
|
el("label", { cls: "cx-check-face cx-toggle-face", attrs: { for: id }, children: [
|
|
el("span", { cls: "cx-check-text", children: [
|
|
el("span", { cls: "cx-check-label", text: label }),
|
|
hint ? el("span", { cls: "cx-hint", text: hint }) : null,
|
|
].filter(Boolean) }),
|
|
el("span", { cls: "cx-toggle-track", attrs: { "aria-hidden": "true" },
|
|
children: el("span", { cls: "cx-toggle-knob" }) }),
|
|
] }),
|
|
] });
|
|
|
|
return {
|
|
node,
|
|
get value() { return input.checked; },
|
|
setValue(v) { input.checked = Boolean(v); },
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|