Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 250:745fd127b2a1 | 251:117c4d53c9a4 |
|---|---|
| 1 let nextFieldId = 1; | |
| 2 | |
| 3 function uniqueId(base) { | |
| 4 let candidate = base; | |
| 5 let suffix = 2; | |
| 6 while (document.getElementById(candidate)) { | |
| 7 candidate = `${base}-${suffix++}`; | |
| 8 } | |
| 9 return candidate; | |
| 10 } | |
| 11 | |
| 12 export class ZenField extends HTMLElement { | |
| 13 connectedCallback() { | |
| 14 if (!this._observer) { | |
| 15 this._observer = new MutationObserver(() => this.sync()); | |
| 16 } | |
| 17 this._observer.observe(this, { | |
| 18 attributes: true, | |
| 19 attributeFilter: ["id"], | |
| 20 childList: true, | |
| 21 subtree: true, | |
| 22 }); | |
| 23 this.sync(); | |
| 24 } | |
| 25 | |
| 26 disconnectedCallback() { | |
| 27 this._observer?.disconnect(); | |
| 28 this.disconnectControl(); | |
| 29 } | |
| 30 | |
| 31 disconnectControl() { | |
| 32 if (!this._control || !this._updateValidity) return; | |
| 33 this._control.removeEventListener("invalid", this._updateValidity); | |
| 34 this._control.removeEventListener("input", this._updateValidity); | |
| 35 this._control.removeEventListener("change", this._updateValidity); | |
| 36 if (this._addedDescription) { | |
| 37 const describedBy = (this._control.getAttribute("aria-describedby") || "") | |
| 38 .split(/\s+/) | |
| 39 .filter(value => value && value !== this._addedDescription); | |
| 40 if (describedBy.length) { | |
| 41 this._control.setAttribute( | |
| 42 "aria-describedby", | |
| 43 describedBy.join(" "), | |
| 44 ); | |
| 45 } else { | |
| 46 this._control.removeAttribute("aria-describedby"); | |
| 47 } | |
| 48 } | |
| 49 if (this._label) { | |
| 50 if (this._originalLabelFor === null) { | |
| 51 this._label.removeAttribute("for"); | |
| 52 } else { | |
| 53 this._label.setAttribute("for", this._originalLabelFor); | |
| 54 } | |
| 55 } | |
| 56 this._control = null; | |
| 57 this._label = null; | |
| 58 this._help = null; | |
| 59 this._controlId = null; | |
| 60 this._helpId = null; | |
| 61 this._addedDescription = null; | |
| 62 this._originalLabelFor = null; | |
| 63 this._updateValidity = null; | |
| 64 this.removeAttribute("data-invalid"); | |
| 65 } | |
| 66 | |
| 67 sync() { | |
| 68 const label = this.querySelector(":scope > label"); | |
| 69 const control = this.querySelector( | |
| 70 ":scope > input, :scope > select, :scope > textarea", | |
| 71 ); | |
| 72 if (!label || !control) { | |
| 73 this.disconnectControl(); | |
| 74 return; | |
| 75 } | |
| 76 const help = this.querySelector(":scope > small, :scope > [data-help]"); | |
| 77 if (control === this._control && | |
| 78 label === this._label && | |
| 79 help === this._help && | |
| 80 control.id === this._controlId && | |
| 81 (help?.id || null) === this._helpId) return; | |
| 82 | |
| 83 this.disconnectControl(); | |
| 84 this._control = control; | |
| 85 this._label = label; | |
| 86 this._help = help; | |
| 87 if (!control.id) { | |
| 88 control.id = uniqueId(`zen-field-${nextFieldId++}`); | |
| 89 } | |
| 90 this._originalLabelFor = label.getAttribute("for"); | |
| 91 label.htmlFor = control.id; | |
| 92 | |
| 93 if (help) { | |
| 94 if (!help.id) help.id = uniqueId(`${control.id}-help`); | |
| 95 const describedBy = new Set( | |
| 96 (control.getAttribute("aria-describedby") || "") | |
| 97 .split(/\s+/) | |
| 98 .filter(Boolean), | |
| 99 ); | |
| 100 if (!describedBy.has(help.id)) { | |
| 101 this._addedDescription = help.id; | |
| 102 } | |
| 103 describedBy.add(help.id); | |
| 104 control.setAttribute( | |
| 105 "aria-describedby", | |
| 106 [...describedBy].join(" "), | |
| 107 ); | |
| 108 } | |
| 109 this._controlId = control.id; | |
| 110 this._helpId = help?.id || null; | |
| 111 | |
| 112 this._updateValidity = () => { | |
| 113 this.toggleAttribute( | |
| 114 "data-invalid", | |
| 115 !control.validity.valid, | |
| 116 ); | |
| 117 }; | |
| 118 control.addEventListener("invalid", this._updateValidity); | |
| 119 control.addEventListener("input", this._updateValidity); | |
| 120 control.addEventListener("change", this._updateValidity); | |
| 121 } | |
| 122 } | |
| 123 | |
| 124 if (!customElements.get("zen-field")) { | |
| 125 customElements.define("zen-field", ZenField); | |
| 126 } |