Mercurial
diff design_system/src/components/disclosure.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/disclosure.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,800 @@ +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); + } +}