view design_system/src/components/field.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 117c4d53c9a4
children
line wrap: on
line source

let nextFieldId = 1;

function uniqueId(base) {
  let candidate = base;
  let suffix = 2;
  while (document.getElementById(candidate)) {
    candidate = `${base}-${suffix++}`;
  }
  return candidate;
}

/**
 * Associates a label, native form control, and descriptive help text.
 *
 * @extends {HTMLElement}
 */
export class ZenField extends HTMLElement {
  connectedCallback() {
    if (!this._observer) {
      this._observer = new MutationObserver(() => this.sync());
    }
    this._observer.observe(this, {
      attributes: true,
      attributeFilter: ["id"],
      childList: true,
      subtree: true,
    });
    this.sync();
  }

  disconnectedCallback() {
    this._observer?.disconnect();
    this.disconnectControl();
  }

  disconnectControl() {
    if (!this._control || !this._updateValidity) return;
    this._control.removeEventListener("invalid", this._updateValidity);
    this._control.removeEventListener("input", this._updateValidity);
    this._control.removeEventListener("change", this._updateValidity);
    if (this._addedDescription) {
      const describedBy = (this._control.getAttribute("aria-describedby") || "")
        .split(/\s+/)
        .filter(value => value && value !== this._addedDescription);
      if (describedBy.length) {
        this._control.setAttribute(
          "aria-describedby",
          describedBy.join(" "),
        );
      } else {
        this._control.removeAttribute("aria-describedby");
      }
    }
    if (this._label) {
      if (this._originalLabelFor === null) {
        this._label.removeAttribute("for");
      } else {
        this._label.setAttribute("for", this._originalLabelFor);
      }
    }
    this._control = null;
    this._label = null;
    this._help = null;
    this._controlId = null;
    this._helpId = null;
    this._addedDescription = null;
    this._originalLabelFor = null;
    this._updateValidity = null;
    this.removeAttribute("data-invalid");
  }

  sync() {
    const label = this.querySelector(":scope > label");
    const control = this.querySelector(
      ":scope > input, :scope > select, :scope > textarea",
    );
    if (!label || !control) {
      this.disconnectControl();
      return;
    }
    const help = this.querySelector(":scope > small, :scope > [data-help]");
    if (control === this._control &&
        label === this._label &&
        help === this._help &&
        control.id === this._controlId &&
        (help?.id || null) === this._helpId) return;

    this.disconnectControl();
    this._control = control;
    this._label = label;
    this._help = help;
    if (!control.id) {
      control.id = uniqueId(`zen-field-${nextFieldId++}`);
    }
    this._originalLabelFor = label.getAttribute("for");
    label.htmlFor = control.id;

    if (help) {
      if (!help.id) help.id = uniqueId(`${control.id}-help`);
      const describedBy = new Set(
        (control.getAttribute("aria-describedby") || "")
          .split(/\s+/)
          .filter(Boolean),
      );
      if (!describedBy.has(help.id)) {
        this._addedDescription = help.id;
      }
      describedBy.add(help.id);
      control.setAttribute(
        "aria-describedby",
        [...describedBy].join(" "),
      );
    }
    this._controlId = control.id;
    this._helpId = help?.id || null;

    this._updateValidity = () => {
      this.toggleAttribute(
        "data-invalid",
        !control.validity.valid,
      );
    };
    control.addEventListener("invalid", this._updateValidity);
    control.addEventListener("input", this._updateValidity);
    control.addEventListener("change", this._updateValidity);
  }
}

if (!customElements.get("zen-field")) {
  customElements.define("zen-field", ZenField);
}