143 lines
5.8 KiB
JavaScript
143 lines
5.8 KiB
JavaScript
// Buttons.
|
|
//
|
|
// Every one is focusable with a visible ring -- v4 styled a focus ring for
|
|
// inputs and gave buttons none, so keyboard users lost their place the moment
|
|
// they tabbed onto one.
|
|
|
|
import { define } from "../internals/register.js";
|
|
import { el } from "../internals/el.js";
|
|
import { setText } from "../internals/text.js";
|
|
import { roving } from "../internals/focus.js";
|
|
|
|
const TONES = new Set(["neutral", "primary", "danger", "ghost", "render"]);
|
|
|
|
function base(size, { label, onClick, tone = "neutral", icon, disabled = false, busy = false, type = "button", title, pressed } = {}) {
|
|
if (!TONES.has(tone)) throw new RangeError(`Unknown button tone "${tone}". Known: ${[...TONES].join(", ")}.`);
|
|
|
|
const node = el("button", {
|
|
cls: ["cx-btn", `cx-btn-${size}`, `cx-btn-${tone}`, "cx-focusable"],
|
|
attrs: { type, disabled: disabled || busy, title, "aria-busy": busy || undefined,
|
|
// A toggle says so, or a screen reader reads "Assets" the same
|
|
// whether the panel is open or shut. Absent unless the caller
|
|
// passes one: most buttons are not toggles and must not claim to be.
|
|
"aria-pressed": pressed === undefined ? undefined : String(Boolean(pressed)) },
|
|
});
|
|
if (pressed) node.classList.add("cx-on");
|
|
|
|
const spinner = el("span", { cls: "cx-btn-spin", attrs: { "aria-hidden": "true" } });
|
|
const glyph = icon ? el("span", { cls: "cx-btn-icon", text: icon, attrs: { "aria-hidden": "true" } }) : null;
|
|
const text = el("span", { cls: "cx-btn-label", text: label });
|
|
|
|
if (glyph) node.appendChild(glyph);
|
|
node.appendChild(text);
|
|
if (busy) node.appendChild(spinner);
|
|
|
|
if (onClick) node.addEventListener("click", onClick);
|
|
|
|
// Busy and disabled are two independent reasons a button is not clickable, so
|
|
// both are tracked. Deriving the attribute from the constructor's `disabled`
|
|
// meant finishing a busy spell re-enabled a button that had been disabled
|
|
// since -- the later call silently lost to the earlier value.
|
|
let isDisabled = Boolean(disabled);
|
|
let isBusy = Boolean(busy);
|
|
const sync = () => node.toggleAttribute("disabled", isDisabled || isBusy);
|
|
|
|
return {
|
|
node,
|
|
setLabel: (value) => setText(text, value),
|
|
setBusy(value) {
|
|
isBusy = Boolean(value);
|
|
sync();
|
|
if (isBusy) { node.setAttribute("aria-busy", "true"); node.appendChild(spinner); }
|
|
else { node.removeAttribute("aria-busy"); spinner.remove(); }
|
|
},
|
|
setDisabled(value) { isDisabled = Boolean(value); sync(); },
|
|
setPressed(value) {
|
|
node.setAttribute("aria-pressed", String(Boolean(value)));
|
|
node.classList.toggle("cx-on", Boolean(value));
|
|
},
|
|
get disabled() { return isDisabled; },
|
|
get busy() { return isBusy; },
|
|
destroy: () => node.remove(),
|
|
};
|
|
}
|
|
|
|
for (const size of ["xl", "lg", "md", "sm", "menu"]) define("button", size, (props) => base(size, props));
|
|
|
|
// Icon-only controls take `label` as their accessible name, not as decoration.
|
|
// Making it required is the cheapest way to stop a toolbar of unlabelled glyphs.
|
|
for (const size of ["md", "sm", "micro"]) {
|
|
define("iconButton", size, ({ icon, label, onClick, tone = "ghost", disabled, pressed } = {}) => {
|
|
if (!label) throw new TypeError("iconButton needs a label: it is the accessible name and the tooltip.");
|
|
const node = el("button", {
|
|
cls: ["cx-icon-btn", `cx-icon-btn-${size}`, `cx-btn-${tone}`, "cx-focusable"],
|
|
attrs: {
|
|
type: "button", disabled, title: label, "aria-label": label,
|
|
"aria-pressed": pressed === undefined ? undefined : String(Boolean(pressed)),
|
|
},
|
|
children: el("span", { cls: "cx-btn-icon", text: icon, attrs: { "aria-hidden": "true" } }),
|
|
});
|
|
if (onClick) node.addEventListener("click", onClick);
|
|
return {
|
|
node,
|
|
setPressed(value) { node.setAttribute("aria-pressed", String(Boolean(value))); },
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** A row of buttons where one (or several) stay active: B / I, align, flips. */
|
|
define("buttonGroup", "md", ({ items = [], value, onChange, multi = false, label } = {}) => {
|
|
const selected = new Set(multi ? [].concat(value || []) : value == null ? [] : [value]);
|
|
const node = el("div", {
|
|
cls: "cx-btn-group",
|
|
attrs: { role: multi ? "group" : "radiogroup", "aria-label": label },
|
|
});
|
|
|
|
const buttons = items.map((item) => {
|
|
const b = el("button", {
|
|
cls: ["cx-btn", "cx-btn-md", "cx-btn-neutral", "cx-focusable"],
|
|
text: item.label,
|
|
attrs: {
|
|
type: "button", title: item.title,
|
|
role: multi ? undefined : "radio",
|
|
"aria-checked": multi ? undefined : String(selected.has(item.value)),
|
|
"aria-pressed": multi ? String(selected.has(item.value)) : undefined,
|
|
},
|
|
});
|
|
b.classList.toggle("cx-on", selected.has(item.value));
|
|
b.addEventListener("click", () => {
|
|
if (multi) selected.has(item.value) ? selected.delete(item.value) : selected.add(item.value);
|
|
else { selected.clear(); selected.add(item.value); }
|
|
sync();
|
|
if (onChange) onChange(multi ? [...selected] : item.value);
|
|
});
|
|
node.appendChild(b);
|
|
return { item, b };
|
|
});
|
|
|
|
function sync() {
|
|
for (const { item, b } of buttons) {
|
|
const on = selected.has(item.value);
|
|
b.classList.toggle("cx-on", on);
|
|
if (multi) b.setAttribute("aria-pressed", String(on));
|
|
else b.setAttribute("aria-checked", String(on));
|
|
}
|
|
}
|
|
|
|
// One tab stop for the group; arrows move inside it. Otherwise Tab walks
|
|
// every option, which is tedious in a row of eight.
|
|
const stopRoving = roving(node);
|
|
|
|
return {
|
|
node,
|
|
get value() { return multi ? [...selected] : [...selected][0]; },
|
|
setValue(next) {
|
|
selected.clear();
|
|
for (const v of multi ? [].concat(next || []) : [next]) if (v != null) selected.add(v);
|
|
sync();
|
|
},
|
|
destroy() { stopRoving(); node.remove(); },
|
|
};
|
|
});
|