Files

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(); },
};
});