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);
+  }
+}