Mercurial
view design_system/src/components/field.js @ 272:41a49c29a28f
polish JRPG conversation experience
Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 16:05:29 -0700 |
| parents | 2b6e732087ff |
| 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); }