Mercurial
view design_system/src/components/forms.js @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -0700 |
| parents | 2b6e732087ff |
| children |
line wrap: on
line source
let nextFormId = 1; function uniqueId(prefix) { let id; do id = `${prefix}-${nextFormId++}`; while (document.getElementById(id)); return id; } function rememberAttribute(state, element, name) { let attributes = state.get(element); if (!attributes) { attributes = new Map(); state.set(element, attributes); } if (!attributes.has(name)) { attributes.set(name, element.getAttribute(name)); } } function setManagedAttribute(state, element, name, value) { rememberAttribute(state, element, name); if (value === null) element.removeAttribute(name); else element.setAttribute(name, value); } function restoreElement(state, element) { const attributes = state.get(element); if (!attributes) return; for (const [name, value] of attributes) { if (value === null) element.removeAttribute(name); else element.setAttribute(name, value); } state.delete(element); } function restoreAttribute(state, element, name) { const attributes = state.get(element); if (!attributes?.has(name)) return; const value = attributes.get(name); if (value === null) element.removeAttribute(name); else element.setAttribute(name, value); attributes.delete(name); if (!attributes.size) state.delete(element); } function restoreAttributes(state) { for (const element of [...state.keys()]) restoreElement(state, element); } function originalAttribute(state, element, name) { const attributes = state.get(element); return attributes?.has(name) ? attributes.get(name) : element.getAttribute(name); } function ensureId(state, element, prefix) { const owner = element.id && document.getElementById(element.id); if (!element.id || (owner && owner !== element)) { setManagedAttribute(state, element, "id", uniqueId(prefix)); } return element.id; } function emit(element, type, detail) { element.dispatchEvent(new CustomEvent(type, { bubbles: true, detail, })); } class NativeControl extends HTMLElement { connectedCallback() { if (!this._observer) { this._observer = new MutationObserver(() => this.sync()); } this._observer.observe(this, { childList: true, subtree: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this._control = null; } sync() { this._control = this.querySelector(this.controlSelector); } } /** * Provides presentation around a native checkbox input. * * @extends {NativeControl} */ export class ZenCheckbox extends NativeControl { get controlSelector() { return 'input[type="checkbox"]'; } } /** * Provides presentation around a native select control. * * @extends {NativeControl} */ export class ZenSelect extends NativeControl { get controlSelector() { return "select"; } sync() { super.sync(); if (!this._control) return; let icon = this.querySelector( ":scope > zen-icon[data-zen-select-icon]", ); if (!icon) { icon = document.createElement("zen-icon"); icon.dataset.zenSelectIcon = ""; icon.dataset.zenGenerated = "true"; icon.setAttribute("name", "chevron-down"); this.append(icon); } } disconnectedCallback() { this.querySelector( ':scope > zen-icon[data-zen-select-icon][data-zen-generated="true"]', )?.remove(); super.disconnectedCallback(); } } /** * Provides presentation around a native range input. * * @extends {NativeControl} */ export class ZenSlider extends NativeControl { get controlSelector() { return 'input[type="range"]'; } } function createDate(year, month, day) { const date = new Date(0); date.setHours(0, 0, 0, 0); date.setFullYear(year, month, day); return date; } function parseDate(value) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || ""); if (!match) return null; const year = Number(match[1]); const month = Number(match[2]) - 1; const day = Number(match[3]); const date = createDate(year, month, day); if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null; return date; } function dateKey(date) { const year = String(date.getFullYear()).padStart(4, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function addDays(date, amount) { return createDate( date.getFullYear(), date.getMonth(), date.getDate() + amount, ); } function addMonths(date, amount) { const first = createDate(date.getFullYear(), date.getMonth() + amount, 1); const last = createDate( first.getFullYear(), first.getMonth() + 1, 0, ).getDate(); return createDate( first.getFullYear(), first.getMonth(), Math.min(date.getDate(), last), ); } /** * Renders a tokenized calendar backed by a native date input. * * @extends {HTMLElement} */ export class ZenCalendar extends HTMLElement { connectedCallback() { this._managed ||= new Map(); this._onClick ||= event => this.onClick(event); this._onKeydown ||= event => this.onKeydown(event); this._onInput ||= event => { if (event.target !== this._input) return; this.readInput(); if (this._input.validity.valid) this.setInvalid(false); this.render(); }; this._onInvalid ||= event => { if (event.target !== this._input) return; event.preventDefault(); this.setInvalid(true); queueMicrotask(() => { if (!this.isConnected) return; const target = this._trigger || this._grid?.querySelector('[tabindex="0"]:not(:disabled)'); target?.focus(); }); }; this._onDocumentPointerDown ||= event => { if (this.isPicker && this._open && !this.contains(event.target)) { this.setOpen(false); } }; this._onReset ||= () => queueMicrotask(() => { if (!this.isConnected) return; this.readInput(); this.render(); }); this.addEventListener("click", this._onClick); this.addEventListener("keydown", this._onKeydown); this.addEventListener("input", this._onInput); this.addEventListener("change", this._onInput); this.addEventListener("invalid", this._onInvalid, true); document.addEventListener("pointerdown", this._onDocumentPointerDown); this._observer ||= new MutationObserver(() => this.sync()); this._observer.observe(this, { childList: true }); this._inputObserver ||= new MutationObserver(() => { this.readInput(); this.render(); }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this._inputObserver?.disconnect(); this._form?.removeEventListener("reset", this._onReset); this.removeEventListener("click", this._onClick); this.removeEventListener("keydown", this._onKeydown); this.removeEventListener("input", this._onInput); this.removeEventListener("change", this._onInput); this.removeEventListener("invalid", this._onInvalid, true); document.removeEventListener("pointerdown", this._onDocumentPointerDown); this.release(); } get isPicker() { return this.localName === "zen-date-picker"; } sync() { const input = [...this.children].find( child => child.matches?.('input[type="date"]'), ) || null; if (input === this._input) return; this.release(); this._input = input; if (!input) return; setManagedAttribute( this._managed, input, "data-zen-calendar-input", "", ); setManagedAttribute(this._managed, input, "tabindex", "-1"); setManagedAttribute(this._managed, input, "aria-hidden", "true"); this._inputObserver.observe(input, { attributes: true, attributeFilter: ["disabled", "max", "min", "readonly", "value"], }); this._form = input.form; this._form?.addEventListener("reset", this._onReset); this.readInput(); this.createView(); this.render(); } release() { this._inputObserver?.disconnect(); this._form?.removeEventListener("reset", this._onReset); this._generated?.remove(); this._generated = null; this._view = null; this._grid = null; this._heading = null; this._trigger = null; this._panel = null; this._labelText = ""; this._labelIds = []; this._form = null; this._open = false; restoreAttributes(this._managed || new Map()); this._input = null; } readInput() { const selected = parseDate(this._input?.value); const today = new Date(); this._selected = selected; this._focusDate = selected || this._focusDate || today; this._month = createDate( (selected || this._focusDate).getFullYear(), (selected || this._focusDate).getMonth(), 1, ); } limits() { return { maximum: parseDate(this._input?.max), minimum: parseDate(this._input?.min), }; } isEditable() { return Boolean(this._input) && !this._input.disabled && !this._input.readOnly; } isAllowed(date) { if (!date) return false; const { maximum, minimum } = this.limits(); return (!minimum || date >= minimum) && (!maximum || date <= maximum); } clampDate(date) { const { maximum, minimum } = this.limits(); if (minimum && date < minimum) return minimum; if (maximum && date > maximum) return maximum; return date; } setInvalid(invalid) { setManagedAttribute( this._managed, this, "data-invalid", invalid ? "" : null, ); if (this._trigger) { this._trigger.setAttribute("aria-invalid", String(invalid)); } if (this._grid) { this._grid.setAttribute("aria-invalid", String(invalid)); } } createIcon(name) { const icon = document.createElement("zen-icon"); icon.setAttribute("name", name); return icon; } createControl(name, label) { const button = document.createElement("button"); button.type = "button"; button.dataset[name] = ""; button.setAttribute("aria-label", label); return button; } createView() { const generated = document.createElement("div"); generated.dataset.zenGenerated = "calendar"; this._generated = generated; const labels = [...this._input.labels]; const authoredLabelIds = (this._input.getAttribute("aria-labelledby") || "") .split(/\s+/) .filter(id => id && document.getElementById(id)); const associatedLabelIds = labels.map(label => ensureId(this._managed, label, "zen-date-label") ); this._labelIds = authoredLabelIds.length ? authoredLabelIds : associatedLabelIds; this._labelText = this._labelIds.map(id => document.getElementById(id)?.textContent.trim() ) .filter(Boolean) .join(", ") || labels.map(label => label.textContent.trim()) .filter(Boolean) .join(", ") || this._input.getAttribute("aria-label") || "Date"; if (this.isPicker) { const trigger = this.createControl( "zenDateTrigger", "Choose date", ); trigger.id = uniqueId("zen-date-picker-trigger"); trigger.append(this.createIcon("calendar")); const triggerText = document.createElement("span"); triggerText.dataset.zenDateValue = ""; triggerText.id = uniqueId("zen-date-picker-value"); trigger.append(triggerText); this._trigger = trigger; const panel = document.createElement("div"); panel.dataset.zenCalendarPanel = ""; panel.id = uniqueId("zen-date-picker-panel"); panel.setAttribute("role", "dialog"); panel.setAttribute("aria-label", `${this._labelText} calendar`); panel.hidden = true; trigger.setAttribute("aria-haspopup", "dialog"); trigger.setAttribute("aria-controls", panel.id); if (this._input.hasAttribute("aria-describedby")) { trigger.setAttribute( "aria-describedby", this._input.getAttribute("aria-describedby"), ); } if (this._labelIds.length) { trigger.setAttribute( "aria-labelledby", [...this._labelIds, triggerText.id].join(" "), ); } for (const label of labels) { if (label.htmlFor === this._input.id) { setManagedAttribute(this._managed, label, "for", trigger.id); } } this._panel = panel; generated.append(trigger, panel); } const view = document.createElement("div"); view.dataset.zenCalendarView = ""; const header = document.createElement("header"); const previous = this.createControl( "zenCalendarPrevious", "Previous month", ); previous.append(this.createIcon("chevron-left")); const heading = document.createElement("strong"); heading.setAttribute("aria-live", "polite"); const next = this.createControl("zenCalendarNext", "Next month"); next.append(this.createIcon("chevron-right")); header.append(previous, heading, next); const weekdays = document.createElement("div"); weekdays.dataset.zenCalendarWeekdays = ""; weekdays.setAttribute("aria-hidden", "true"); const formatter = new Intl.DateTimeFormat(undefined, { weekday: "narrow", }); for (let day = 4; day < 11; day++) { const label = document.createElement("span"); label.textContent = formatter.format(new Date(2026, 0, day)); weekdays.append(label); } const grid = document.createElement("div"); grid.dataset.zenCalendarGrid = ""; grid.setAttribute("role", "grid"); grid.setAttribute( "aria-label", this._labelText ? `${this._labelText} calendar` : "Calendar", ); view.append(header, weekdays, grid); this._view = view; this._heading = heading; this._grid = grid; (this._panel || generated).append(view); this.append(generated); if (this.isPicker) this.setOpen(false); } render() { if (!this._grid || !this._month) return; this._focusDate = this.clampDate(this._focusDate); this._heading.textContent = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric", }).format(this._month); if (this._trigger) { const text = this._trigger.querySelector("[data-zen-date-value]"); text.textContent = this._selected ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", }).format(this._selected) : "Choose date"; if (this._labelIds.length) { this._trigger.removeAttribute("aria-label"); } else { this._trigger.setAttribute( "aria-label", `${this._labelText}, ${text.textContent}`, ); } this._trigger.disabled = !this.isEditable(); } for (const control of this._view.querySelectorAll( "[data-zen-calendar-previous], [data-zen-calendar-next]", )) { control.disabled = !this.isEditable(); } const first = createDate( this._month.getFullYear(), this._month.getMonth(), 1, ); const start = addDays(first, -first.getDay()); const { maximum, minimum } = this.limits(); const today = dateKey(new Date()); const selected = this._selected ? dateKey(this._selected) : ""; const focused = dateKey(this._focusDate); const fragment = document.createDocumentFragment(); const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "full", }); for (let week = 0; week < 6; week++) { const row = document.createElement("div"); row.setAttribute("role", "row"); for (let day = 0; day < 7; day++) { const date = addDays(start, week * 7 + day); const key = dateKey(date); const button = document.createElement("button"); button.type = "button"; button.dataset.zenCalendarDay = key; button.setAttribute("role", "gridcell"); button.setAttribute("aria-label", formatter.format(date)); button.setAttribute("aria-selected", String(key === selected)); const disabled = !this.isEditable() || Boolean((minimum && date < minimum) || (maximum && date > maximum)); button.tabIndex = key === focused && !disabled ? 0 : -1; button.textContent = String(date.getDate()); button.toggleAttribute( "data-outside", date.getMonth() !== this._month.getMonth(), ); button.toggleAttribute("data-today", key === today); button.disabled = disabled; row.append(button); } fragment.append(row); } this._grid.replaceChildren(fragment); } onClick(event) { const target = event.target instanceof Element ? event.target : null; if (!target) return; if (target.closest("[data-zen-date-trigger]") === this._trigger) { if (!this.isEditable()) return; this.setOpen(!this._open); return; } if (target.closest("[data-zen-calendar-previous]")) { if (!this.isEditable()) return; this.changeMonth(-1); return; } if (target.closest("[data-zen-calendar-next]")) { if (!this.isEditable()) return; this.changeMonth(1); return; } const day = target.closest("[data-zen-calendar-day]"); if (day && !day.disabled) this.selectDate(parseDate(day.dataset.zenCalendarDay)); } onKeydown(event) { if (event.key === "Escape" && this.isPicker && this._open) { event.preventDefault(); this.setOpen(false); this._trigger.focus(); return; } const day = event.target instanceof Element ? event.target.closest("[data-zen-calendar-day]") : null; if (!day) return; const current = parseDate(day.dataset.zenCalendarDay); let next = null; if (event.key === "ArrowLeft") next = addDays(current, -1); else if (event.key === "ArrowRight") next = addDays(current, 1); else if (event.key === "ArrowUp") next = addDays(current, -7); else if (event.key === "ArrowDown") next = addDays(current, 7); else if (event.key === "Home") next = addDays(current, -current.getDay()); else if (event.key === "End") next = addDays(current, 6 - current.getDay()); else if (event.key === "PageUp") next = addMonths(current, -1); else if (event.key === "PageDown") next = addMonths(current, 1); if (!next) return; next = this.clampDate(next); event.preventDefault(); this._focusDate = next; this._month = createDate(next.getFullYear(), next.getMonth(), 1); this.render(); this._grid.querySelector( `[data-zen-calendar-day="${dateKey(next)}"]`, )?.focus(); } changeMonth(amount) { if (!this.isEditable()) return; this._month = addMonths(this._month, amount); this._focusDate = this.clampDate(createDate( this._month.getFullYear(), this._month.getMonth(), 1, )); this._month = createDate( this._focusDate.getFullYear(), this._focusDate.getMonth(), 1, ); this.render(); this._grid.querySelector('[tabindex="0"]')?.focus(); } selectDate(date) { if (!this.isEditable() || !this.isAllowed(date)) return; this._selected = date; this._focusDate = date; this._month = createDate(date.getFullYear(), date.getMonth(), 1); this._input.value = dateKey(date); this._input.dispatchEvent(new Event("input", { bubbles: true, composed: true, })); this._input.dispatchEvent(new Event("change", { bubbles: true })); emit(this, "zen-change", { value: this._input.value }); this.render(); if (this.isPicker) { this.setOpen(false); this._trigger.focus(); } } setOpen(open) { if (!this.isPicker || !this._panel || !this._trigger) return; this._open = Boolean(open) && this.isEditable(); this._panel.hidden = !this._open; this._trigger.setAttribute("aria-expanded", String(this._open)); if (this._open) { this._grid.querySelector('[tabindex="0"]')?.focus(); } } } /** * Presents the first-party calendar behind a tokenized disclosure trigger. * * @extends {ZenCalendar} */ export class ZenDatePicker extends ZenCalendar {} /** * Adds radiogroup semantics when no native fieldset owns the radios. * * @extends {HTMLElement} */ export class ZenRadioGroup extends HTMLElement { connectedCallback() { this._managed ||= new Map(); if (!this._observer) { this._observer = new MutationObserver(() => this.sync()); } this._observer.observe(this, { childList: true, subtree: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); restoreAttributes(this._managed); } sync() { const radios = this.querySelectorAll('input[type="radio"]'); const needsRole = radios.length > 0 && !this.querySelector("fieldset"); if (needsRole && !this.hasAttribute("role")) { setManagedAttribute(this._managed, this, "role", "radiogroup"); } else if (!needsRole) { restoreElement(this._managed, this); } } } /** * Synchronizes switch semantics onto a native checkbox. * * @extends {HTMLElement} */ export class ZenSwitch extends HTMLElement { connectedCallback() { this._managed ||= new Map(); this._onStateChange ||= event => { if (event.target === this._control) this.updateState(); }; this._onReset ||= () => { queueMicrotask(() => { if (this.isConnected) this.updateState(); }); }; this.addEventListener("input", this._onStateChange); this.addEventListener("change", this._onStateChange); if (!this._observer) { this._observer = new MutationObserver(() => this.sync()); } this._observer.observe(this, { childList: true, subtree: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("input", this._onStateChange); this.removeEventListener("change", this._onStateChange); this._form?.removeEventListener("reset", this._onReset); restoreAttributes(this._managed); this._control = null; this._form = null; } sync() { const control = this.querySelector('input[type="checkbox"]'); const form = control?.form || null; if (control !== this._control) { if (this._control) restoreElement(this._managed, this._control); this._control = control; } if (form !== this._form) { this._form?.removeEventListener("reset", this._onReset); this._form = form; form?.addEventListener("reset", this._onReset); } this.updateState(); } updateState() { if (!this._control) return; setManagedAttribute(this._managed, this._control, "role", "switch"); setManagedAttribute( this._managed, this._control, "aria-checked", String(this._control.checked), ); } } /** * Reflects native form validity on the component boundary. * * @extends {HTMLElement} */ export class ZenForm extends HTMLElement { connectedCallback() { this._managed ||= new Map(); this._syncValidity ||= () => { if (!this._form) return; setManagedAttribute( this._managed, this, "data-invalid", this._form.checkValidity() ? null : "", ); }; this._markInvalid ||= () => { setManagedAttribute(this._managed, this, "data-invalid", ""); }; this._onReset ||= () => queueMicrotask(this._syncValidity); if (!this._observer) { this._observer = new MutationObserver(() => this.sync()); } this._observer.observe(this, { childList: true, subtree: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.releaseForm(); restoreAttributes(this._managed); } releaseForm() { this._form?.removeEventListener("submit", this._syncValidity); this._form?.removeEventListener("input", this._syncValidity); this._form?.removeEventListener("invalid", this._markInvalid, true); this._form?.removeEventListener("reset", this._onReset); this._form = null; } sync() { const form = this.querySelector("form"); if (form === this._form) return; this.releaseForm(); restoreElement(this._managed, this); this._form = form; form?.addEventListener("submit", this._syncValidity); form?.addEventListener("input", this._syncValidity); form?.addEventListener("invalid", this._markInvalid, true); form?.addEventListener("reset", this._onReset); } } class FilterableList extends HTMLElement { connectedCallback() { this._managed ||= new Map(); this._active = null; this._onInput ||= event => { if (event.target !== this._input) return; if (this.isCombobox) this.setOpen(true); this.filter(); }; this._onKeydown ||= event => this.onKeydown(event); this._onClick ||= event => this.onClick(event); this._onFocus ||= event => { if (this.isCombobox && event.target === this._input) { this.setOpen(true); this.filter(); } }; this.addEventListener("input", this._onInput); this.addEventListener("keydown", this._onKeydown); this.addEventListener("click", this._onClick); this.addEventListener("focusin", this._onFocus); if (!this._observer) { this._observer = new MutationObserver(() => this.sync()); } this._observer.observe(this, { childList: true, subtree: true }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("input", this._onInput); this.removeEventListener("keydown", this._onKeydown); this.removeEventListener("click", this._onClick); this.removeEventListener("focusin", this._onFocus); restoreAttributes(this._managed); this._input = null; this._list = null; this._items = []; this._active = null; } get isCombobox() { return false; } get listRole() { return "menu"; } get itemRole() { return "menuitem"; } sync() { const input = this.querySelector("input"); const list = this.querySelector(`[role="${this.listRole}"]`); if (input !== this._input || list !== this._list) { restoreAttributes(this._managed); this._input = input; this._list = list; this._items = []; this._active = null; this._open = false; } if (!input || !list) return; const items = [...list.querySelectorAll(`[role="${this.itemRole}"]`)]; for (const item of this._items) { if (!items.includes(item)) restoreElement(this._managed, item); } this._items = items; const listId = ensureId( this._managed, list, this.isCombobox ? "zen-listbox" : "zen-command-menu", ); if (!input.hasAttribute("aria-controls")) { setManagedAttribute(this._managed, input, "aria-controls", listId); } if (this.isCombobox) { if (!input.hasAttribute("role")) { setManagedAttribute(this._managed, input, "role", "combobox"); } if (!input.hasAttribute("aria-autocomplete")) { setManagedAttribute( this._managed, input, "aria-autocomplete", "list", ); } this.setOpen(this._open); } this.filter(); } filter() { if (!this._input || !this._list) return; const query = this._input.value.trim().toLowerCase(); for (const item of this._items) { const originallyHidden = originalAttribute(this._managed, item, "hidden") !== null; const matches = item.textContent.toLowerCase().includes(query); setManagedAttribute( this._managed, item, "hidden", originallyHidden || !matches ? "" : null, ); } if (this._active?.hidden) this.setActive(null); } visibleItems() { return this._items.filter(item => { if (item.hidden || item.closest("[hidden]")) return false; if (item.getAttribute("aria-disabled") === "true") return false; return !("disabled" in item && item.disabled); }); } setActive(item) { this._active = item; for (const candidate of this._items) { if (this.isCombobox) { setManagedAttribute( this._managed, candidate, "aria-selected", String(candidate === item), ); } else { restoreAttribute(this._managed, candidate, "aria-selected"); } } if (!this._input) return; if (item) { setManagedAttribute( this._managed, this._input, "aria-activedescendant", ensureId(this._managed, item, "zen-option"), ); item.scrollIntoView?.({ block: "nearest" }); } else { setManagedAttribute( this._managed, this._input, "aria-activedescendant", null, ); } } moveActive(offset) { const items = this.visibleItems(); if (!items.length) { this.setActive(null); return; } const current = items.indexOf(this._active); const index = current < 0 ? (offset > 0 ? 0 : items.length - 1) : (current + offset + items.length) % items.length; this.setActive(items[index]); } onKeydown(event) { if (event.target !== this._input) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); if (this.isCombobox) this.setOpen(true); this.moveActive(event.key === "ArrowDown" ? 1 : -1); return; } if (event.key === "Enter") { const item = this._active || this.visibleItems()[0]; if (!item) return; event.preventDefault(); this.choose(item); } } onClick(event) { const target = event.target instanceof Element ? event.target.closest(`[role="${this.itemRole}"]`) : null; if (!target || !this._list?.contains(target) || target.hidden) return; if (target.getAttribute("aria-disabled") === "true" || ("disabled" in target && target.disabled)) return; this.choose(target); } valueFor(item) { return item.dataset.value ?? item.getAttribute("value") ?? item.textContent.trim(); } } /** * Filters and selects native option elements from a text input. * * @extends {FilterableList} */ export class ZenCombobox extends FilterableList { get isCombobox() { return true; } get listRole() { return "listbox"; } get itemRole() { return "option"; } setOpen(open) { this._open = Boolean(open); if (!this._input || !this._list) return; setManagedAttribute( this._managed, this._input, "aria-expanded", String(this._open), ); setManagedAttribute( this._managed, this._list, "hidden", this._open ? null : "", ); if (!this._open) this.setActive(null); } onKeydown(event) { if (event.target === this._input && event.key === "Escape") { if (this._open) event.preventDefault(); this.setOpen(false); return; } super.onKeydown(event); } choose(item) { const value = this.valueFor(item); this._input.value = value; this._input.dispatchEvent(new Event("input", { bubbles: true, composed: true, })); this._input.dispatchEvent(new Event("change", { bubbles: true })); emit(this, "zen-change", { value }); this.setOpen(false); } } /** * Filters a keyboard-oriented command menu and emits selected commands. * * @extends {FilterableList} */ export class ZenCommand extends FilterableList { choose(item) { emit(this, "zen-command", { value: this.valueFor(item) }); } } /** * Enhances one native one-time-code input with visual slots. * * @extends {HTMLElement} */ export class ZenInputOtp extends HTMLElement { connectedCallback() { this._managed ||= new Map(); this._complete ??= false; this._onInput ||= event => { if (event.target === this._input) this.update(true); }; this.addEventListener("input", this._onInput); if (!this._observer) { this._observer = new MutationObserver(() => this.sync()); } this._observer.observe(this, { attributes: true, attributeFilter: ["allow", "maxlength", "pattern"], childList: true, subtree: true, }); this.sync(); } disconnectedCallback() { this._observer?.disconnect(); this.removeEventListener("input", this._onInput); const input = this._input; const complete = this._complete; this.release(); this._previousInput = input; this._complete = complete; } release() { restoreAttributes(this._managed); if (this._slots && this._originalSlots) { this._slots.replaceChildren(...this._originalSlots); } this._input = null; this._slots = null; this._originalSlots = null; this._complete = false; } sync() { const inputs = [...this.querySelectorAll("input")]; const input = inputs.length === 1 ? inputs[0] : null; const slots = this.querySelector("[data-zen-otp-slots]"); if (input !== this._input || slots !== this._slots) { const remainedComplete = (input === this._input || input === this._previousInput) && this._complete; this.release(); this._input = input; this._slots = slots; this._originalSlots = slots ? [...slots.childNodes] : null; this._complete = remainedComplete; this._previousInput = null; } if (!this._input) return; if (!this._input.hasAttribute("maxlength")) { setManagedAttribute(this._managed, this._input, "maxlength", "6"); } if (this.numericOnly() && !this._input.hasAttribute("inputmode")) { setManagedAttribute(this._managed, this._input, "inputmode", "numeric"); } else if (!this.numericOnly()) { restoreAttribute(this._managed, this._input, "inputmode"); } this.update(true); } numericOnly() { return !this.hasAttribute("allow") && !this.hasAttribute("pattern") && !this._input.hasAttribute("allow") && !this._input.hasAttribute("pattern"); } length() { const value = Number.parseInt(this._input.getAttribute("maxlength"), 10); return Number.isFinite(value) && value > 0 ? value : 6; } update(notify) { const length = this.length(); const before = this._input.value; let value = this.numericOnly() ? before.replace(/\D/g, "") : before; value = [...value].slice(0, length).join(""); if (value !== before) { const selection = this._input.selectionStart; this._input.value = value; if (selection !== null) { const preceding = before.slice(0, selection); const position = this.numericOnly() ? preceding.replace(/\D/g, "").length : [...preceding].slice(0, length).join("").length; try { this._input.setSelectionRange(position, position); } catch { // Some native input types do not expose text selection. } } } this.renderSlots(value, length); const complete = [...value].length === length; if (notify && complete && !this._complete) { emit(this, "zen-complete", { value }); } this._complete = complete; } renderSlots(value, length) { if (!this._slots) return; const characters = [...value]; const current = [...this._slots.children]; const unchanged = current.length === length && current.every((slot, index) => slot.localName === "span" && slot.textContent === (characters[index] || "")); if (unchanged) return; const fragment = document.createDocumentFragment(); for (let index = 0; index < length; index++) { const slot = document.createElement("span"); slot.textContent = characters[index] || ""; fragment.append(slot); } this._slots.replaceChildren(fragment); } } const definitions = { "zen-calendar": ZenCalendar, "zen-checkbox": ZenCheckbox, "zen-combobox": ZenCombobox, "zen-command": ZenCommand, "zen-date-picker": ZenDatePicker, "zen-form": ZenForm, "zen-input-otp": ZenInputOtp, "zen-radio-group": ZenRadioGroup, "zen-select": ZenSelect, "zen-slider": ZenSlider, "zen-switch": ZenSwitch, }; if (globalThis.customElements) { for (const [name, definition] of Object.entries(definitions)) { if (!customElements.get(name)) customElements.define(name, definition); } }