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