Files

246 lines
9.4 KiB
JavaScript

// Anchored overlays: popovers, menus, tooltips, autocomplete.
//
// v4 had seven copies of measure-flip-clamp-and-dismiss, one per feature. This
// is the one, and everything anchored is built on it.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { mount, unmount } from "../internals/portal.js";
import { claimFor } from "../internals/zlayer.js";
import { push } from "../internals/dismiss.js";
import { anchorTo } from "../internals/anchor.js";
function nodeOf(control, where) {
if (!control) return null;
if (control.node && control.node.nodeType === 1) return control.node;
throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
}
function floatOn(rung, cls, { anchor, side = "bottom", align = "start", gap = 6, onClose, closeOnOutside = true, content, role, label }) {
const anchorNode = anchor && anchor.node ? anchor.node : anchor;
const node = el("div", { cls, attrs: { role, "aria-label": label } });
const layer = claimFor(rung, node);
if (content) node.append(content);
// Mounted before measuring: a hidden element measures 0x0 and lands in the
// corner, which is the classic "my menu appears top-left" bug.
mount(node);
if (anchorNode) anchorTo(node, anchorNode, { side, align, gap });
const reposition = () => { if (anchorNode) anchorTo(node, anchorNode, { side, align, gap }); };
window.addEventListener("resize", reposition);
// Capture, because the scroller is usually an ancestor panel, not the window.
window.addEventListener("scroll", reposition, true);
// Content that arrives after opening changes the size: placed for the old one, it sat wrong until the next scroll.
const resized = typeof ResizeObserver === "function" ? new ResizeObserver(reposition) : null;
if (resized) resized.observe(node);
const dismissal = push({
nodes: [node, anchorNode].filter(Boolean),
closeOnOutside,
onDismiss: (reason) => handle.close(reason),
});
let closed = false;
const handle = {
node,
isOverlay: true,
reposition,
close(reason = "manual") {
if (closed) return;
closed = true;
dismissal.release();
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
if (resized) resized.disconnect();
layer.release();
unmount(node);
if (onClose) onClose(reason);
},
destroy() { handle.close("destroy"); },
};
return handle;
}
define("popover", "anchored", ({ body, ...rest } = {}) =>
floatOn("popover", "cx-popover", { ...rest, content: nodeOf(body, "popover.anchored"), role: "dialog" }));
function menuList(items, onPick, close) {
const list = el("div", { cls: "cx-menu", attrs: { role: "menu" } });
for (const item of items) {
if (item.heading) { list.append(el("div", { cls: "cx-menu-heading", text: item.heading })); continue; }
if (item.separator) { list.append(el("div", { cls: "cx-menu-sep", attrs: { role: "separator" } })); continue; }
list.append(el("button", {
cls: ["cx-menu-item", "cx-focusable", item.danger ? "cx-danger" : null],
attrs: { type: "button", role: "menuitem", disabled: item.disabled },
children: [
item.icon ? el("span", { cls: "cx-menu-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null,
el("span", { cls: "cx-menu-label", text: item.label }),
item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null,
].filter(Boolean),
on: { click: () => { close(); if (onPick) onPick(item.id); } },
}));
}
return list;
}
define("menu", "dropdown", ({ items = [], onPick, ...rest } = {}) => {
const handle = floatOn("popover", "cx-popover cx-popover-menu", {
...rest, role: "menu",
content: null,
});
handle.node.append(menuList(items, onPick, () => handle.close("picked")));
handle.reposition();
return handle;
});
/**
* Right-click menu: anchored to a POINT rather than an element.
*
* The anchor is a stand-in object with only getBoundingClientRect, because that
* is all anchorTo needs. It is not a node, so it never reaches the dismiss stack
* -- that filters to things that can answer "was the click inside me".
*/
define("menu", "context", ({ x = 0, y = 0, items = [], onPick, onClose } = {}) => {
const point = { getBoundingClientRect: () => ({ x, y, width: 0, height: 0, top: y, left: x, right: x, bottom: y }) };
const handle = floatOn("popover", "cx-popover cx-popover-menu", {
anchor: point, onClose, role: "menu",
});
handle.node.append(menuList(items, onPick, () => handle.close("picked")));
handle.reposition();
return handle;
});
/**
* A real tooltip. v4 used the native title attribute everywhere, which cannot be
* styled, cannot be triggered by keyboard focus, and waits about a second.
*/
define("tooltip", "default", ({ anchor, text, side = "top", trigger = true } = {}) => {
const anchorNode = anchor && anchor.node ? anchor.node : anchor;
let live = null;
const show = () => {
if (live) return;
live = floatOn("popover", "cx-tooltip", {
anchor: anchorNode, side, align: "center", gap: 6, closeOnOutside: false,
content: el("span", { text }), role: "tooltip",
});
};
const hide = () => { if (live) { live.close("hide"); live = null; } };
if (trigger && anchorNode) {
anchorNode.addEventListener("pointerenter", show);
anchorNode.addEventListener("pointerleave", hide);
anchorNode.addEventListener("focus", show);
anchorNode.addEventListener("blur", hide);
}
return {
node: anchorNode || el("span"),
isOverlay: true,
show, hide,
destroy() {
hide();
if (trigger && anchorNode) {
anchorNode.removeEventListener("pointerenter", show);
anchorNode.removeEventListener("pointerleave", hide);
anchorNode.removeEventListener("focus", show);
anchorNode.removeEventListener("blur", hide);
}
},
};
});
/**
* Completion under a field. This is the one overlay that must outrank modals --
* it opens from a field that is usually inside one.
*/
define("autocomplete", "default", ({ input, source, onPick, minChars = 1 } = {}) => {
const field = input && input.node ? input.node : input;
let live = null;
let index = -1;
let current = [];
const close = () => { if (live) { live.close("hide"); live = null; } index = -1; };
const draw = (items) => {
current = items;
if (!items.length) { close(); return; }
if (!live) {
live = floatOn("autocomplete", "cx-popover cx-autocomplete", {
anchor: field, side: "bottom", align: "start", gap: 2,
role: "listbox", onClose: () => { live = null; },
});
}
live.node.replaceChildren();
items.forEach((item, i) => {
live.node.append(el("button", {
cls: ["cx-menu-item", i === index ? "cx-on" : null],
attrs: { type: "button", role: "option", "aria-selected": String(i === index), title: item.hint ? `${item.label}: ${item.hint}` : undefined },
children: [
el("span", { cls: "cx-menu-label", text: item.label }),
item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null,
].filter(Boolean),
on: { click: () => { if (onPick) onPick(item); close(); } },
}));
});
live.reposition();
};
const onInput = () => {
const q = field.value || "";
if (q.length < minChars) { close(); return; }
index = -1;
draw(source ? source(q) : []);
};
const onKeyDown = (e) => {
if (!live) return;
if (e.key === "ArrowDown") { e.preventDefault(); index = Math.min(index + 1, current.length - 1); draw(current); }
else if (e.key === "ArrowUp") { e.preventDefault(); index = Math.max(index - 1, 0); draw(current); }
else if ((e.key === "Enter" && index >= 0) || (e.key === "Tab" && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey)) {
// Tab takes the highlighted suggestion, or the first one; it is a plain Tab only when nothing is offered.
e.preventDefault(); if (onPick) onPick(current[Math.max(index, 0)]); close();
}
};
if (field) {
field.addEventListener("input", onInput);
field.addEventListener("keydown", onKeyDown);
}
return {
node: field || el("span"),
isOverlay: true,
close,
destroy() {
close();
if (field) {
field.removeEventListener("input", onInput);
field.removeEventListener("keydown", onKeyDown);
}
},
};
});
/** A button whose second half opens a menu. */
define("splitButton", "md", ({ label, onClick, items = [], onPick, tone = "neutral" } = {}) => {
const main = el("button", { cls: ["cx-btn", "cx-btn-md", `cx-btn-${tone}`, "cx-focusable"],
text: label, attrs: { type: "button" }, on: { click: onClick } });
const arrow = el("button", { cls: ["cx-btn", "cx-btn-md", `cx-btn-${tone}`, "cx-split-arrow", "cx-focusable"],
text: "▾", attrs: { type: "button", "aria-label": `${label} options`, "aria-haspopup": "menu" } });
let menu = null;
arrow.addEventListener("click", () => {
if (menu) { menu.close("toggle"); menu = null; return; }
menu = floatOn("popover", "cx-popover cx-popover-menu", {
anchor: arrow, side: "bottom", align: "end", onClose: () => { menu = null; },
});
menu.node.append(menuList(items, onPick, () => { if (menu) menu.close("picked"); }));
menu.reposition();
});
const node = el("div", { cls: "cx-split-btn", children: [main, arrow] });
return { node, destroy() { if (menu) menu.close("destroy"); node.remove(); } };
});