Files

185 lines
7.9 KiB
JavaScript

// Telling the user what is going on.
//
// v4 had roughly fifteen badge classes, ten empty states and three spinners.
// Here there are five tones and one shape of each, which is the whole point: a
// status looks the same everywhere, so its colour actually means something.
import { define, lookup } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
import { portal } from "../internals/portal.js";
import { claimFor } from "../internals/zlayer.js";
const TONES = ["neutral", "good", "warn", "danger", "info"];
for (const tone of TONES) {
define("chip", tone, ({ label, dot = false } = {}) =>
({ node: el("span", { cls: ["cx-chip", `cx-chip-${tone}`], children: [
dot ? el("span", { cls: "cx-chip-dot", attrs: { "aria-hidden": "true" } }) : null,
el("span", { text: label }),
].filter(Boolean) }),
destroy() { this.node.remove(); } }));
}
define("dot", "default", ({ tone = "neutral", label } = {}) =>
({ node: el("span", { cls: ["cx-dot", `cx-dot-${tone}`],
attrs: { role: label ? "img" : undefined, "aria-label": label, title: label } }),
destroy() { this.node.remove(); } }));
for (const tone of ["info", "warn", "danger"]) {
define("banner", tone, ({ text, action } = {}) => {
const words = el("span", { cls: "cx-banner-text", text });
const node = el("div", { cls: ["cx-banner", `cx-banner-${tone}`],
attrs: { role: tone === "danger" ? "alert" : "status" }, children: [
el("span", { cls: "cx-dot", attrs: { "aria-hidden": "true" } }),
words,
action && action.node ? action.node : null,
].filter(Boolean) });
// Settable, because the things worth a banner come and go: a warning that
// can only be built is a warning that has to be destroyed and rebuilt to
// change, and whatever was holding it then holds a dead handle.
return { node, setText: (next) => setText(words, next), destroy: () => node.remove() };
});
}
define("inlineError", "default", ({ text } = {}) =>
({ node: el("p", { cls: "cx-inline-error", text, attrs: { role: "alert" } }),
destroy() { this.node.remove(); } }));
define("progress", "bar", ({ value = 0, max = 100, label } = {}) => {
const fill = el("div", { cls: "cx-progress-fill" });
const node = el("div", { cls: "cx-progress",
attrs: { role: "progressbar", "aria-valuemin": "0", "aria-valuemax": String(max),
"aria-valuenow": String(value), "aria-label": label },
children: fill });
const set = (v) => {
const pct = max ? Math.min(100, Math.max(0, (v / max) * 100)) : 0;
fill.style.width = `${pct}%`;
node.setAttribute("aria-valuenow", String(v));
};
set(value);
return { node, setValue: set, destroy: () => node.remove() };
});
// A short bar with one line beside it: "Step 3/20 · 00m 12s". Sits in a toolbar or a panel head.
define("progress", "inline", ({ label } = {}) => {
const bar = lookup("progress", "bar")({ value: 0, max: 100, label });
const text = lookup("text", "sm")({ text: "" });
const node = el("span", { cls: "cx-progress-inline", children: [bar.node, text.node] });
return { node, setValue: bar.setValue, setText: text.setText, setHidden: (hidden) => { node.hidden = Boolean(hidden); }, destroy: () => node.remove() };
});
// For work whose total is genuinely unknown. A bar that invents a percentage is
// worse than one that admits it does not know.
define("progress", "indeterminate", ({ label } = {}) =>
({ node: el("div", { cls: ["cx-progress", "cx-progress-indeterminate"],
attrs: { role: "progressbar", "aria-label": label },
children: el("div", { cls: "cx-progress-fill" }) }),
destroy() { this.node.remove(); } }));
for (const size of ["md", "sm"]) {
define("spinner", size, ({ label = "Working" } = {}) =>
({ node: el("span", { cls: ["cx-spinner", `cx-spinner-${size}`],
attrs: { role: "status", "aria-label": label } }),
destroy() { this.node.remove(); } }));
}
define("emptyState", "default", ({ icon, title, hint, action } = {}) =>
({ node: el("div", { cls: "cx-empty", children: [
icon ? el("div", { cls: "cx-empty-icon", text: icon, attrs: { "aria-hidden": "true" } }) : null,
el("p", { cls: "cx-empty-title", text: title }),
hint ? el("p", { cls: "cx-hint", text: hint }) : null,
action && action.node ? action.node : null,
].filter(Boolean) }),
destroy() { this.node.remove(); } }));
define("dropzone", "default", ({ label, hint, accept, onFiles, multiple = true } = {}) => {
const input = el("input", { cls: "cx-sr",
attrs: { type: "file", accept, multiple: multiple || undefined } });
const node = el("label", { cls: "cx-dropzone", children: [
input,
el("span", { cls: "cx-dropzone-label", text: label }),
hint ? el("span", { cls: "cx-hint", text: hint }) : null,
].filter(Boolean) });
const emit = (files) => { if (onFiles && files && files.length) onFiles([...files]); };
input.addEventListener("change", () => emit(input.files));
// dragover must be prevented or the browser navigates to the dropped file,
// losing whatever was on screen.
for (const type of ["dragenter", "dragover"]) {
node.addEventListener(type, (e) => { e.preventDefault(); node.classList.add("cx-over"); });
}
for (const type of ["dragleave", "drop"]) {
node.addEventListener(type, (e) => { e.preventDefault(); node.classList.remove("cx-over"); });
}
node.addEventListener("drop", (e) => emit(e.dataTransfer && e.dataTransfer.files));
return { node, destroy: () => node.remove() };
});
for (const shape of ["line", "block", "grid"]) {
define("skeleton", shape, ({ count = shape === "grid" ? 6 : 3, label = "Loading" } = {}) => {
const node = el("div", { cls: ["cx-skeleton", `cx-skeleton-${shape}`],
attrs: { role: "status", "aria-label": label } });
for (let i = 0; i < count; i += 1) node.append(el("span", { cls: "cx-skeleton-cell" }));
return { node, destroy: () => node.remove() };
});
}
// One toast stack, owned by the kit. Several stacks fighting for the corner is
// how v4's tour toast ended up as the only one anybody could use.
let stack = null;
let stackLayer = null;
function toastStack() {
if (stack && stack.isConnected) return stack;
stack = el("div", { cls: "cx-toasts" });
stackLayer = claimFor("toast", stack);
stack.style.pointerEvents = "auto";
portal().appendChild(stack);
return stack;
}
for (const tone of ["info", "good", "warn", "danger"]) {
define("toast", tone, ({ text, action, duration = 3200 } = {}) => {
// Nothing to say: nothing shown. An empty bubble reads as a message that went missing.
if (!String(text ?? "").trim() && !(action && action.node)) return { node: null, setText() {}, destroy() {} };
const node = el("div", { cls: ["cx-toast", `cx-toast-${tone}`],
attrs: { role: tone === "danger" ? "alert" : "status" }, children: [
el("span", { cls: "cx-toast-text", text }),
action && action.node ? action.node : null,
].filter(Boolean) });
toastStack().appendChild(node);
let timer = duration ? setTimeout(() => handle.destroy(), duration) : null;
// Hovering pauses the countdown: a toast that vanishes while being read is
// a toast that failed to say anything.
node.addEventListener("pointerenter", () => { if (timer) { clearTimeout(timer); timer = null; } });
node.addEventListener("pointerleave", () => {
if (duration && !timer) timer = setTimeout(() => handle.destroy(), duration);
});
const handle = {
node,
setText: (value) => setText(node.querySelector(".cx-toast-text"), value),
destroy() {
if (timer) clearTimeout(timer);
node.remove();
if (stack && !stack.childElementCount) {
stackLayer.release();
stack.remove();
stack = null;
}
},
};
return handle;
});
}
export function _resetToasts() {
if (stack) { stack.remove(); stack = null; }
if (stackLayer) { stackLayer.release(); stackLayer = null; }
}