Mercurial
view design_system/src/components/forms.js @ 268:f7b1188d5fb1
support repo rules Copilot bundle path
Match both +_repo_rules2+ and +http_archive+ Copilot CLI repository layouts during deployment and production startup.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 13:02:41 -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); } }