Spring launch draft
Brand voice checked. Waiting on editor approval.
Positioning, dismissal, focus-trap and roving-focus helpers for popovers, menus and dialogs.
OverlaysAtomNo dependencies Original, MIT
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- | Prop | Type | Default | Description |
|---|---|---|---|
computePosition(anchor, floating, opts) | function | · | Places a fixed element beside its anchor with flip and shift. |
autoUpdate(anchor, floating, update) | function | · | Re-runs `update` on scroll and resize. Returns cleanup. |
onDismiss(el, dismiss, opts) | function | · | Escape, outside-pointer and focus-out dismissal. Returns cleanup. |
trapFocus(container, opts) | function | · | Contains Tab focus. Release restores prior focus. |
rovingFocus(container, opts) | function | · | Arrow/Home/End navigation with typeahead. |
uid(prefix) | function | · | Unique id for ARIA wiring. |
/**
* overlay: shared behaviour for floating and modal components (popover,
* tooltip, select, combobox, menus, dialogs). Framework-free:
* - computePosition / autoUpdate anchor positioning with flip + shift
* - onDismiss Escape + outside-pointer dismissal
* - trapFocus modal focus containment + restore
* - rovingFocus arrow-key navigation + typeahead
* - uid stable ids for aria wiring
*
* Put floating elements in the top layer (Popover API or modal <dialog>) so
* ancestors cannot clip them. Positions are viewport coordinates (fixed).
*/
export type Side = "top" | "right" | "bottom" | "left";
export type Align = "start" | "center" | "end";
export type Placement = Side | `${Side}-${Align}`;
export interface PositionOptions {
placement?: Placement;
/** Gap between anchor and floating element, px. */
offset?: number;
/** Minimum distance from the viewport edge, px. */
padding?: number;
/** Flip to the opposite side when the preferred side does not fit. */
flip?: boolean;
/** Slide along the cross axis to stay inside the viewport. */
shift?: boolean;
/** Match the anchor's width (select/combobox listboxes). */
matchWidth?: boolean;
}
export interface PositionResult {
x: number;
y: number;
side: Side;
align: Align;
}
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
function split(p: Placement): [Side, Align] {
const [side, align = "center"] = p.split("-") as [Side, Align?];
return [side, align as Align];
}
function coords(a: DOMRect, f: { width: number; height: number }, side: Side, align: Align, offset: number) {
const vertical = side === "top" || side === "bottom";
let x = 0;
let y = 0;
if (vertical) {
y = side === "bottom" ? a.bottom + offset : a.top - f.height - offset;
x = align === "start" ? a.left : align === "end" ? a.right - f.width : a.left + (a.width - f.width) / 2;
} else {
x = side === "right" ? a.right + offset : a.left - f.width - offset;
y = align === "start" ? a.top : align === "end" ? a.bottom - f.height : a.top + (a.height - f.height) / 2;
}
return { x, y };
}
function fits(side: Side, a: DOMRect, f: { width: number; height: number }, offset: number, pad: number) {
const vw = window.innerWidth;
const vh = window.innerHeight;
switch (side) {
case "bottom": return a.bottom + offset + f.height <= vh - pad;
case "top": return a.top - offset - f.height >= pad;
case "right": return a.right + offset + f.width <= vw - pad;
case "left": return a.left - offset - f.width >= pad;
}
}
/**
* Positions `floating` next to `anchor` and writes `left/top` (fixed) plus
* `data-side` / `data-align` (for direction-aware animations and arrows).
*/
export function computePosition(anchor: Element, floating: HTMLElement, opts: PositionOptions = {}): PositionResult {
const { placement = "bottom", offset = 6, padding = 8, flip = true, shift = true, matchWidth = false } = opts;
const a = anchor.getBoundingClientRect();
floating.style.position = "fixed";
floating.style.margin = "0";
if (matchWidth) floating.style.minWidth = `${a.width}px`;
const f = { width: floating.offsetWidth, height: floating.offsetHeight };
let [side, align] = split(placement);
if (flip && !fits(side, a, f, offset, padding) && fits(OPPOSITE[side], a, f, offset, padding)) {
side = OPPOSITE[side];
}
let { x, y } = coords(a, f, side, align, offset);
if (shift) {
const maxX = window.innerWidth - f.width - padding;
const maxY = window.innerHeight - f.height - padding;
x = Math.min(Math.max(x, padding), Math.max(padding, maxX));
y = Math.min(Math.max(y, padding), Math.max(padding, maxY));
}
floating.style.left = `${Math.round(x)}px`;
floating.style.top = `${Math.round(y)}px`;
floating.dataset.side = side;
floating.dataset.align = align;
// Transform origin points back at the anchor, for scale-in animations.
const vertical = side === "top" || side === "bottom";
const cross = align === "center" ? "center" : vertical ? (align === "start" ? "left" : "right") : align === "start" ? "top" : "bottom";
floating.style.transformOrigin = `${OPPOSITE[side]} ${cross}`;
return { x, y, side, align };
}
/**
* Keeps a floating element positioned while open: on scroll (any ancestor),
* resize, and size changes of either element. Returns a cleanup function.
*/
export function autoUpdate(anchor: Element, floating: HTMLElement, update: () => void): () => void {
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
window.addEventListener("scroll", schedule, { capture: true, passive: true });
window.addEventListener("resize", schedule, { passive: true });
const ro = new ResizeObserver(schedule);
ro.observe(anchor);
ro.observe(floating);
update();
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", schedule, { capture: true });
window.removeEventListener("resize", schedule);
ro.disconnect();
};
}
export interface DismissOptions {
/** Elements that count as "inside" (e.g. the trigger). */
inside?: Array<Element | null | undefined>;
escape?: boolean;
outsidePointer?: boolean;
/** Close when focus moves outside (non-modal popovers). */
focusOut?: boolean;
}
/**
* Calls `dismiss(reason)` on Escape or a pointerdown outside `el` and
* `inside`. Returns a cleanup function.
*/
export function onDismiss(
el: HTMLElement,
dismiss: (reason: "escape" | "outside" | "focusout") => void,
{ inside = [], escape = true, outsidePointer = true, focusOut = false }: DismissOptions = {},
): () => void {
const isInside = (t: EventTarget | null) =>
t instanceof Node && (el.contains(t) || inside.some((i) => i && i.contains(t)));
const onKey = (e: KeyboardEvent) => {
if (escape && e.key === "Escape" && !e.defaultPrevented) {
e.preventDefault();
dismiss("escape");
}
};
const onPointer = (e: PointerEvent) => {
if (outsidePointer && !isInside(e.target)) dismiss("outside");
};
const onFocus = (e: FocusEvent) => {
if (focusOut && !isInside(e.target)) dismiss("focusout");
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointer, true);
document.addEventListener("focusin", onFocus);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointer, true);
document.removeEventListener("focusin", onFocus);
};
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
function focusables(container: ParentNode): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => !el.closest("[inert]") && el.getClientRects().length > 0,
);
}
/**
* Contains Tab focus within `container` (for modal surfaces not using a
* modal <dialog>, which traps natively). Returns a release function that
* restores focus to whatever was focused before.
*/
export function trapFocus(container: HTMLElement, { initial }: { initial?: HTMLElement | null } = {}): () => void {
const previous = document.activeElement as HTMLElement | null;
(initial ?? focusables(container)[0] ?? container).focus({ preventScroll: true });
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const items = focusables(container);
if (!items.length) {
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
container.addEventListener("keydown", onKey);
return () => {
container.removeEventListener("keydown", onKey);
previous?.focus?.({ preventScroll: true });
};
}
export interface RovingOptions {
/** Selector for navigable items within the container. */
items: string;
orientation?: "vertical" | "horizontal" | "both";
loop?: boolean;
/** Type characters to jump to the item whose text starts with them. */
typeahead?: boolean;
/**
* "focus": move real focus with a roving tabindex (menus, toolbars, tabs).
* "active": keep focus on the container/input and move aria-activedescendant
* (listboxes driven from a combobox input).
*/
mode?: "focus" | "active";
/** Element that receives aria-activedescendant in "active" mode. */
controller?: HTMLElement;
onMove?: (item: HTMLElement) => void;
}
/**
* Arrow-key / Home / End navigation with optional typeahead.
* Disabled items (`aria-disabled="true"` or `disabled`) are skipped.
* Returns a handle to move programmatically, plus cleanup.
*/
export function rovingFocus(container: HTMLElement, opts: RovingOptions) {
const { items: selector, orientation = "vertical", loop = true, typeahead = true, mode = "focus", controller, onMove } = opts;
const keyTarget = controller ?? container;
let buffer = "";
let bufferTimer = 0;
const list = () =>
[...container.querySelectorAll<HTMLElement>(selector)].filter(
(el) => el.getAttribute("aria-disabled") !== "true" && !(el as HTMLButtonElement).disabled && !el.hidden,
);
const current = () => {
const items = list();
if (mode === "active") {
const id = (controller ?? container).getAttribute("aria-activedescendant");
return items.findIndex((el) => el.id === id);
}
return items.findIndex((el) => el === document.activeElement || el.contains(document.activeElement));
};
function move(target: HTMLElement | null) {
if (!target) return;
if (mode === "focus") {
list().forEach((el) => (el.tabIndex = el === target ? 0 : -1));
target.focus({ preventScroll: false });
} else {
list().forEach((el) => el.removeAttribute("data-active"));
target.dataset.active = "";
if (!target.id) target.id = uid("vs-opt");
(controller ?? container).setAttribute("aria-activedescendant", target.id);
target.scrollIntoView({ block: "nearest" });
}
onMove?.(target);
}
function step(delta: number) {
const items = list();
if (!items.length) return;
const i = current();
let next = i === -1 ? (delta > 0 ? 0 : items.length - 1) : i + delta;
if (loop) next = (next + items.length) % items.length;
else next = Math.max(0, Math.min(items.length - 1, next));
move(items[next]);
}
const prevKeys = orientation === "horizontal" ? ["ArrowLeft"] : orientation === "vertical" ? ["ArrowUp"] : ["ArrowUp", "ArrowLeft"];
const nextKeys = orientation === "horizontal" ? ["ArrowRight"] : orientation === "vertical" ? ["ArrowDown"] : ["ArrowDown", "ArrowRight"];
const onKey = (e: KeyboardEvent) => {
if (nextKeys.includes(e.key)) {
e.preventDefault();
step(1);
} else if (prevKeys.includes(e.key)) {
e.preventDefault();
step(-1);
} else if (e.key === "Home") {
e.preventDefault();
move(list()[0] ?? null);
} else if (e.key === "End") {
e.preventDefault();
const items = list();
move(items[items.length - 1] ?? null);
} else if (typeahead && mode === "focus" && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && e.key !== " ") {
buffer += e.key.toLowerCase();
window.clearTimeout(bufferTimer);
bufferTimer = window.setTimeout(() => (buffer = ""), 500);
const items = list();
const start = Math.max(0, current() + (buffer.length === 1 ? 1 : 0));
const ordered = [...items.slice(start), ...items.slice(0, start)];
const hit = ordered.find((el) => (el.textContent ?? "").trim().toLowerCase().startsWith(buffer));
if (hit) move(hit);
}
};
keyTarget.addEventListener("keydown", onKey);
if (mode === "focus") {
const items = list();
items.forEach((el, i) => (el.tabIndex = i === 0 ? 0 : -1));
}
return {
move,
first: () => move(list()[0] ?? null),
last: () => {
const items = list();
move(items[items.length - 1] ?? null);
},
step,
destroy: () => {
keyTarget.removeEventListener("keydown", onKey);
window.clearTimeout(bufferTimer);
},
};
}
let counter = 0;
/** Unique, stable-within-page id for aria wiring. */
export function uid(prefix = "at"): string {
counter += 1;
return `${prefix}-${counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
} Original, MIT.