diff design_system/src/components/field.js @ 251:117c4d53c9a4

[ui] Add HTML-first Web Component system Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 09:14:57 -0700
parents
children 2b6e732087ff
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/design_system/src/components/field.js	Tue Aug 04 09:14:57 2026 -0700
@@ -0,0 +1,126 @@
+let nextFieldId = 1;
+
+function uniqueId(base) {
+  let candidate = base;
+  let suffix = 2;
+  while (document.getElementById(candidate)) {
+    candidate = `${base}-${suffix++}`;
+  }
+  return candidate;
+}
+
+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);
+}