Packages

Headless-first, token-driven UI components for Phoenix LiveView — rich selects, date pickers, dialogs, menus, tables, sliders and toasts that live in the browser top layer. Themeable through CSS tokens, viewport-aware, server-authoritative, with zero third-party JS.

Current section

Files

Jump to
skua assets js skua panel_stack.js
Raw

assets/js/skua/panel_stack.js

/* =============================================================================
PanelStack — the shared top-layer panel engine.
One instance drives every Skua popover, dropdown, select and date picker.
It owns: native Popover API show/hide, viewport-aware flip/shift/clamp
positioning, nesting (a panel opened inside another keeps its parent open),
close-on-animation-end (no flash), and FOCUS MANAGEMENT (save focus on open,
move focus into the panel, restore it to the trigger on close).
This is plain ES — imported normally by each hook, which is exactly why Skua
ships a bundled module rather than colocated hooks (colocated files cannot
import shared code).
========================================================================== */
const MARGIN = 8;
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),' +
'textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
function focusables(panel) {
return [...panel.querySelectorAll(FOCUSABLE)].filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
}
function placeVertical(r, panel, pw, ph, vw, vh) {
let left = r.left;
let top = r.bottom + 6;
let origin = "top";
if (top + ph > vh - MARGIN && r.top - 6 - ph > MARGIN) {
top = r.top - 6 - ph;
origin = "bottom";
}
left = Math.max(MARGIN, Math.min(left, vw - MARGIN - pw));
if (top + ph > vh - MARGIN) top = Math.max(MARGIN, vh - MARGIN - ph);
panel.style.left = left + "px";
panel.style.top = Math.max(MARGIN, top) + "px";
panel.style.setProperty("--sk-origin", origin + " left");
}
function position(trigger, panel) {
const r = trigger.getBoundingClientRect();
if (panel.dataset.matchWidth !== "no") panel.style.minWidth = r.width + "px";
const pw = panel.offsetWidth;
const ph = panel.offsetHeight;
const vw = window.innerWidth;
const vh = window.innerHeight;
// A panel whose trigger lives inside an already-open panel is positioned
// BESIDE that parent (submenu behavior) so it never overlaps it. Same for an
// explicit placement="right". Falls back to above/below when there's no
// horizontal room either side.
let parent = null;
try {
parent = trigger.closest(".sk-panel:popover-open");
} catch (_) {}
const sideMode = panel.dataset.placement === "right" || (parent && parent !== panel);
if (sideMode) {
const anchor = parent ? parent.getBoundingClientRect() : r;
let left = anchor.right + 6;
let origin = "left";
if (left + pw > vw - MARGIN) {
const leftAlt = anchor.left - 6 - pw;
if (leftAlt > MARGIN) {
left = leftAlt;
origin = "right";
} else {
// no room either side → drop to vertical placement off the trigger
placeVertical(r, panel, pw, ph, vw, vh);
return;
}
}
let top = r.top - 5;
top = Math.max(MARGIN, Math.min(top, vh - MARGIN - ph));
panel.style.left = Math.max(MARGIN, left) + "px";
panel.style.top = top + "px";
panel.style.setProperty("--sk-origin", "top " + origin);
return;
}
placeVertical(r, panel, pw, ph, vw, vh);
}
const stack = []; // [{ trigger, panel, onClose, returnFocus }]
const inStack = (panel) => stack.some((e) => e.panel === panel);
function closeEntry(e) {
const panel = e.panel;
panel.dataset.state = "closed";
if (e.trigger) {
e.trigger.dataset.state = "closed";
e.trigger.setAttribute("aria-expanded", "false");
}
let done = false;
const finish = () => {
if (done) return;
done = true;
panel.removeEventListener("animationend", finish);
if (panel.dataset.state === "open") return; // reopened during exit
if (panel.hasAttribute("popover") && panel.matches(":popover-open")) {
try {
panel.hidePopover();
} catch (_) {}
} else {
panel.style.display = "none";
}
if (e.returnFocus && e.returnFocus.isConnected) e.returnFocus.focus();
e.onClose && e.onClose();
};
panel.addEventListener("animationend", finish, { once: true });
const ms =
parseInt(
getComputedStyle(document.documentElement).getPropertyValue("--skua-duration-exit")
) || 120;
setTimeout(finish, ms + 80);
}
function closeToAncestor(node) {
while (stack.length) {
const top = stack[stack.length - 1];
if (node && top.panel.contains(node)) break;
stack.pop();
closeEntry(top);
}
}
const PanelStack = {
position,
isOpen: (panel) => inStack(panel),
current: () => (stack.length ? stack[stack.length - 1].panel : null),
show(trigger, panel, opts = {}) {
closeToAncestor(trigger);
if (inStack(panel)) return;
panel.dataset.state = "open";
if (trigger) {
trigger.dataset.state = "open";
trigger.setAttribute("aria-expanded", "true");
}
if (panel.hasAttribute("popover") && panel.showPopover) {
try {
panel.showPopover();
} catch (_) {}
} else {
panel.style.display = "block";
}
position(trigger, panel);
// Re-measure next frame: a top-layer panel can report stale geometry on the
// first synchronous measure — especially one opened from inside another
// panel that is itself still settling. This fixes the "corner" race.
requestAnimationFrame(() => {
if (inStack(panel)) position(trigger, panel);
});
// Focus management: remember where focus was, then move it into the panel.
const returnFocus = opts.returnFocus || document.activeElement;
stack.push({ trigger, panel, onClose: opts.onClose, returnFocus });
if (opts.focusPanel !== false) {
const first = focusables(panel)[0];
if (first) requestAnimationFrame(() => first.focus());
}
},
close() {
closeToAncestor(null);
},
closePanel(panel) {
if (!inStack(panel)) return;
while (stack.length && stack[stack.length - 1].panel !== panel) {
closeEntry(stack.pop());
}
const self = stack.pop();
if (self) closeEntry(self);
},
toggle(trigger, panel, opts) {
if (inStack(panel)) this.closePanel(panel);
else this.show(trigger, panel, opts);
},
reposition() {
stack.forEach((e) => position(e.trigger, e.panel));
},
};
// Global listeners, attached once per bundle.
let wired = false;
function wire() {
if (wired || typeof document === "undefined") return;
wired = true;
document.addEventListener("click", () => PanelStack.close());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && stack.length) {
e.stopPropagation();
PanelStack.closePanel(stack[stack.length - 1].panel);
}
});
const reflow = () => PanelStack.reposition();
window.addEventListener("resize", reflow);
window.addEventListener("scroll", reflow, true);
// Re-position open panels after every LiveView DOM patch.
window.addEventListener("phx:update", reflow);
}
wire();
export default PanelStack;
export { position, focusables };