Mercurial
view design_system/src/components/disclosure.js @ 258:60a876c4587a
[ui] Add semantic primitive ownership
Build a layered Zenbu token and sizing system, make authored controls use native-underneath primitives, migrate mrjunejune without imposing visual surfaces, and document/enforce HTML ownership in the catalog and wiki.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Wed, 05 Aug 2026 05:25:40 -0700 |
| parents | 2b6e732087ff |
| children |
line wrap: on
line source
let nextDisclosureId = 1; const HTMLElementBase = globalThis.HTMLElement || class {}; 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) { if (element.hasAttribute(name)) element.removeAttribute(name); } else if (element.getAttribute(name) !== value) { element.setAttribute(name, value); } } function restoreAttribute(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); attributes.delete(name); if (!attributes.size) state.delete(element); } function restoreElement(state, element) { const attributes = state.get(element); if (!attributes) return; for (const [name, value] of attributes) { if (value === null) element.removeAttribute(name); else element.setAttribute(name, value); } state.delete(element); } function restoreAttributes(state) { for (const element of [...state.keys()]) restoreElement(state, element); } function pruneAttributes(state, retained) { for (const element of [...state.keys()]) { if (!retained.has(element)) restoreElement(state, element); } } function uniqueId(element, prefix) { const document = element.ownerDocument; let id; do id = `${prefix}-${nextDisclosureId++}`; while ([...document.querySelectorAll("[id]")].some(item => item.id === id)); return id; } function ensureId(state, element, prefix) { const document = element.ownerDocument; const duplicate = element.id && [...document.querySelectorAll("[id]")].some( item => item !== element && item.id === element.id, ); if (!element.id || duplicate) { setManagedAttribute(state, element, "id", uniqueId(element, prefix)); } return element.id; } function eventElement(event) { const target = event.target; if (target?.nodeType === 1) return target; return target?.parentElement || null; } function isDisabled(control) { return Boolean(control.disabled) || control.getAttribute("aria-disabled") === "true"; } function emitChange(element, detail) { element.dispatchEvent(new CustomEvent("zen-change", { bubbles: true, detail, })); } function ownedElements(host, selector) { return [...host.querySelectorAll(selector)].filter( element => element.closest(host.localName) === host, ); } function syncDisclosureIcon(details) { const summary = details.querySelector(":scope > summary"); if (!summary) return; let icon = summary.querySelector( ":scope > zen-icon[data-zen-disclosure-icon]", ); if (!icon) { icon = document.createElement("zen-icon"); icon.dataset.zenDisclosureIcon = ""; icon.dataset.zenGenerated = "true"; summary.append(icon); } if (!icon.hasAttribute("name")) icon.setAttribute("name", "chevron-down"); } function removeGeneratedDisclosureIcons(host) { for (const icon of host.querySelectorAll( 'zen-icon[data-zen-disclosure-icon][data-zen-generated="true"]', )) { icon.remove(); } } /** * Coordinates native details elements and summary keyboard navigation. * * @extends {HTMLElement} */ export class ZenAccordion extends HTMLElementBase { static get observedAttributes() { return ["multiple"]; } connectedCallback() { this._details ||= new Set(); this._onToggle ||= event => { const opened = event.currentTarget; if (!opened.open || this.hasAttribute("multiple")) return; for (const details of this._details) { if (details !== opened) details.open = false; } }; this._onKeydown ||= event => { if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { return; } const summary = eventElement(event)?.closest("summary"); const summaries = this.summaries(); const index = summaries.indexOf(summary); if (index < 0) return; event.preventDefault(); let next = event.key === "Home" ? 0 : summaries.length - 1; if (event.key === "ArrowUp") { next = (index - 1 + summaries.length) % summaries.length; } else if (event.key === "ArrowDown") { next = (index + 1) % summaries.length; } summaries[next]?.focus(); }; this.addEventListener("keydown", this._onKeydown); this._observer ||= new MutationObserver(() => this.sync()); this._observer.observe(this, { childList: true, subtree: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("keydown", this._onKeydown); for (const details of this._details || []) { details.removeEventListener("toggle", this._onToggle); } removeGeneratedDisclosureIcons(this); this._details?.clear(); } attributeChangedCallback() { if (this.isConnected && this._details) this.sync(); } summaries() { return [...this._details] .map(details => details.querySelector(":scope > summary")) .filter(Boolean); } sync() { const current = new Set( ownedElements(this, "details").filter(details => { const parent = details.parentElement?.closest("details"); return !parent || parent.closest("zen-accordion") !== this; }), ); for (const details of this._details) { if (!current.has(details)) { details.removeEventListener("toggle", this._onToggle); } } for (const details of current) { if (!this._details.has(details)) { details.addEventListener("toggle", this._onToggle); } syncDisclosureIcon(details); } this._details = current; if (!this.hasAttribute("multiple")) { const opened = [...current].filter(details => details.open); for (const details of opened.slice(1)) details.open = false; } } } /** * Styles a single native details disclosure without replacing its behavior. * * @extends {HTMLElement} */ export class ZenCollapsible extends HTMLElementBase { connectedCallback() { this._observer ||= new MutationObserver(() => this.sync()); this._observer.observe(this, { childList: true, subtree: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); removeGeneratedDisclosureIcons(this); } sync() { for (const details of ownedElements(this, "details")) { syncDisclosureIcon(details); } } } /** * Connects an ARIA tablist with its native content panels. * * @extends {HTMLElement} */ export class ZenTabs extends HTMLElementBase { connectedCallback() { this._managed ||= new Map(); this._onClick ||= event => { const tab = eventElement(event)?.closest('[role="tab"]'); if (!this.isTab(tab) || isDisabled(tab)) return; this.activate(tab, true); }; this._onKeydown ||= event => { const tab = eventElement(event)?.closest('[role="tab"]'); if (!this.isTab(tab)) return; const tabs = this._tabs.filter(candidate => !isDisabled(candidate)); const index = tabs.indexOf(tab); if (index < 0) return; let next; if (event.key === "Home") next = tabs[0]; else if (event.key === "End") next = tabs[tabs.length - 1]; else if (event.key === "ArrowLeft") { next = tabs[(index - 1 + tabs.length) % tabs.length]; } else if (event.key === "ArrowRight") { next = tabs[(index + 1) % tabs.length]; } else { return; } event.preventDefault(); this._focusTab = next; this.render(); next.focus(); if (this.getAttribute("activation") !== "manual") { this.activate(next, true); } }; this.addEventListener("click", this._onClick); this.addEventListener("keydown", this._onKeydown); this._observer ||= new MutationObserver(() => this.sync()); this._observer.observe(this, { attributeFilter: ["aria-controls", "aria-disabled", "disabled", "id"], attributes: true, childList: true, subtree: true, }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("click", this._onClick); this.removeEventListener("keydown", this._onKeydown); restoreAttributes(this._managed); this._tablist = null; this._tabs = []; this._panels = []; this._pairs = new Map(); this._active = null; this._focusTab = null; } isTab(tab) { return Boolean( tab && this._tabs?.includes(tab) && this._tablist?.contains(tab), ); } sync() { this._tablist = ownedElements(this, '[role="tablist"]')[0] || null; this._tabs = this._tablist ? [...this._tablist.querySelectorAll('[role="tab"]')].filter( tab => tab.closest("zen-tabs") === this, ) : []; this._panels = ownedElements(this, '[role="tabpanel"]'); pruneAttributes( this._managed, new Set([...this._tabs, ...this._panels]), ); for (const tab of this._tabs) { ensureId(this._managed, tab, "zen-tab"); } for (const panel of this._panels) { ensureId(this._managed, panel, "zen-tabpanel"); } const available = new Set(this._panels); this._pairs = new Map(); for (const [index, tab] of this._tabs.entries()) { const controlled = tab.getAttribute("aria-controls"); let panel = this._panels.find( candidate => available.has(candidate) && candidate.id === controlled, ); if (!panel && available.has(this._panels[index])) { panel = this._panels[index]; } panel ||= [...available][0] || null; if (panel) { available.delete(panel); this._pairs.set(tab, panel); setManagedAttribute( this._managed, tab, "aria-controls", panel.id, ); setManagedAttribute( this._managed, panel, "aria-labelledby", tab.id, ); } else { restoreAttribute(this._managed, tab, "aria-controls"); } } for (const panel of available) { restoreAttribute(this._managed, panel, "aria-labelledby"); } if (!this._tabs.includes(this._active) || isDisabled(this._active)) { this._active = this._tabs.find( tab => !isDisabled(tab) && tab.getAttribute("aria-selected") === "true", ) || this._tabs.find(tab => !isDisabled(tab)) || this._tabs[0] || null; } if (!this._tabs.includes(this._focusTab) || isDisabled(this._focusTab)) { this._focusTab = this._active; } this.render(); } render() { for (const tab of this._tabs) { const active = tab === this._active; setManagedAttribute( this._managed, tab, "aria-selected", String(active), ); setManagedAttribute( this._managed, tab, "tabindex", tab === this._focusTab && !isDisabled(tab) ? "0" : "-1", ); } const activePanel = this._pairs.get(this._active); for (const panel of this._panels) { setManagedAttribute( this._managed, panel, "hidden", panel === activePanel ? null : "", ); } } activate(tab, notify) { if (!this.isTab(tab) || isDisabled(tab)) return; this._focusTab = tab; if (tab === this._active) { this.render(); return; } this._active = tab; this.render(); if (notify) { emitChange(this, { value: tab.dataset.value ?? tab.getAttribute("value") ?? tab.id, }); } } } /** * Manages the pressed state of one direct native button. * * @extends {HTMLElement} */ export class ZenToggle extends HTMLElementBase { static get observedAttributes() { return ["pressed"]; } get pressed() { return this.hasAttribute("pressed"); } set pressed(value) { this.toggleAttribute("pressed", Boolean(value)); } connectedCallback() { this._managed ||= new Map(); this._onClick ||= event => { const button = eventElement(event)?.closest("button"); if (button !== this._button || isDisabled(button)) return; this.pressed = !this.pressed; emitChange(this, { pressed: this.pressed }); }; this.addEventListener("click", this._onClick); this._observer ||= new MutationObserver(() => this.sync()); this._observer.observe(this, { childList: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("click", this._onClick); restoreAttributes(this._managed); this._button = null; } attributeChangedCallback() { if (this.isConnected && this._managed) this.sync(); } sync() { const button = this.querySelector(":scope > button"); if (button !== this._button) { if (this._button) restoreElement(this._managed, this._button); this._button = button; } if (button) { setManagedAttribute( this._managed, button, "aria-pressed", String(this.pressed), ); } } } function buttonValue(button) { return button.getAttribute("value") ?? button.dataset.value ?? ""; } function parseMultipleValue(value) { if (!value) return []; if (value.trim().startsWith("[")) { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) return parsed.map(String); } catch { // Fall through to token parsing. } } return value.trim().split(/[\s,]+/).filter(Boolean); } /** * Manages single or multiple pressed buttons with roving focus. * * @extends {HTMLElement} */ export class ZenToggleGroup extends HTMLElementBase { static get observedAttributes() { return ["type", "value"]; } get values() { return [...(this._values || [])]; } set values(values) { this.setValues(Array.isArray(values) ? values.map(String) : [], false); } connectedCallback() { this._managed ||= new Map(); this._buttons ||= []; if (!this._initialized) { this._values = this.initialValues(); this._initialized = true; } this._hasConnected = true; this._onClick ||= event => { const button = eventElement(event)?.closest("button"); if (!this._buttons.includes(button) || isDisabled(button)) return; const value = buttonValue(button); this._focusButton = button; let values; if (this.multiple) { values = this._values.includes(value) ? this._values.filter(item => item !== value) : [...this._values, value]; } else { values = this._values.includes(value) ? [] : [value]; } this.setValues(values, true); }; this._onKeydown ||= event => { const button = eventElement(event)?.closest("button"); const buttons = this._buttons.filter(candidate => !isDisabled(candidate)); const index = buttons.indexOf(button); if (index < 0) return; let next; if (event.key === "Home") next = buttons[0]; else if (event.key === "End") next = buttons[buttons.length - 1]; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { next = buttons[(index - 1 + buttons.length) % buttons.length]; } else if (event.key === "ArrowRight" || event.key === "ArrowDown") { next = buttons[(index + 1) % buttons.length]; } else { return; } event.preventDefault(); this._focusButton = next; this.render(); next.focus(); }; this.addEventListener("click", this._onClick); this.addEventListener("keydown", this._onKeydown); this._observer ||= new MutationObserver(() => this.sync()); this._observer.observe(this, { attributeFilter: ["aria-disabled", "data-value", "disabled", "value"], attributes: true, childList: true, subtree: true, }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("click", this._onClick); this.removeEventListener("keydown", this._onKeydown); restoreAttributes(this._managed); this._buttons = []; this._focusButton = null; } attributeChangedCallback(name) { if (name === "value" && !this._settingValue && this.hasAttribute("value")) { this._values = this.readValue(); this._initialized = true; } else if (name === "value" && !this._settingValue) { this._values = []; this._initialized = true; } else if (name === "type" && !this._hasConnected && this.hasAttribute("value")) { this._values = this.readValue(); this._initialized = true; } if (this.isConnected && this._managed) this.sync(); } get multiple() { return this.getAttribute("type") === "multiple"; } readValue() { const value = this.getAttribute("value"); if (value === null) return []; return this.multiple ? parseMultipleValue(value) : [value]; } initialValues() { if (this.hasAttribute("value")) return this.readValue(); const pressed = [...this.querySelectorAll(":scope > button")] .filter(button => button.getAttribute("aria-pressed") === "true"); return pressed.map(buttonValue); } sync() { const buttons = [...this.querySelectorAll(":scope > button")]; pruneAttributes(this._managed, new Set([this, ...buttons])); this._buttons = buttons; if (!this.hasAttribute("role")) { setManagedAttribute(this._managed, this, "role", "group"); } if (!this.multiple && this._values.length > 1) { this._values = this._values.slice(0, 1); this.writeValue(); } if (!buttons.includes(this._focusButton) || isDisabled(this._focusButton)) { this._focusButton = buttons.find( button => !isDisabled(button) && button.getAttribute("tabindex") === "0", ) || buttons.find( button => !isDisabled(button) && this._values.includes(buttonValue(button)), ) || buttons.find(button => !isDisabled(button)) || null; } this.render(); } render() { for (const button of this._buttons) { setManagedAttribute( this._managed, button, "aria-pressed", String(this._values.includes(buttonValue(button))), ); setManagedAttribute( this._managed, button, "tabindex", button === this._focusButton ? "0" : "-1", ); } } writeValue() { this._settingValue = true; const value = this.multiple ? (this._values.some(item => /[\s,]/.test(item)) ? JSON.stringify(this._values) : this._values.join(" ")) : this._values[0]; if (value === undefined || value === "") this.removeAttribute("value"); else this.setAttribute("value", value); this._settingValue = false; } setValues(values, notify) { const unique = [...new Set(values.map(String))]; this._values = this.multiple ? unique : unique.slice(0, 1); this.writeValue(); if (this.isConnected) this.render(); if (notify) emitChange(this, { values: this.values }); } } /** * Controls a responsive native sidebar region and its trigger. * * @extends {HTMLElement} */ export class ZenSidebar extends HTMLElementBase { static get observedAttributes() { return ["open"]; } get open() { return this.hasAttribute("open"); } set open(value) { this.toggleAttribute("open", Boolean(value)); } connectedCallback() { this._managed ||= new Map(); this._onClick ||= event => { const trigger = eventElement(event)?.closest( "[data-zen-sidebar-trigger]", ); if (trigger !== this._trigger || isDisabled(trigger)) return; this.setOpen(!this.open, true); }; this._onKeydown ||= event => { if (event.key !== "Escape" || !this.open) return; event.preventDefault(); this.setOpen(false, true); this._trigger?.focus(); }; this._onMediaChange ||= () => this.syncState(); this.addEventListener("click", this._onClick); this.addEventListener("keydown", this._onKeydown); this._observer ||= new MutationObserver(() => this.sync()); this._observer.observe(this, { childList: true }); this._media = globalThis.matchMedia?.("(max-width: 640px)") || null; if (this._media?.addEventListener) { this._media.addEventListener("change", this._onMediaChange); } else { this._media?.addListener?.(this._onMediaChange); } this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("click", this._onClick); this.removeEventListener("keydown", this._onKeydown); if (this._media?.removeEventListener) { this._media.removeEventListener("change", this._onMediaChange); } else { this._media?.removeListener?.(this._onMediaChange); } restoreAttributes(this._managed); this._trigger = null; this._panel = null; this._media = null; } attributeChangedCallback() { if (this.isConnected) this.syncState(); } sync() { const trigger = ownedElements( this, "[data-zen-sidebar-trigger]", )[0] || null; const panel = ownedElements( this, "[data-zen-sidebar-panel]", )[0] || null; if (trigger !== this._trigger || panel !== this._panel) { if (this._trigger) restoreElement(this._managed, this._trigger); if (this._panel) restoreElement(this._managed, this._panel); this._trigger = trigger; this._panel = panel; } if (!trigger || !panel) return; const panelId = ensureId(this._managed, panel, "zen-sidebar-panel"); setManagedAttribute( this._managed, trigger, "aria-controls", panelId, ); this.syncState(); } syncState() { if (!this._trigger || !this._panel) return; setManagedAttribute( this._managed, this._panel, "hidden", this.open ? null : "", ); setManagedAttribute( this._managed, this._trigger, "aria-expanded", String(this.open), ); } setOpen(open, notify) { if (this.open === open) return; this.open = open; this.syncState(); if (notify) emitChange(this, { open }); } } const definitions = { "zen-accordion": ZenAccordion, "zen-collapsible": ZenCollapsible, "zen-sidebar": ZenSidebar, "zen-tabs": ZenTabs, "zen-toggle": ZenToggle, "zen-toggle-group": ZenToggleGroup, }; const registry = globalThis.customElements; if (registry) { for (const [name, definition] of Object.entries(definitions)) { if (!registry.get(name)) registry.define(name, definition); } }