Mercurial
diff design_system/src/components/forms.js @ 254:2b6e732087ff
[ui] Add complete native component catalog
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 04 Aug 2026 15:12:09 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/forms.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,1240 @@ +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); + } +}