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 }