view design_system/src/components/forms.js @ 273:e02e2036ef84 default tip

add Layer 2 JRPG component system Add reusable content and window modals, an isolated component sandbox, shared cyberpunk scroll areas, production-safe cache freshness, and server-rendered JRPG panel state. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Sat, 08 Aug 2026 02:08:08 -0700
parents 2b6e732087ff
children
line wrap: on
line source

let nextFormId = 1;

function uniqueId(prefix) {
  let id;
  do id = `${prefix}-${nextFormId++}`;
  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 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 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 restoreAttributes(state) {
  for (const element of [...state.keys()]) restoreElement(state, element);
}

function originalAttribute(state, element, name) {
  const attributes = state.get(element);
  return attributes?.has(name)
    ? attributes.get(name)
    : element.getAttribute(name);
}

function ensureId(state, element, prefix) {
  const owner = element.id && document.getElementById(element.id);
  if (!element.id || (owner && owner !== element)) {
    setManagedAttribute(state, element, "id", uniqueId(prefix));
  }
  return element.id;
}

function emit(element, type, detail) {
  element.dispatchEvent(new CustomEvent(type, {
    bubbles: true,
    detail,
  }));
}

class NativeControl extends HTMLElement {
  connectedCallback() {
    if (!this._observer) {
      this._observer = new MutationObserver(() => this.sync());
    }
    this._observer.observe(this, { childList: true, subtree: true });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    this._control = null;
  }

  sync() {
    this._control = this.querySelector(this.controlSelector);
  }
}

/**
 * Provides presentation around a native checkbox input.
 *
 * @extends {NativeControl}
 */
export class ZenCheckbox extends NativeControl {
  get controlSelector() {
    return 'input[type="checkbox"]';
  }
}

/**
 * Provides presentation around a native select control.
 *
 * @extends {NativeControl}
 */
export class ZenSelect extends NativeControl {
  get controlSelector() {
    return "select";
  }

  sync() {
    super.sync();
    if (!this._control) return;
    let icon = this.querySelector(
      ":scope > zen-icon[data-zen-select-icon]",
    );
    if (!icon) {
      icon = document.createElement("zen-icon");
      icon.dataset.zenSelectIcon = "";
      icon.dataset.zenGenerated = "true";
      icon.setAttribute("name", "chevron-down");
      this.append(icon);
    }
  }

  disconnectedCallback() {
    this.querySelector(
      ':scope > zen-icon[data-zen-select-icon][data-zen-generated="true"]',
    )?.remove();
    super.disconnectedCallback();
  }
}

/**
 * Provides presentation around a native range input.
 *
 * @extends {NativeControl}
 */
export class ZenSlider extends NativeControl {
  get controlSelector() {
    return 'input[type="range"]';
  }
}

function createDate(year, month, day) {
  const date = new Date(0);
  date.setHours(0, 0, 0, 0);
  date.setFullYear(year, month, day);
  return date;
}

function parseDate(value) {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
  if (!match) return null;
  const year = Number(match[1]);
  const month = Number(match[2]) - 1;
  const day = Number(match[3]);
  const date = createDate(year, month, day);
  if (date.getFullYear() !== year ||
      date.getMonth() !== month ||
      date.getDate() !== day) return null;
  return date;
}

function dateKey(date) {
  const year = String(date.getFullYear()).padStart(4, "0");
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
}

function addDays(date, amount) {
  return createDate(
    date.getFullYear(),
    date.getMonth(),
    date.getDate() + amount,
  );
}

function addMonths(date, amount) {
  const first = createDate(date.getFullYear(), date.getMonth() + amount, 1);
  const last = createDate(
    first.getFullYear(),
    first.getMonth() + 1,
    0,
  ).getDate();
  return createDate(
    first.getFullYear(),
    first.getMonth(),
    Math.min(date.getDate(), last),
  );
}

/**
 * Renders a tokenized calendar backed by a native date input.
 *
 * @extends {HTMLElement}
 */
export class ZenCalendar extends HTMLElement {
  connectedCallback() {
    this._managed ||= new Map();
    this._onClick ||= event => this.onClick(event);
    this._onKeydown ||= event => this.onKeydown(event);
    this._onInput ||= event => {
      if (event.target !== this._input) return;
      this.readInput();
      if (this._input.validity.valid) this.setInvalid(false);
      this.render();
    };
    this._onInvalid ||= event => {
      if (event.target !== this._input) return;
      event.preventDefault();
      this.setInvalid(true);
      queueMicrotask(() => {
        if (!this.isConnected) return;
        const target = this._trigger ||
          this._grid?.querySelector('[tabindex="0"]:not(:disabled)');
        target?.focus();
      });
    };
    this._onDocumentPointerDown ||= event => {
      if (this.isPicker && this._open && !this.contains(event.target)) {
        this.setOpen(false);
      }
    };
    this._onReset ||= () => queueMicrotask(() => {
      if (!this.isConnected) return;
      this.readInput();
      this.render();
    });
    this.addEventListener("click", this._onClick);
    this.addEventListener("keydown", this._onKeydown);
    this.addEventListener("input", this._onInput);
    this.addEventListener("change", this._onInput);
    this.addEventListener("invalid", this._onInvalid, true);
    document.addEventListener("pointerdown", this._onDocumentPointerDown);
    this._observer ||= new MutationObserver(() => this.sync());
    this._observer.observe(this, { childList: true });
    this._inputObserver ||= new MutationObserver(() => {
      this.readInput();
      this.render();
    });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    this._inputObserver?.disconnect();
    this._form?.removeEventListener("reset", this._onReset);
    this.removeEventListener("click", this._onClick);
    this.removeEventListener("keydown", this._onKeydown);
    this.removeEventListener("input", this._onInput);
    this.removeEventListener("change", this._onInput);
    this.removeEventListener("invalid", this._onInvalid, true);
    document.removeEventListener("pointerdown", this._onDocumentPointerDown);
    this.release();
  }

  get isPicker() {
    return this.localName === "zen-date-picker";
  }

  sync() {
    const input = [...this.children].find(
      child => child.matches?.('input[type="date"]'),
    ) || null;
    if (input === this._input) return;
    this.release();
    this._input = input;
    if (!input) return;

    setManagedAttribute(
      this._managed,
      input,
      "data-zen-calendar-input",
      "",
    );
    setManagedAttribute(this._managed, input, "tabindex", "-1");
    setManagedAttribute(this._managed, input, "aria-hidden", "true");
    this._inputObserver.observe(input, {
      attributes: true,
      attributeFilter: ["disabled", "max", "min", "readonly", "value"],
    });
    this._form = input.form;
    this._form?.addEventListener("reset", this._onReset);
    this.readInput();
    this.createView();
    this.render();
  }

  release() {
    this._inputObserver?.disconnect();
    this._form?.removeEventListener("reset", this._onReset);
    this._generated?.remove();
    this._generated = null;
    this._view = null;
    this._grid = null;
    this._heading = null;
    this._trigger = null;
    this._panel = null;
    this._labelText = "";
    this._labelIds = [];
    this._form = null;
    this._open = false;
    restoreAttributes(this._managed || new Map());
    this._input = null;
  }

  readInput() {
    const selected = parseDate(this._input?.value);
    const today = new Date();
    this._selected = selected;
    this._focusDate = selected || this._focusDate || today;
    this._month = createDate(
      (selected || this._focusDate).getFullYear(),
      (selected || this._focusDate).getMonth(),
      1,
    );
  }

  limits() {
    return {
      maximum: parseDate(this._input?.max),
      minimum: parseDate(this._input?.min),
    };
  }

  isEditable() {
    return Boolean(this._input) &&
      !this._input.disabled &&
      !this._input.readOnly;
  }

  isAllowed(date) {
    if (!date) return false;
    const { maximum, minimum } = this.limits();
    return (!minimum || date >= minimum) && (!maximum || date <= maximum);
  }

  clampDate(date) {
    const { maximum, minimum } = this.limits();
    if (minimum && date < minimum) return minimum;
    if (maximum && date > maximum) return maximum;
    return date;
  }

  setInvalid(invalid) {
    setManagedAttribute(
      this._managed,
      this,
      "data-invalid",
      invalid ? "" : null,
    );
    if (this._trigger) {
      this._trigger.setAttribute("aria-invalid", String(invalid));
    }
    if (this._grid) {
      this._grid.setAttribute("aria-invalid", String(invalid));
    }
  }

  createIcon(name) {
    const icon = document.createElement("zen-icon");
    icon.setAttribute("name", name);
    return icon;
  }

  createControl(name, label) {
    const button = document.createElement("button");
    button.type = "button";
    button.dataset[name] = "";
    button.setAttribute("aria-label", label);
    return button;
  }

  createView() {
    const generated = document.createElement("div");
    generated.dataset.zenGenerated = "calendar";
    this._generated = generated;
    const labels = [...this._input.labels];
    const authoredLabelIds = (this._input.getAttribute("aria-labelledby") || "")
      .split(/\s+/)
      .filter(id => id && document.getElementById(id));
    const associatedLabelIds = labels.map(label =>
      ensureId(this._managed, label, "zen-date-label")
    );
    this._labelIds = authoredLabelIds.length
      ? authoredLabelIds
      : associatedLabelIds;
    this._labelText = this._labelIds.map(id =>
      document.getElementById(id)?.textContent.trim()
    )
      .filter(Boolean)
      .join(", ") ||
      labels.map(label => label.textContent.trim())
      .filter(Boolean)
      .join(", ") ||
      this._input.getAttribute("aria-label") ||
      "Date";

    if (this.isPicker) {
      const trigger = this.createControl(
        "zenDateTrigger",
        "Choose date",
      );
      trigger.id = uniqueId("zen-date-picker-trigger");
      trigger.append(this.createIcon("calendar"));
      const triggerText = document.createElement("span");
      triggerText.dataset.zenDateValue = "";
      triggerText.id = uniqueId("zen-date-picker-value");
      trigger.append(triggerText);
      this._trigger = trigger;

      const panel = document.createElement("div");
      panel.dataset.zenCalendarPanel = "";
      panel.id = uniqueId("zen-date-picker-panel");
      panel.setAttribute("role", "dialog");
      panel.setAttribute("aria-label", `${this._labelText} calendar`);
      panel.hidden = true;
      trigger.setAttribute("aria-haspopup", "dialog");
      trigger.setAttribute("aria-controls", panel.id);
      if (this._input.hasAttribute("aria-describedby")) {
        trigger.setAttribute(
          "aria-describedby",
          this._input.getAttribute("aria-describedby"),
        );
      }
      if (this._labelIds.length) {
        trigger.setAttribute(
          "aria-labelledby",
          [...this._labelIds, triggerText.id].join(" "),
        );
      }
      for (const label of labels) {
        if (label.htmlFor === this._input.id) {
          setManagedAttribute(this._managed, label, "for", trigger.id);
        }
      }
      this._panel = panel;
      generated.append(trigger, panel);
    }

    const view = document.createElement("div");
    view.dataset.zenCalendarView = "";
    const header = document.createElement("header");
    const previous = this.createControl(
      "zenCalendarPrevious",
      "Previous month",
    );
    previous.append(this.createIcon("chevron-left"));
    const heading = document.createElement("strong");
    heading.setAttribute("aria-live", "polite");
    const next = this.createControl("zenCalendarNext", "Next month");
    next.append(this.createIcon("chevron-right"));
    header.append(previous, heading, next);

    const weekdays = document.createElement("div");
    weekdays.dataset.zenCalendarWeekdays = "";
    weekdays.setAttribute("aria-hidden", "true");
    const formatter = new Intl.DateTimeFormat(undefined, {
      weekday: "narrow",
    });
    for (let day = 4; day < 11; day++) {
      const label = document.createElement("span");
      label.textContent = formatter.format(new Date(2026, 0, day));
      weekdays.append(label);
    }

    const grid = document.createElement("div");
    grid.dataset.zenCalendarGrid = "";
    grid.setAttribute("role", "grid");
    grid.setAttribute(
      "aria-label",
      this._labelText ? `${this._labelText} calendar` : "Calendar",
    );
    view.append(header, weekdays, grid);
    this._view = view;
    this._heading = heading;
    this._grid = grid;
    (this._panel || generated).append(view);
    this.append(generated);
    if (this.isPicker) this.setOpen(false);
  }

  render() {
    if (!this._grid || !this._month) return;
    this._focusDate = this.clampDate(this._focusDate);
    this._heading.textContent = new Intl.DateTimeFormat(undefined, {
      month: "long",
      year: "numeric",
    }).format(this._month);

    if (this._trigger) {
      const text = this._trigger.querySelector("[data-zen-date-value]");
      text.textContent = this._selected
        ? new Intl.DateTimeFormat(undefined, {
          dateStyle: "medium",
        }).format(this._selected)
        : "Choose date";
      if (this._labelIds.length) {
        this._trigger.removeAttribute("aria-label");
      } else {
        this._trigger.setAttribute(
          "aria-label",
          `${this._labelText}, ${text.textContent}`,
        );
      }
      this._trigger.disabled = !this.isEditable();
    }
    for (const control of this._view.querySelectorAll(
      "[data-zen-calendar-previous], [data-zen-calendar-next]",
    )) {
      control.disabled = !this.isEditable();
    }

    const first = createDate(
      this._month.getFullYear(),
      this._month.getMonth(),
      1,
    );
    const start = addDays(first, -first.getDay());
    const { maximum, minimum } = this.limits();
    const today = dateKey(new Date());
    const selected = this._selected ? dateKey(this._selected) : "";
    const focused = dateKey(this._focusDate);
    const fragment = document.createDocumentFragment();
    const formatter = new Intl.DateTimeFormat(undefined, {
      dateStyle: "full",
    });

    for (let week = 0; week < 6; week++) {
      const row = document.createElement("div");
      row.setAttribute("role", "row");
      for (let day = 0; day < 7; day++) {
        const date = addDays(start, week * 7 + day);
        const key = dateKey(date);
        const button = document.createElement("button");
        button.type = "button";
        button.dataset.zenCalendarDay = key;
        button.setAttribute("role", "gridcell");
        button.setAttribute("aria-label", formatter.format(date));
        button.setAttribute("aria-selected", String(key === selected));
        const disabled = !this.isEditable() ||
          Boolean((minimum && date < minimum) || (maximum && date > maximum));
        button.tabIndex = key === focused && !disabled ? 0 : -1;
        button.textContent = String(date.getDate());
        button.toggleAttribute(
          "data-outside",
          date.getMonth() !== this._month.getMonth(),
        );
        button.toggleAttribute("data-today", key === today);
        button.disabled = disabled;
        row.append(button);
      }
      fragment.append(row);
    }
    this._grid.replaceChildren(fragment);
  }

  onClick(event) {
    const target = event.target instanceof Element ? event.target : null;
    if (!target) return;
    if (target.closest("[data-zen-date-trigger]") === this._trigger) {
      if (!this.isEditable()) return;
      this.setOpen(!this._open);
      return;
    }
    if (target.closest("[data-zen-calendar-previous]")) {
      if (!this.isEditable()) return;
      this.changeMonth(-1);
      return;
    }
    if (target.closest("[data-zen-calendar-next]")) {
      if (!this.isEditable()) return;
      this.changeMonth(1);
      return;
    }
    const day = target.closest("[data-zen-calendar-day]");
    if (day && !day.disabled) this.selectDate(parseDate(day.dataset.zenCalendarDay));
  }

  onKeydown(event) {
    if (event.key === "Escape" && this.isPicker && this._open) {
      event.preventDefault();
      this.setOpen(false);
      this._trigger.focus();
      return;
    }
    const day = event.target instanceof Element
      ? event.target.closest("[data-zen-calendar-day]")
      : null;
    if (!day) return;
    const current = parseDate(day.dataset.zenCalendarDay);
    let next = null;
    if (event.key === "ArrowLeft") next = addDays(current, -1);
    else if (event.key === "ArrowRight") next = addDays(current, 1);
    else if (event.key === "ArrowUp") next = addDays(current, -7);
    else if (event.key === "ArrowDown") next = addDays(current, 7);
    else if (event.key === "Home") next = addDays(current, -current.getDay());
    else if (event.key === "End") next = addDays(current, 6 - current.getDay());
    else if (event.key === "PageUp") next = addMonths(current, -1);
    else if (event.key === "PageDown") next = addMonths(current, 1);
    if (!next) return;
    next = this.clampDate(next);
    event.preventDefault();
    this._focusDate = next;
    this._month = createDate(next.getFullYear(), next.getMonth(), 1);
    this.render();
    this._grid.querySelector(
      `[data-zen-calendar-day="${dateKey(next)}"]`,
    )?.focus();
  }

  changeMonth(amount) {
    if (!this.isEditable()) return;
    this._month = addMonths(this._month, amount);
    this._focusDate = this.clampDate(createDate(
      this._month.getFullYear(),
      this._month.getMonth(),
      1,
    ));
    this._month = createDate(
      this._focusDate.getFullYear(),
      this._focusDate.getMonth(),
      1,
    );
    this.render();
    this._grid.querySelector('[tabindex="0"]')?.focus();
  }

  selectDate(date) {
    if (!this.isEditable() || !this.isAllowed(date)) return;
    this._selected = date;
    this._focusDate = date;
    this._month = createDate(date.getFullYear(), date.getMonth(), 1);
    this._input.value = dateKey(date);
    this._input.dispatchEvent(new Event("input", {
      bubbles: true,
      composed: true,
    }));
    this._input.dispatchEvent(new Event("change", { bubbles: true }));
    emit(this, "zen-change", { value: this._input.value });
    this.render();
    if (this.isPicker) {
      this.setOpen(false);
      this._trigger.focus();
    }
  }

  setOpen(open) {
    if (!this.isPicker || !this._panel || !this._trigger) return;
    this._open = Boolean(open) && this.isEditable();
    this._panel.hidden = !this._open;
    this._trigger.setAttribute("aria-expanded", String(this._open));
    if (this._open) {
      this._grid.querySelector('[tabindex="0"]')?.focus();
    }
  }
}

/**
 * Presents the first-party calendar behind a tokenized disclosure trigger.
 *
 * @extends {ZenCalendar}
 */
export class ZenDatePicker extends ZenCalendar {}

/**
 * Adds radiogroup semantics when no native fieldset owns the radios.
 *
 * @extends {HTMLElement}
 */
export class ZenRadioGroup extends HTMLElement {
  connectedCallback() {
    this._managed ||= new Map();
    if (!this._observer) {
      this._observer = new MutationObserver(() => this.sync());
    }
    this._observer.observe(this, { childList: true, subtree: true });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    restoreAttributes(this._managed);
  }

  sync() {
    const radios = this.querySelectorAll('input[type="radio"]');
    const needsRole = radios.length > 0 && !this.querySelector("fieldset");
    if (needsRole && !this.hasAttribute("role")) {
      setManagedAttribute(this._managed, this, "role", "radiogroup");
    } else if (!needsRole) {
      restoreElement(this._managed, this);
    }
  }
}

/**
 * Synchronizes switch semantics onto a native checkbox.
 *
 * @extends {HTMLElement}
 */
export class ZenSwitch extends HTMLElement {
  connectedCallback() {
    this._managed ||= new Map();
    this._onStateChange ||= event => {
      if (event.target === this._control) this.updateState();
    };
    this._onReset ||= () => {
      queueMicrotask(() => {
        if (this.isConnected) this.updateState();
      });
    };
    this.addEventListener("input", this._onStateChange);
    this.addEventListener("change", this._onStateChange);
    if (!this._observer) {
      this._observer = new MutationObserver(() => this.sync());
    }
    this._observer.observe(this, { childList: true, subtree: true });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    this.removeEventListener("input", this._onStateChange);
    this.removeEventListener("change", this._onStateChange);
    this._form?.removeEventListener("reset", this._onReset);
    restoreAttributes(this._managed);
    this._control = null;
    this._form = null;
  }

  sync() {
    const control = this.querySelector('input[type="checkbox"]');
    const form = control?.form || null;
    if (control !== this._control) {
      if (this._control) restoreElement(this._managed, this._control);
      this._control = control;
    }
    if (form !== this._form) {
      this._form?.removeEventListener("reset", this._onReset);
      this._form = form;
      form?.addEventListener("reset", this._onReset);
    }
    this.updateState();
  }

  updateState() {
    if (!this._control) return;
    setManagedAttribute(this._managed, this._control, "role", "switch");
    setManagedAttribute(
      this._managed,
      this._control,
      "aria-checked",
      String(this._control.checked),
    );
  }
}

/**
 * Reflects native form validity on the component boundary.
 *
 * @extends {HTMLElement}
 */
export class ZenForm extends HTMLElement {
  connectedCallback() {
    this._managed ||= new Map();
    this._syncValidity ||= () => {
      if (!this._form) return;
      setManagedAttribute(
        this._managed,
        this,
        "data-invalid",
        this._form.checkValidity() ? null : "",
      );
    };
    this._markInvalid ||= () => {
      setManagedAttribute(this._managed, this, "data-invalid", "");
    };
    this._onReset ||= () => queueMicrotask(this._syncValidity);
    if (!this._observer) {
      this._observer = new MutationObserver(() => this.sync());
    }
    this._observer.observe(this, { childList: true, subtree: true });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    this.releaseForm();
    restoreAttributes(this._managed);
  }

  releaseForm() {
    this._form?.removeEventListener("submit", this._syncValidity);
    this._form?.removeEventListener("input", this._syncValidity);
    this._form?.removeEventListener("invalid", this._markInvalid, true);
    this._form?.removeEventListener("reset", this._onReset);
    this._form = null;
  }

  sync() {
    const form = this.querySelector("form");
    if (form === this._form) return;
    this.releaseForm();
    restoreElement(this._managed, this);
    this._form = form;
    form?.addEventListener("submit", this._syncValidity);
    form?.addEventListener("input", this._syncValidity);
    form?.addEventListener("invalid", this._markInvalid, true);
    form?.addEventListener("reset", this._onReset);
  }
}

class FilterableList extends HTMLElement {
  connectedCallback() {
    this._managed ||= new Map();
    this._active = null;
    this._onInput ||= event => {
      if (event.target !== this._input) return;
      if (this.isCombobox) this.setOpen(true);
      this.filter();
    };
    this._onKeydown ||= event => this.onKeydown(event);
    this._onClick ||= event => this.onClick(event);
    this._onFocus ||= event => {
      if (this.isCombobox && event.target === this._input) {
        this.setOpen(true);
        this.filter();
      }
    };
    this.addEventListener("input", this._onInput);
    this.addEventListener("keydown", this._onKeydown);
    this.addEventListener("click", this._onClick);
    this.addEventListener("focusin", this._onFocus);
    if (!this._observer) {
      this._observer = new MutationObserver(() => this.sync());
    }
    this._observer.observe(this, { childList: true, subtree: true });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    this.removeEventListener("input", this._onInput);
    this.removeEventListener("keydown", this._onKeydown);
    this.removeEventListener("click", this._onClick);
    this.removeEventListener("focusin", this._onFocus);
    restoreAttributes(this._managed);
    this._input = null;
    this._list = null;
    this._items = [];
    this._active = null;
  }

  get isCombobox() {
    return false;
  }

  get listRole() {
    return "menu";
  }

  get itemRole() {
    return "menuitem";
  }

  sync() {
    const input = this.querySelector("input");
    const list = this.querySelector(`[role="${this.listRole}"]`);
    if (input !== this._input || list !== this._list) {
      restoreAttributes(this._managed);
      this._input = input;
      this._list = list;
      this._items = [];
      this._active = null;
      this._open = false;
    }
    if (!input || !list) return;

    const items = [...list.querySelectorAll(`[role="${this.itemRole}"]`)];
    for (const item of this._items) {
      if (!items.includes(item)) restoreElement(this._managed, item);
    }
    this._items = items;

    const listId = ensureId(
      this._managed,
      list,
      this.isCombobox ? "zen-listbox" : "zen-command-menu",
    );
    if (!input.hasAttribute("aria-controls")) {
      setManagedAttribute(this._managed, input, "aria-controls", listId);
    }
    if (this.isCombobox) {
      if (!input.hasAttribute("role")) {
        setManagedAttribute(this._managed, input, "role", "combobox");
      }
      if (!input.hasAttribute("aria-autocomplete")) {
        setManagedAttribute(
          this._managed,
          input,
          "aria-autocomplete",
          "list",
        );
      }
      this.setOpen(this._open);
    }
    this.filter();
  }

  filter() {
    if (!this._input || !this._list) return;
    const query = this._input.value.trim().toLowerCase();
    for (const item of this._items) {
      const originallyHidden =
        originalAttribute(this._managed, item, "hidden") !== null;
      const matches = item.textContent.toLowerCase().includes(query);
      setManagedAttribute(
        this._managed,
        item,
        "hidden",
        originallyHidden || !matches ? "" : null,
      );
    }
    if (this._active?.hidden) this.setActive(null);
  }

  visibleItems() {
    return this._items.filter(item => {
      if (item.hidden || item.closest("[hidden]")) return false;
      if (item.getAttribute("aria-disabled") === "true") return false;
      return !("disabled" in item && item.disabled);
    });
  }

  setActive(item) {
    this._active = item;
    for (const candidate of this._items) {
      if (this.isCombobox) {
        setManagedAttribute(
          this._managed,
          candidate,
          "aria-selected",
          String(candidate === item),
        );
      } else {
        restoreAttribute(this._managed, candidate, "aria-selected");
      }
    }
    if (!this._input) return;
    if (item) {
      setManagedAttribute(
        this._managed,
        this._input,
        "aria-activedescendant",
        ensureId(this._managed, item, "zen-option"),
      );
      item.scrollIntoView?.({ block: "nearest" });
    } else {
      setManagedAttribute(
        this._managed,
        this._input,
        "aria-activedescendant",
        null,
      );
    }
  }

  moveActive(offset) {
    const items = this.visibleItems();
    if (!items.length) {
      this.setActive(null);
      return;
    }
    const current = items.indexOf(this._active);
    const index = current < 0
      ? (offset > 0 ? 0 : items.length - 1)
      : (current + offset + items.length) % items.length;
    this.setActive(items[index]);
  }

  onKeydown(event) {
    if (event.target !== this._input) return;
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      if (this.isCombobox) this.setOpen(true);
      this.moveActive(event.key === "ArrowDown" ? 1 : -1);
      return;
    }
    if (event.key === "Enter") {
      const item = this._active || this.visibleItems()[0];
      if (!item) return;
      event.preventDefault();
      this.choose(item);
    }
  }

  onClick(event) {
    const target = event.target instanceof Element
      ? event.target.closest(`[role="${this.itemRole}"]`)
      : null;
    if (!target || !this._list?.contains(target) || target.hidden) return;
    if (target.getAttribute("aria-disabled") === "true" ||
        ("disabled" in target && target.disabled)) return;
    this.choose(target);
  }

  valueFor(item) {
    return item.dataset.value ?? item.getAttribute("value") ??
      item.textContent.trim();
  }
}

/**
 * Filters and selects native option elements from a text input.
 *
 * @extends {FilterableList}
 */
export class ZenCombobox extends FilterableList {
  get isCombobox() {
    return true;
  }

  get listRole() {
    return "listbox";
  }

  get itemRole() {
    return "option";
  }

  setOpen(open) {
    this._open = Boolean(open);
    if (!this._input || !this._list) return;
    setManagedAttribute(
      this._managed,
      this._input,
      "aria-expanded",
      String(this._open),
    );
    setManagedAttribute(
      this._managed,
      this._list,
      "hidden",
      this._open ? null : "",
    );
    if (!this._open) this.setActive(null);
  }

  onKeydown(event) {
    if (event.target === this._input && event.key === "Escape") {
      if (this._open) event.preventDefault();
      this.setOpen(false);
      return;
    }
    super.onKeydown(event);
  }

  choose(item) {
    const value = this.valueFor(item);
    this._input.value = value;
    this._input.dispatchEvent(new Event("input", {
      bubbles: true,
      composed: true,
    }));
    this._input.dispatchEvent(new Event("change", { bubbles: true }));
    emit(this, "zen-change", { value });
    this.setOpen(false);
  }
}

/**
 * Filters a keyboard-oriented command menu and emits selected commands.
 *
 * @extends {FilterableList}
 */
export class ZenCommand extends FilterableList {
  choose(item) {
    emit(this, "zen-command", { value: this.valueFor(item) });
  }
}

/**
 * Enhances one native one-time-code input with visual slots.
 *
 * @extends {HTMLElement}
 */
export class ZenInputOtp extends HTMLElement {
  connectedCallback() {
    this._managed ||= new Map();
    this._complete ??= false;
    this._onInput ||= event => {
      if (event.target === this._input) this.update(true);
    };
    this.addEventListener("input", this._onInput);
    if (!this._observer) {
      this._observer = new MutationObserver(() => this.sync());
    }
    this._observer.observe(this, {
      attributes: true,
      attributeFilter: ["allow", "maxlength", "pattern"],
      childList: true,
      subtree: true,
    });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    this.removeEventListener("input", this._onInput);
    const input = this._input;
    const complete = this._complete;
    this.release();
    this._previousInput = input;
    this._complete = complete;
  }

  release() {
    restoreAttributes(this._managed);
    if (this._slots && this._originalSlots) {
      this._slots.replaceChildren(...this._originalSlots);
    }
    this._input = null;
    this._slots = null;
    this._originalSlots = null;
    this._complete = false;
  }

  sync() {
    const inputs = [...this.querySelectorAll("input")];
    const input = inputs.length === 1 ? inputs[0] : null;
    const slots = this.querySelector("[data-zen-otp-slots]");
    if (input !== this._input || slots !== this._slots) {
      const remainedComplete =
        (input === this._input || input === this._previousInput) &&
        this._complete;
      this.release();
      this._input = input;
      this._slots = slots;
      this._originalSlots = slots ? [...slots.childNodes] : null;
      this._complete = remainedComplete;
      this._previousInput = null;
    }
    if (!this._input) return;

    if (!this._input.hasAttribute("maxlength")) {
      setManagedAttribute(this._managed, this._input, "maxlength", "6");
    }
    if (this.numericOnly() && !this._input.hasAttribute("inputmode")) {
      setManagedAttribute(this._managed, this._input, "inputmode", "numeric");
    } else if (!this.numericOnly()) {
      restoreAttribute(this._managed, this._input, "inputmode");
    }
    this.update(true);
  }

  numericOnly() {
    return !this.hasAttribute("allow") &&
      !this.hasAttribute("pattern") &&
      !this._input.hasAttribute("allow") &&
      !this._input.hasAttribute("pattern");
  }

  length() {
    const value = Number.parseInt(this._input.getAttribute("maxlength"), 10);
    return Number.isFinite(value) && value > 0 ? value : 6;
  }

  update(notify) {
    const length = this.length();
    const before = this._input.value;
    let value = this.numericOnly() ? before.replace(/\D/g, "") : before;
    value = [...value].slice(0, length).join("");
    if (value !== before) {
      const selection = this._input.selectionStart;
      this._input.value = value;
      if (selection !== null) {
        const preceding = before.slice(0, selection);
        const position = this.numericOnly()
          ? preceding.replace(/\D/g, "").length
          : [...preceding].slice(0, length).join("").length;
        try {
          this._input.setSelectionRange(position, position);
        } catch {
          // Some native input types do not expose text selection.
        }
      }
    }
    this.renderSlots(value, length);

    const complete = [...value].length === length;
    if (notify && complete && !this._complete) {
      emit(this, "zen-complete", { value });
    }
    this._complete = complete;
  }

  renderSlots(value, length) {
    if (!this._slots) return;
    const characters = [...value];
    const current = [...this._slots.children];
    const unchanged = current.length === length &&
      current.every((slot, index) =>
        slot.localName === "span" &&
        slot.textContent === (characters[index] || ""));
    if (unchanged) return;

    const fragment = document.createDocumentFragment();
    for (let index = 0; index < length; index++) {
      const slot = document.createElement("span");
      slot.textContent = characters[index] || "";
      fragment.append(slot);
    }
    this._slots.replaceChildren(fragment);
  }
}

const definitions = {
  "zen-calendar": ZenCalendar,
  "zen-checkbox": ZenCheckbox,
  "zen-combobox": ZenCombobox,
  "zen-command": ZenCommand,
  "zen-date-picker": ZenDatePicker,
  "zen-form": ZenForm,
  "zen-input-otp": ZenInputOtp,
  "zen-radio-group": ZenRadioGroup,
  "zen-select": ZenSelect,
  "zen-slider": ZenSlider,
  "zen-switch": ZenSwitch,
};

if (globalThis.customElements) {
  for (const [name, definition] of Object.entries(definitions)) {
    if (!customElements.get(name)) customElements.define(name, definition);
  }
}