Mercurial
diff design_system/src/components/overlays.js @ 254:2b6e732087ff
[ui] Add complete native component catalog
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 04 Aug 2026 15:12:09 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/overlays.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,865 @@ +let nextOverlayId = 1; + +function uniqueId(prefix) { + let id; + do id = `${prefix}-${nextOverlayId++}`; + while (document.getElementById(id)); + return id; +} + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function restoreAttributes(state) { + for (const [element, attributes] of state) { + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + } + state.clear(); +} + +function resetManagedAttribute(state, element, name) { + const attributes = state.get(element); + if (!attributes?.has(name)) return; + const value = attributes.get(name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function ensureId(state, element, prefix) { + const existing = element.id && document.getElementById(element.id); + if (!element.id || (existing && existing !== element)) { + setManagedAttribute(state, element, "id", uniqueId(prefix)); + } + return element.id; +} + +function eventElement(event) { + return event.target instanceof Element ? event.target : null; +} + +const OVERLAY_COMPONENTS = [ + "zen-dialog", + "zen-alert-dialog", + "zen-sheet", + "zen-drawer", + "zen-popover", + "zen-hover-card", + "zen-tooltip", +].join(","); + +function ownedElement(host, selector) { + return [...host.querySelectorAll(selector)].find( + element => element.closest(OVERLAY_COMPONENTS) === host, + ) || null; +} + +/** + * Wires a native modal dialog to explicit trigger and close controls. + * + * @extends {HTMLElement} + */ +export class ZenDialog extends HTMLElement { + static dialogRole = null; + + constructor() { + super(); + this._managed = new Map(); + this._open = false; + this._openedByComponent = false; + this._onTriggerClick = event => { + event.preventDefault(); + this.open(); + }; + this._onDialogClick = event => { + const control = eventElement(event)?.closest("[data-zen-close]"); + if (!control || !this._dialog?.contains(control)) return; + const value = control.hasAttribute("value") + ? control.getAttribute("value") + : undefined; + this.close(value); + }; + this._onNativeClose = () => this.syncOpen(false); + } + + connectedCallback() { + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "button[data-zen-trigger]"); + const dialog = ownedElement(this, "dialog"); + if (trigger === this._trigger && dialog === this._dialog) return; + if (this._openedByComponent) this.close(); + this.unbind(); + this._trigger = trigger; + this._dialog = dialog; + if (!this._trigger || !this._dialog) return; + + const id = ensureId(this._managed, this._dialog, this.localName); + setManagedAttribute( + this._managed, + this._trigger, + "aria-haspopup", + "dialog", + ); + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + if (this.constructor.dialogRole) { + setManagedAttribute( + this._managed, + this._dialog, + "role", + this.constructor.dialogRole, + ); + } + this._trigger.addEventListener("click", this._onTriggerClick); + this._dialog.addEventListener("click", this._onDialogClick); + this._dialog.addEventListener("close", this._onNativeClose); + this.syncOpen(this._dialog.open); + } + + unbind() { + this._trigger?.removeEventListener("click", this._onTriggerClick); + this._dialog?.removeEventListener("click", this._onDialogClick); + this._dialog?.removeEventListener("close", this._onNativeClose); + restoreAttributes(this._managed); + this._trigger = null; + this._dialog = null; + this._open = false; + this._openedByComponent = false; + } + + disconnectedCallback() { + this._observer?.disconnect(); + if (this._openedByComponent) this.close(); + this.unbind(); + } + + open() { + if (!this._dialog || this._dialog.open || this._open) return; + try { + if (typeof this._dialog.showModal === "function") { + this._dialog.showModal(); + } else { + setManagedAttribute(this._managed, this._dialog, "open", ""); + } + this._openedByComponent = true; + this.syncOpen(true); + } catch { + this.syncOpen(Boolean(this._dialog.open)); + } + } + + close(returnValue) { + if (!this._dialog || (!this._dialog.open && !this._open)) return; + if (typeof this._dialog.close === "function") { + if (returnValue === undefined) this._dialog.close(); + else this._dialog.close(returnValue); + } else { + setManagedAttribute(this._managed, this._dialog, "open", null); + } + this.syncOpen(false); + } + + syncOpen(open) { + this._open = open; + if (!open) this._openedByComponent = false; + if (this._trigger) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + } +} + +/** + * Presents a native modal with alert-dialog semantics. + * + * @extends {ZenDialog} + */ +export class ZenAlertDialog extends ZenDialog { + static dialogRole = "alertdialog"; +} + +/** + * Presents a native dialog as an edge-aligned sheet. + * + * @extends {ZenDialog} + */ +export class ZenSheet extends ZenDialog {} + +/** + * Presents a native dialog as a drawer. + * + * @extends {ZenDialog} + */ +export class ZenDrawer extends ZenDialog {} + +function isPopoverOpen(element) { + try { + return element.matches(":popover-open"); + } catch { + return false; + } +} + +/** + * Connects a trigger to a native popover with an accessible fallback. + * + * @extends {HTMLElement} + */ +export class ZenPopover extends HTMLElement { + constructor() { + super(); + this._managed = new Map(); + this._open = false; + this._onClick = event => { + const target = eventElement(event); + if (!target) return; + if (this._trigger?.contains(target)) { + event.preventDefault(); + this.toggle(); + } else if (this._content?.contains(target) && + target.closest("[data-zen-close]")) { + this.hide(); + } + }; + this._onToggle = event => { + this.syncOpen(event.newState + ? event.newState === "open" + : isPopoverOpen(this._content)); + }; + this._onDocumentPointerDown = event => { + if (!this._native && this._open && !this.contains(event.target)) { + this.hide(); + } + }; + this._onDocumentKeyDown = event => { + if (!this._native && this._open && event.key === "Escape") { + this.hide(); + this._trigger?.focus(); + } + }; + } + + connectedCallback() { + this.addEventListener("click", this._onClick); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + document.addEventListener("keydown", this._onDocumentKeyDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "[data-zen-trigger]"); + const content = ownedElement( + this, + "[data-zen-content], [popover]", + ); + if (trigger === this._trigger && content === this._content) return; + if (this._open) this.hide(); + this.unbindElements(); + this._trigger = trigger; + this._content = content; + if (!this._trigger || !this._content) return; + + const id = ensureId(this._managed, this._content, "zen-popover"); + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + this._native = typeof this._content.showPopover === "function" && + typeof this._content.hidePopover === "function"; + if (this._native && !this._content.hasAttribute("popover")) { + setManagedAttribute(this._managed, this._content, "popover", "auto"); + } + if (this._native && "popoverTargetElement" in this._trigger) { + this._originalPopoverTarget = this._trigger.popoverTargetElement; + this._trigger.popoverTargetElement = this._content; + } + if (this._native) { + setManagedAttribute(this._managed, this._content, "hidden", null); + } else { + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + + this._content.addEventListener("toggle", this._onToggle); + this.syncOpen(this._native && isPopoverOpen(this._content)); + } + + unbindElements() { + this._content?.removeEventListener("toggle", this._onToggle); + if (this._trigger && "popoverTargetElement" in this._trigger) { + this._trigger.popoverTargetElement = this._originalPopoverTarget || null; + } + restoreAttributes(this._managed); + this._trigger = null; + this._content = null; + this._native = false; + this._open = false; + this._originalPopoverTarget = null; + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + document.removeEventListener("keydown", this._onDocumentKeyDown); + if (this._open) this.hide(); + this.unbindElements(); + } + + toggle() { + if (this._open || isPopoverOpen(this._content)) this.hide(); + else this.show(); + } + + show() { + if (!this._content || this._open) return; + if (this._native) { + try { + this._content.showPopover(); + } catch { + this._native = false; + setManagedAttribute(this._managed, this._content, "hidden", null); + } + } else { + setManagedAttribute(this._managed, this._content, "hidden", null); + } + this.syncOpen(true); + } + + hide() { + if (!this._content || !this._open) return; + if (this._native) { + try { + this._content.hidePopover(); + } catch { + this._native = false; + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + } else { + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + this.syncOpen(false); + } + + syncOpen(open) { + this._open = open; + if (this._trigger) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + if (!this._native && this._content) { + setManagedAttribute( + this._managed, + this._content, + "hidden", + open ? null : "", + ); + } + } +} + +class ZenTimedOverlay extends HTMLElement { + static delay = 150; + static tooltip = false; + + constructor() { + super(); + this._managed = new Map(); + this._timer = null; + this._open = false; + this._pointerWithin = false; + this._focusWithin = false; + this._onPointerEnter = () => { + this._pointerWithin = true; + this.schedule(true); + }; + this._onPointerLeave = event => { + if (event.relatedTarget instanceof Node && + this.contains(event.relatedTarget)) return; + this._pointerWithin = false; + this.schedule(this._focusWithin); + }; + this._onFocusIn = () => { + this._focusWithin = true; + this.schedule(true); + }; + this._onFocusOut = event => { + if (event.relatedTarget instanceof Node && + this.contains(event.relatedTarget)) return; + this._focusWithin = false; + this.schedule(this._pointerWithin); + }; + this._onKeyDown = event => { + if (event.key !== "Escape" || !this._open) return; + clearTimeout(this._timer); + this._timer = null; + this.setOpen(false); + }; + } + + connectedCallback() { + this.addEventListener("keydown", this._onKeyDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "[data-zen-trigger]"); + const content = ownedElement(this, "[data-zen-content]"); + if (trigger === this._trigger && content === this._content) return; + this.unbindElements(); + this._trigger = trigger; + this._content = content; + if (!this._trigger || !this._content) return; + + const id = ensureId(this._managed, this._content, this.localName); + setManagedAttribute(this._managed, this._content, "hidden", ""); + if (this.constructor.tooltip) { + setManagedAttribute(this._managed, this._content, "role", "tooltip"); + const ids = new Set( + (this._trigger.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(Boolean), + ); + ids.add(id); + setManagedAttribute( + this._managed, + this._trigger, + "aria-describedby", + [...ids].join(" "), + ); + } else { + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + "false", + ); + } + + for (const element of [this._trigger, this._content]) { + element.addEventListener("pointerenter", this._onPointerEnter); + element.addEventListener("pointerleave", this._onPointerLeave); + element.addEventListener("focusin", this._onFocusIn); + element.addEventListener("focusout", this._onFocusOut); + } + } + + unbindElements() { + clearTimeout(this._timer); + this._timer = null; + for (const element of [this._trigger, this._content]) { + element?.removeEventListener("pointerenter", this._onPointerEnter); + element?.removeEventListener("pointerleave", this._onPointerLeave); + element?.removeEventListener("focusin", this._onFocusIn); + element?.removeEventListener("focusout", this._onFocusOut); + } + restoreAttributes(this._managed); + this._trigger = null; + this._content = null; + this._open = false; + this._pointerWithin = false; + this._focusWithin = false; + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("keydown", this._onKeyDown); + this.unbindElements(); + } + + schedule(open) { + clearTimeout(this._timer); + this._timer = setTimeout( + () => this.setOpen(open), + this.constructor.delay, + ); + } + + setOpen(open) { + if (!this._content || open === this._open) return; + this._open = open; + setManagedAttribute( + this._managed, + this._content, + "hidden", + open ? null : "", + ); + if (!this.constructor.tooltip) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + } +} + +/** + * Shows supplemental interactive content on hover or focus. + * + * @extends {ZenTimedOverlay} + */ +export class ZenHoverCard extends ZenTimedOverlay { + static delay = 150; +} + +/** + * Shows a delayed non-interactive description on hover or focus. + * + * @extends {ZenTimedOverlay} + */ +export class ZenTooltip extends ZenTimedOverlay { + static delay = 350; + static tooltip = true; +} + +const MENU_COMPONENTS = [ + "zen-dropdown-menu", + "zen-context-menu", + "zen-menubar", + "zen-navigation-menu", +].join(","); +const MENU_SELECTOR = "[data-zen-menu], [role='menu'], [data-zen-content]"; +const ITEM_SELECTOR = [ + "[role='menuitem']", + "[role='menuitemcheckbox']", + "[role='menuitemradio']", + "[data-zen-menu-item]", + "a[href]", + "button", + "input[type='button']", + "input[type='submit']", +].join(","); + +class ZenMenu extends HTMLElement { + static contextMenu = false; + static menubar = false; + static multiple = false; + + constructor() { + super(); + this._managed = new Map(); + this._pairs = []; + this._openPair = null; + this._onClick = event => { + const target = eventElement(event); + if (!target) return; + const pair = this._pairs.find(({ trigger }) => + trigger.contains(target)); + if (pair) { + event.preventDefault(); + if (this._openPair === pair) this.close(false); + else this.open(pair, 0, this.constructor.contextMenu ? event : null); + return; + } + const item = target.closest(ITEM_SELECTOR); + if (this._openPair?.menu.contains(target) && + item && + !item.hasAttribute("disabled") && + item.getAttribute("aria-disabled") !== "true") { + this.close(true); + } + }; + this._onContextMenu = event => { + if (!this.constructor.contextMenu || + this._pairs.some(({ menu }) => menu.contains(event.target))) return; + event.preventDefault(); + this.open(this._pairs[0], 0, event); + }; + this._onKeyDown = event => this.handleKeyDown(event); + this._onDocumentPointerDown = event => { + if (!this._openPair) return; + const { trigger, menu } = this._openPair; + if (!trigger.contains(event.target) && !menu.contains(event.target)) { + this.close(false); + } + }; + } + + connectedCallback() { + this.setupPairs(); + this.addEventListener("click", this._onClick); + this.addEventListener("contextmenu", this._onContextMenu); + this.addEventListener("keydown", this._onKeyDown); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + this._observer ||= new MutationObserver(() => this.refreshPairs()); + this._observer.observe(this, { childList: true, subtree: true }); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("contextmenu", this._onContextMenu); + this.removeEventListener("keydown", this._onKeyDown); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + this._openPair = null; + restoreAttributes(this._managed); + this._pairs = []; + } + + refreshPairs() { + if (this._openPair) this.close(false); + restoreAttributes(this._managed); + this._pairs = []; + this.setupPairs(); + } + + ownedElements(selector) { + return [...this.querySelectorAll(selector)].filter(element => + element.closest(MENU_COMPONENTS) === this); + } + + setupPairs() { + let triggers = this.ownedElements("[data-zen-trigger]"); + const menus = this.ownedElements(MENU_SELECTOR); + triggers = triggers.filter(trigger => + !menus.some(menu => menu.contains(trigger))); + if (!this.constructor.multiple) triggers = triggers.slice(0, 1); + + const unused = new Set(menus); + for (const trigger of triggers) { + const controlled = trigger.getAttribute("aria-controls"); + let menu = controlled + ? menus.find(candidate => candidate.id === controlled) + : null; + if (!menu) menu = [...unused][0]; + if (!menu) continue; + unused.delete(menu); + + const triggerId = ensureId(this._managed, trigger, `${this.localName}-trigger`); + const menuId = ensureId(this._managed, menu, `${this.localName}-menu`); + setManagedAttribute(this._managed, trigger, "aria-haspopup", "menu"); + setManagedAttribute(this._managed, trigger, "aria-controls", menuId); + setManagedAttribute(this._managed, trigger, "aria-expanded", "false"); + setManagedAttribute(this._managed, menu, "role", "menu"); + setManagedAttribute(this._managed, menu, "aria-labelledby", triggerId); + setManagedAttribute(this._managed, menu, "hidden", ""); + if (this.constructor.menubar) { + setManagedAttribute(this._managed, trigger, "role", "menuitem"); + } + const pair = { trigger, menu }; + this._pairs.push(pair); + this.prepareItems(pair); + } + if (this.constructor.menubar) { + setManagedAttribute(this._managed, this, "role", "menubar"); + this.setMenubarTabStop(this._pairs[0]); + } + } + + prepareItems(pair) { + const items = [...pair.menu.querySelectorAll(ITEM_SELECTOR)].filter(item => + item.closest(MENU_SELECTOR) === pair.menu && + !item.hidden && + !item.hasAttribute("disabled") && + item.getAttribute("aria-disabled") !== "true"); + for (const item of items) { + if (!item.hasAttribute("role")) { + setManagedAttribute(this._managed, item, "role", "menuitem"); + } + setManagedAttribute(this._managed, item, "tabindex", "-1"); + } + return items; + } + + open(pair, itemIndex = 0, pointerEvent = null) { + if (!pair) return; + if (this._openPair && this._openPair !== pair) this.close(false); + this._openPair = pair; + if (this.constructor.menubar) this.setMenubarTabStop(pair); + if (pointerEvent) { + rememberAttribute(this._managed, pair.menu, "style"); + pair.menu.style.position = "fixed"; + pair.menu.style.left = `${Number(pointerEvent.clientX) || 0}px`; + pair.menu.style.top = `${Number(pointerEvent.clientY) || 0}px`; + } else { + resetManagedAttribute(this._managed, pair.menu, "style"); + } + setManagedAttribute(this._managed, pair.menu, "hidden", null); + setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "true"); + const items = this.prepareItems(pair); + if (items.length) { + const index = itemIndex < 0 ? items.length - 1 : itemIndex; + setManagedAttribute(this._managed, items[index], "tabindex", "0"); + items[index].focus(); + } else { + setManagedAttribute(this._managed, pair.menu, "tabindex", "-1"); + pair.menu.focus(); + } + } + + close(restoreFocus) { + if (!this._openPair) return; + const pair = this._openPair; + this._openPair = null; + setManagedAttribute(this._managed, pair.menu, "hidden", ""); + setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "false"); + resetManagedAttribute(this._managed, pair.menu, "style"); + if (restoreFocus) pair.trigger.focus(); + } + + moveItem(pair, offset, edge = null) { + const active = document.activeElement; + const items = this.prepareItems(pair); + if (!items.length) return; + let index = items.indexOf(active); + if (edge === "first") index = 0; + else if (edge === "last") index = items.length - 1; + else index = (Math.max(index, 0) + offset + items.length) % items.length; + setManagedAttribute(this._managed, items[index], "tabindex", "0"); + items[index].focus(); + } + + moveMenubarTrigger(pair, offset, openMenu) { + const index = this._pairs.indexOf(pair); + const next = this._pairs[ + (index + offset + this._pairs.length) % this._pairs.length + ]; + this.setMenubarTabStop(next); + if (openMenu) this.open(next); + else next.trigger.focus(); + } + + setMenubarTabStop(activePair) { + for (const pair of this._pairs) { + setManagedAttribute( + this._managed, + pair.trigger, + "tabindex", + pair === activePair ? "0" : "-1", + ); + } + } + + handleKeyDown(event) { + const target = eventElement(event); + if (!target) return; + const triggerPair = this._pairs.find(({ trigger }) => + trigger.contains(target)); + if (triggerPair) { + if (this.constructor.menubar && + (event.key === "ArrowRight" || event.key === "ArrowLeft")) { + event.preventDefault(); + this.moveMenubarTrigger( + triggerPair, + event.key === "ArrowRight" ? 1 : -1, + false, + ); + } else if (["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) { + event.preventDefault(); + this.open(triggerPair, event.key === "ArrowUp" ? -1 : 0); + } else if (event.key === "Escape" && this._openPair) { + event.preventDefault(); + this.close(true); + } + return; + } + + const pair = this._pairs.find(({ menu }) => menu.contains(target)); + if (!pair || this._openPair !== pair) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + this.moveItem(pair, 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.moveItem(pair, -1); + } else if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + this.moveItem(pair, 0, event.key === "Home" ? "first" : "last"); + } else if (event.key === "Escape") { + event.preventDefault(); + this.close(true); + } else if (event.key === "Tab") { + this.close(false); + } else if (this.constructor.menubar && + (event.key === "ArrowRight" || event.key === "ArrowLeft")) { + event.preventDefault(); + this.moveMenubarTrigger( + pair, + event.key === "ArrowRight" ? 1 : -1, + true, + ); + } + } +} + +/** + * Provides an accessible action menu opened from a native trigger. + * + * @extends {ZenMenu} + */ +export class ZenDropdownMenu extends ZenMenu {} + +/** + * Opens an accessible action menu at the pointer location. + * + * @extends {ZenMenu} + */ +export class ZenContextMenu extends ZenMenu { + static contextMenu = true; +} + +/** + * Coordinates multiple keyboard-accessible application menus. + * + * @extends {ZenMenu} + */ +export class ZenMenubar extends ZenMenu { + static menubar = true; + static multiple = true; +} + +/** + * Provides keyboard-accessible navigation flyouts. + * + * @extends {ZenMenu} + */ +export class ZenNavigationMenu extends ZenMenu {} + +const definitions = { + "zen-dialog": ZenDialog, + "zen-alert-dialog": ZenAlertDialog, + "zen-sheet": ZenSheet, + "zen-drawer": ZenDrawer, + "zen-popover": ZenPopover, + "zen-hover-card": ZenHoverCard, + "zen-tooltip": ZenTooltip, + "zen-dropdown-menu": ZenDropdownMenu, + "zen-context-menu": ZenContextMenu, + "zen-menubar": ZenMenubar, + "zen-navigation-menu": ZenNavigationMenu, +}; + +for (const [name, constructor] of Object.entries(definitions)) { + if (!customElements.get(name)) customElements.define(name, constructor); +}