comparison 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
comparison
equal deleted inserted replaced
253:fdf3816959cb 254:2b6e732087ff
1 let nextFormId = 1;
2
3 function uniqueId(prefix) {
4 let id;
5 do id = `${prefix}-${nextFormId++}`;
6 while (document.getElementById(id));
7 return id;
8 }
9
10 function rememberAttribute(state, element, name) {
11 let attributes = state.get(element);
12 if (!attributes) {
13 attributes = new Map();
14 state.set(element, attributes);
15 }
16 if (!attributes.has(name)) {
17 attributes.set(name, element.getAttribute(name));
18 }
19 }
20
21 function setManagedAttribute(state, element, name, value) {
22 rememberAttribute(state, element, name);
23 if (value === null) element.removeAttribute(name);
24 else element.setAttribute(name, value);
25 }
26
27 function restoreElement(state, element) {
28 const attributes = state.get(element);
29 if (!attributes) return;
30 for (const [name, value] of attributes) {
31 if (value === null) element.removeAttribute(name);
32 else element.setAttribute(name, value);
33 }
34 state.delete(element);
35 }
36
37 function restoreAttribute(state, element, name) {
38 const attributes = state.get(element);
39 if (!attributes?.has(name)) return;
40 const value = attributes.get(name);
41 if (value === null) element.removeAttribute(name);
42 else element.setAttribute(name, value);
43 attributes.delete(name);
44 if (!attributes.size) state.delete(element);
45 }
46
47 function restoreAttributes(state) {
48 for (const element of [...state.keys()]) restoreElement(state, element);
49 }
50
51 function originalAttribute(state, element, name) {
52 const attributes = state.get(element);
53 return attributes?.has(name)
54 ? attributes.get(name)
55 : element.getAttribute(name);
56 }
57
58 function ensureId(state, element, prefix) {
59 const owner = element.id && document.getElementById(element.id);
60 if (!element.id || (owner && owner !== element)) {
61 setManagedAttribute(state, element, "id", uniqueId(prefix));
62 }
63 return element.id;
64 }
65
66 function emit(element, type, detail) {
67 element.dispatchEvent(new CustomEvent(type, {
68 bubbles: true,
69 detail,
70 }));
71 }
72
73 class NativeControl extends HTMLElement {
74 connectedCallback() {
75 if (!this._observer) {
76 this._observer = new MutationObserver(() => this.sync());
77 }
78 this._observer.observe(this, { childList: true, subtree: true });
79 this.sync();
80 }
81
82 disconnectedCallback() {
83 this._observer?.disconnect();
84 this._control = null;
85 }
86
87 sync() {
88 this._control = this.querySelector(this.controlSelector);
89 }
90 }
91
92 /**
93 * Provides presentation around a native checkbox input.
94 *
95 * @extends {NativeControl}
96 */
97 export class ZenCheckbox extends NativeControl {
98 get controlSelector() {
99 return 'input[type="checkbox"]';
100 }
101 }
102
103 /**
104 * Provides presentation around a native select control.
105 *
106 * @extends {NativeControl}
107 */
108 export class ZenSelect extends NativeControl {
109 get controlSelector() {
110 return "select";
111 }
112
113 sync() {
114 super.sync();
115 if (!this._control) return;
116 let icon = this.querySelector(
117 ":scope > zen-icon[data-zen-select-icon]",
118 );
119 if (!icon) {
120 icon = document.createElement("zen-icon");
121 icon.dataset.zenSelectIcon = "";
122 icon.dataset.zenGenerated = "true";
123 icon.setAttribute("name", "chevron-down");
124 this.append(icon);
125 }
126 }
127
128 disconnectedCallback() {
129 this.querySelector(
130 ':scope > zen-icon[data-zen-select-icon][data-zen-generated="true"]',
131 )?.remove();
132 super.disconnectedCallback();
133 }
134 }
135
136 /**
137 * Provides presentation around a native range input.
138 *
139 * @extends {NativeControl}
140 */
141 export class ZenSlider extends NativeControl {
142 get controlSelector() {
143 return 'input[type="range"]';
144 }
145 }
146
147 function createDate(year, month, day) {
148 const date = new Date(0);
149 date.setHours(0, 0, 0, 0);
150 date.setFullYear(year, month, day);
151 return date;
152 }
153
154 function parseDate(value) {
155 const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
156 if (!match) return null;
157 const year = Number(match[1]);
158 const month = Number(match[2]) - 1;
159 const day = Number(match[3]);
160 const date = createDate(year, month, day);
161 if (date.getFullYear() !== year ||
162 date.getMonth() !== month ||
163 date.getDate() !== day) return null;
164 return date;
165 }
166
167 function dateKey(date) {
168 const year = String(date.getFullYear()).padStart(4, "0");
169 const month = String(date.getMonth() + 1).padStart(2, "0");
170 const day = String(date.getDate()).padStart(2, "0");
171 return `${year}-${month}-${day}`;
172 }
173
174 function addDays(date, amount) {
175 return createDate(
176 date.getFullYear(),
177 date.getMonth(),
178 date.getDate() + amount,
179 );
180 }
181
182 function addMonths(date, amount) {
183 const first = createDate(date.getFullYear(), date.getMonth() + amount, 1);
184 const last = createDate(
185 first.getFullYear(),
186 first.getMonth() + 1,
187 0,
188 ).getDate();
189 return createDate(
190 first.getFullYear(),
191 first.getMonth(),
192 Math.min(date.getDate(), last),
193 );
194 }
195
196 /**
197 * Renders a tokenized calendar backed by a native date input.
198 *
199 * @extends {HTMLElement}
200 */
201 export class ZenCalendar extends HTMLElement {
202 connectedCallback() {
203 this._managed ||= new Map();
204 this._onClick ||= event => this.onClick(event);
205 this._onKeydown ||= event => this.onKeydown(event);
206 this._onInput ||= event => {
207 if (event.target !== this._input) return;
208 this.readInput();
209 if (this._input.validity.valid) this.setInvalid(false);
210 this.render();
211 };
212 this._onInvalid ||= event => {
213 if (event.target !== this._input) return;
214 event.preventDefault();
215 this.setInvalid(true);
216 queueMicrotask(() => {
217 if (!this.isConnected) return;
218 const target = this._trigger ||
219 this._grid?.querySelector('[tabindex="0"]:not(:disabled)');
220 target?.focus();
221 });
222 };
223 this._onDocumentPointerDown ||= event => {
224 if (this.isPicker && this._open && !this.contains(event.target)) {
225 this.setOpen(false);
226 }
227 };
228 this._onReset ||= () => queueMicrotask(() => {
229 if (!this.isConnected) return;
230 this.readInput();
231 this.render();
232 });
233 this.addEventListener("click", this._onClick);
234 this.addEventListener("keydown", this._onKeydown);
235 this.addEventListener("input", this._onInput);
236 this.addEventListener("change", this._onInput);
237 this.addEventListener("invalid", this._onInvalid, true);
238 document.addEventListener("pointerdown", this._onDocumentPointerDown);
239 this._observer ||= new MutationObserver(() => this.sync());
240 this._observer.observe(this, { childList: true });
241 this._inputObserver ||= new MutationObserver(() => {
242 this.readInput();
243 this.render();
244 });
245 this.sync();
246 }
247
248 disconnectedCallback() {
249 this._observer?.disconnect();
250 this._inputObserver?.disconnect();
251 this._form?.removeEventListener("reset", this._onReset);
252 this.removeEventListener("click", this._onClick);
253 this.removeEventListener("keydown", this._onKeydown);
254 this.removeEventListener("input", this._onInput);
255 this.removeEventListener("change", this._onInput);
256 this.removeEventListener("invalid", this._onInvalid, true);
257 document.removeEventListener("pointerdown", this._onDocumentPointerDown);
258 this.release();
259 }
260
261 get isPicker() {
262 return this.localName === "zen-date-picker";
263 }
264
265 sync() {
266 const input = [...this.children].find(
267 child => child.matches?.('input[type="date"]'),
268 ) || null;
269 if (input === this._input) return;
270 this.release();
271 this._input = input;
272 if (!input) return;
273
274 setManagedAttribute(
275 this._managed,
276 input,
277 "data-zen-calendar-input",
278 "",
279 );
280 setManagedAttribute(this._managed, input, "tabindex", "-1");
281 setManagedAttribute(this._managed, input, "aria-hidden", "true");
282 this._inputObserver.observe(input, {
283 attributes: true,
284 attributeFilter: ["disabled", "max", "min", "readonly", "value"],
285 });
286 this._form = input.form;
287 this._form?.addEventListener("reset", this._onReset);
288 this.readInput();
289 this.createView();
290 this.render();
291 }
292
293 release() {
294 this._inputObserver?.disconnect();
295 this._form?.removeEventListener("reset", this._onReset);
296 this._generated?.remove();
297 this._generated = null;
298 this._view = null;
299 this._grid = null;
300 this._heading = null;
301 this._trigger = null;
302 this._panel = null;
303 this._labelText = "";
304 this._labelIds = [];
305 this._form = null;
306 this._open = false;
307 restoreAttributes(this._managed || new Map());
308 this._input = null;
309 }
310
311 readInput() {
312 const selected = parseDate(this._input?.value);
313 const today = new Date();
314 this._selected = selected;
315 this._focusDate = selected || this._focusDate || today;
316 this._month = createDate(
317 (selected || this._focusDate).getFullYear(),
318 (selected || this._focusDate).getMonth(),
319 1,
320 );
321 }
322
323 limits() {
324 return {
325 maximum: parseDate(this._input?.max),
326 minimum: parseDate(this._input?.min),
327 };
328 }
329
330 isEditable() {
331 return Boolean(this._input) &&
332 !this._input.disabled &&
333 !this._input.readOnly;
334 }
335
336 isAllowed(date) {
337 if (!date) return false;
338 const { maximum, minimum } = this.limits();
339 return (!minimum || date >= minimum) && (!maximum || date <= maximum);
340 }
341
342 clampDate(date) {
343 const { maximum, minimum } = this.limits();
344 if (minimum && date < minimum) return minimum;
345 if (maximum && date > maximum) return maximum;
346 return date;
347 }
348
349 setInvalid(invalid) {
350 setManagedAttribute(
351 this._managed,
352 this,
353 "data-invalid",
354 invalid ? "" : null,
355 );
356 if (this._trigger) {
357 this._trigger.setAttribute("aria-invalid", String(invalid));
358 }
359 if (this._grid) {
360 this._grid.setAttribute("aria-invalid", String(invalid));
361 }
362 }
363
364 createIcon(name) {
365 const icon = document.createElement("zen-icon");
366 icon.setAttribute("name", name);
367 return icon;
368 }
369
370 createControl(name, label) {
371 const button = document.createElement("button");
372 button.type = "button";
373 button.dataset[name] = "";
374 button.setAttribute("aria-label", label);
375 return button;
376 }
377
378 createView() {
379 const generated = document.createElement("div");
380 generated.dataset.zenGenerated = "calendar";
381 this._generated = generated;
382 const labels = [...this._input.labels];
383 const authoredLabelIds = (this._input.getAttribute("aria-labelledby") || "")
384 .split(/\s+/)
385 .filter(id => id && document.getElementById(id));
386 const associatedLabelIds = labels.map(label =>
387 ensureId(this._managed, label, "zen-date-label")
388 );
389 this._labelIds = authoredLabelIds.length
390 ? authoredLabelIds
391 : associatedLabelIds;
392 this._labelText = this._labelIds.map(id =>
393 document.getElementById(id)?.textContent.trim()
394 )
395 .filter(Boolean)
396 .join(", ") ||
397 labels.map(label => label.textContent.trim())
398 .filter(Boolean)
399 .join(", ") ||
400 this._input.getAttribute("aria-label") ||
401 "Date";
402
403 if (this.isPicker) {
404 const trigger = this.createControl(
405 "zenDateTrigger",
406 "Choose date",
407 );
408 trigger.id = uniqueId("zen-date-picker-trigger");
409 trigger.append(this.createIcon("calendar"));
410 const triggerText = document.createElement("span");
411 triggerText.dataset.zenDateValue = "";
412 triggerText.id = uniqueId("zen-date-picker-value");
413 trigger.append(triggerText);
414 this._trigger = trigger;
415
416 const panel = document.createElement("div");
417 panel.dataset.zenCalendarPanel = "";
418 panel.id = uniqueId("zen-date-picker-panel");
419 panel.setAttribute("role", "dialog");
420 panel.setAttribute("aria-label", `${this._labelText} calendar`);
421 panel.hidden = true;
422 trigger.setAttribute("aria-haspopup", "dialog");
423 trigger.setAttribute("aria-controls", panel.id);
424 if (this._input.hasAttribute("aria-describedby")) {
425 trigger.setAttribute(
426 "aria-describedby",
427 this._input.getAttribute("aria-describedby"),
428 );
429 }
430 if (this._labelIds.length) {
431 trigger.setAttribute(
432 "aria-labelledby",
433 [...this._labelIds, triggerText.id].join(" "),
434 );
435 }
436 for (const label of labels) {
437 if (label.htmlFor === this._input.id) {
438 setManagedAttribute(this._managed, label, "for", trigger.id);
439 }
440 }
441 this._panel = panel;
442 generated.append(trigger, panel);
443 }
444
445 const view = document.createElement("div");
446 view.dataset.zenCalendarView = "";
447 const header = document.createElement("header");
448 const previous = this.createControl(
449 "zenCalendarPrevious",
450 "Previous month",
451 );
452 previous.append(this.createIcon("chevron-left"));
453 const heading = document.createElement("strong");
454 heading.setAttribute("aria-live", "polite");
455 const next = this.createControl("zenCalendarNext", "Next month");
456 next.append(this.createIcon("chevron-right"));
457 header.append(previous, heading, next);
458
459 const weekdays = document.createElement("div");
460 weekdays.dataset.zenCalendarWeekdays = "";
461 weekdays.setAttribute("aria-hidden", "true");
462 const formatter = new Intl.DateTimeFormat(undefined, {
463 weekday: "narrow",
464 });
465 for (let day = 4; day < 11; day++) {
466 const label = document.createElement("span");
467 label.textContent = formatter.format(new Date(2026, 0, day));
468 weekdays.append(label);
469 }
470
471 const grid = document.createElement("div");
472 grid.dataset.zenCalendarGrid = "";
473 grid.setAttribute("role", "grid");
474 grid.setAttribute(
475 "aria-label",
476 this._labelText ? `${this._labelText} calendar` : "Calendar",
477 );
478 view.append(header, weekdays, grid);
479 this._view = view;
480 this._heading = heading;
481 this._grid = grid;
482 (this._panel || generated).append(view);
483 this.append(generated);
484 if (this.isPicker) this.setOpen(false);
485 }
486
487 render() {
488 if (!this._grid || !this._month) return;
489 this._focusDate = this.clampDate(this._focusDate);
490 this._heading.textContent = new Intl.DateTimeFormat(undefined, {
491 month: "long",
492 year: "numeric",
493 }).format(this._month);
494
495 if (this._trigger) {
496 const text = this._trigger.querySelector("[data-zen-date-value]");
497 text.textContent = this._selected
498 ? new Intl.DateTimeFormat(undefined, {
499 dateStyle: "medium",
500 }).format(this._selected)
501 : "Choose date";
502 if (this._labelIds.length) {
503 this._trigger.removeAttribute("aria-label");
504 } else {
505 this._trigger.setAttribute(
506 "aria-label",
507 `${this._labelText}, ${text.textContent}`,
508 );
509 }
510 this._trigger.disabled = !this.isEditable();
511 }
512 for (const control of this._view.querySelectorAll(
513 "[data-zen-calendar-previous], [data-zen-calendar-next]",
514 )) {
515 control.disabled = !this.isEditable();
516 }
517
518 const first = createDate(
519 this._month.getFullYear(),
520 this._month.getMonth(),
521 1,
522 );
523 const start = addDays(first, -first.getDay());
524 const { maximum, minimum } = this.limits();
525 const today = dateKey(new Date());
526 const selected = this._selected ? dateKey(this._selected) : "";
527 const focused = dateKey(this._focusDate);
528 const fragment = document.createDocumentFragment();
529 const formatter = new Intl.DateTimeFormat(undefined, {
530 dateStyle: "full",
531 });
532
533 for (let week = 0; week < 6; week++) {
534 const row = document.createElement("div");
535 row.setAttribute("role", "row");
536 for (let day = 0; day < 7; day++) {
537 const date = addDays(start, week * 7 + day);
538 const key = dateKey(date);
539 const button = document.createElement("button");
540 button.type = "button";
541 button.dataset.zenCalendarDay = key;
542 button.setAttribute("role", "gridcell");
543 button.setAttribute("aria-label", formatter.format(date));
544 button.setAttribute("aria-selected", String(key === selected));
545 const disabled = !this.isEditable() ||
546 Boolean((minimum && date < minimum) || (maximum && date > maximum));
547 button.tabIndex = key === focused && !disabled ? 0 : -1;
548 button.textContent = String(date.getDate());
549 button.toggleAttribute(
550 "data-outside",
551 date.getMonth() !== this._month.getMonth(),
552 );
553 button.toggleAttribute("data-today", key === today);
554 button.disabled = disabled;
555 row.append(button);
556 }
557 fragment.append(row);
558 }
559 this._grid.replaceChildren(fragment);
560 }
561
562 onClick(event) {
563 const target = event.target instanceof Element ? event.target : null;
564 if (!target) return;
565 if (target.closest("[data-zen-date-trigger]") === this._trigger) {
566 if (!this.isEditable()) return;
567 this.setOpen(!this._open);
568 return;
569 }
570 if (target.closest("[data-zen-calendar-previous]")) {
571 if (!this.isEditable()) return;
572 this.changeMonth(-1);
573 return;
574 }
575 if (target.closest("[data-zen-calendar-next]")) {
576 if (!this.isEditable()) return;
577 this.changeMonth(1);
578 return;
579 }
580 const day = target.closest("[data-zen-calendar-day]");
581 if (day && !day.disabled) this.selectDate(parseDate(day.dataset.zenCalendarDay));
582 }
583
584 onKeydown(event) {
585 if (event.key === "Escape" && this.isPicker && this._open) {
586 event.preventDefault();
587 this.setOpen(false);
588 this._trigger.focus();
589 return;
590 }
591 const day = event.target instanceof Element
592 ? event.target.closest("[data-zen-calendar-day]")
593 : null;
594 if (!day) return;
595 const current = parseDate(day.dataset.zenCalendarDay);
596 let next = null;
597 if (event.key === "ArrowLeft") next = addDays(current, -1);
598 else if (event.key === "ArrowRight") next = addDays(current, 1);
599 else if (event.key === "ArrowUp") next = addDays(current, -7);
600 else if (event.key === "ArrowDown") next = addDays(current, 7);
601 else if (event.key === "Home") next = addDays(current, -current.getDay());
602 else if (event.key === "End") next = addDays(current, 6 - current.getDay());
603 else if (event.key === "PageUp") next = addMonths(current, -1);
604 else if (event.key === "PageDown") next = addMonths(current, 1);
605 if (!next) return;
606 next = this.clampDate(next);
607 event.preventDefault();
608 this._focusDate = next;
609 this._month = createDate(next.getFullYear(), next.getMonth(), 1);
610 this.render();
611 this._grid.querySelector(
612 `[data-zen-calendar-day="${dateKey(next)}"]`,
613 )?.focus();
614 }
615
616 changeMonth(amount) {
617 if (!this.isEditable()) return;
618 this._month = addMonths(this._month, amount);
619 this._focusDate = this.clampDate(createDate(
620 this._month.getFullYear(),
621 this._month.getMonth(),
622 1,
623 ));
624 this._month = createDate(
625 this._focusDate.getFullYear(),
626 this._focusDate.getMonth(),
627 1,
628 );
629 this.render();
630 this._grid.querySelector('[tabindex="0"]')?.focus();
631 }
632
633 selectDate(date) {
634 if (!this.isEditable() || !this.isAllowed(date)) return;
635 this._selected = date;
636 this._focusDate = date;
637 this._month = createDate(date.getFullYear(), date.getMonth(), 1);
638 this._input.value = dateKey(date);
639 this._input.dispatchEvent(new Event("input", {
640 bubbles: true,
641 composed: true,
642 }));
643 this._input.dispatchEvent(new Event("change", { bubbles: true }));
644 emit(this, "zen-change", { value: this._input.value });
645 this.render();
646 if (this.isPicker) {
647 this.setOpen(false);
648 this._trigger.focus();
649 }
650 }
651
652 setOpen(open) {
653 if (!this.isPicker || !this._panel || !this._trigger) return;
654 this._open = Boolean(open) && this.isEditable();
655 this._panel.hidden = !this._open;
656 this._trigger.setAttribute("aria-expanded", String(this._open));
657 if (this._open) {
658 this._grid.querySelector('[tabindex="0"]')?.focus();
659 }
660 }
661 }
662
663 /**
664 * Presents the first-party calendar behind a tokenized disclosure trigger.
665 *
666 * @extends {ZenCalendar}
667 */
668 export class ZenDatePicker extends ZenCalendar {}
669
670 /**
671 * Adds radiogroup semantics when no native fieldset owns the radios.
672 *
673 * @extends {HTMLElement}
674 */
675 export class ZenRadioGroup extends HTMLElement {
676 connectedCallback() {
677 this._managed ||= new Map();
678 if (!this._observer) {
679 this._observer = new MutationObserver(() => this.sync());
680 }
681 this._observer.observe(this, { childList: true, subtree: true });
682 this.sync();
683 }
684
685 disconnectedCallback() {
686 this._observer?.disconnect();
687 restoreAttributes(this._managed);
688 }
689
690 sync() {
691 const radios = this.querySelectorAll('input[type="radio"]');
692 const needsRole = radios.length > 0 && !this.querySelector("fieldset");
693 if (needsRole && !this.hasAttribute("role")) {
694 setManagedAttribute(this._managed, this, "role", "radiogroup");
695 } else if (!needsRole) {
696 restoreElement(this._managed, this);
697 }
698 }
699 }
700
701 /**
702 * Synchronizes switch semantics onto a native checkbox.
703 *
704 * @extends {HTMLElement}
705 */
706 export class ZenSwitch extends HTMLElement {
707 connectedCallback() {
708 this._managed ||= new Map();
709 this._onStateChange ||= event => {
710 if (event.target === this._control) this.updateState();
711 };
712 this._onReset ||= () => {
713 queueMicrotask(() => {
714 if (this.isConnected) this.updateState();
715 });
716 };
717 this.addEventListener("input", this._onStateChange);
718 this.addEventListener("change", this._onStateChange);
719 if (!this._observer) {
720 this._observer = new MutationObserver(() => this.sync());
721 }
722 this._observer.observe(this, { childList: true, subtree: true });
723 this.sync();
724 }
725
726 disconnectedCallback() {
727 this._observer?.disconnect();
728 this.removeEventListener("input", this._onStateChange);
729 this.removeEventListener("change", this._onStateChange);
730 this._form?.removeEventListener("reset", this._onReset);
731 restoreAttributes(this._managed);
732 this._control = null;
733 this._form = null;
734 }
735
736 sync() {
737 const control = this.querySelector('input[type="checkbox"]');
738 const form = control?.form || null;
739 if (control !== this._control) {
740 if (this._control) restoreElement(this._managed, this._control);
741 this._control = control;
742 }
743 if (form !== this._form) {
744 this._form?.removeEventListener("reset", this._onReset);
745 this._form = form;
746 form?.addEventListener("reset", this._onReset);
747 }
748 this.updateState();
749 }
750
751 updateState() {
752 if (!this._control) return;
753 setManagedAttribute(this._managed, this._control, "role", "switch");
754 setManagedAttribute(
755 this._managed,
756 this._control,
757 "aria-checked",
758 String(this._control.checked),
759 );
760 }
761 }
762
763 /**
764 * Reflects native form validity on the component boundary.
765 *
766 * @extends {HTMLElement}
767 */
768 export class ZenForm extends HTMLElement {
769 connectedCallback() {
770 this._managed ||= new Map();
771 this._syncValidity ||= () => {
772 if (!this._form) return;
773 setManagedAttribute(
774 this._managed,
775 this,
776 "data-invalid",
777 this._form.checkValidity() ? null : "",
778 );
779 };
780 this._markInvalid ||= () => {
781 setManagedAttribute(this._managed, this, "data-invalid", "");
782 };
783 this._onReset ||= () => queueMicrotask(this._syncValidity);
784 if (!this._observer) {
785 this._observer = new MutationObserver(() => this.sync());
786 }
787 this._observer.observe(this, { childList: true, subtree: true });
788 this.sync();
789 }
790
791 disconnectedCallback() {
792 this._observer?.disconnect();
793 this.releaseForm();
794 restoreAttributes(this._managed);
795 }
796
797 releaseForm() {
798 this._form?.removeEventListener("submit", this._syncValidity);
799 this._form?.removeEventListener("input", this._syncValidity);
800 this._form?.removeEventListener("invalid", this._markInvalid, true);
801 this._form?.removeEventListener("reset", this._onReset);
802 this._form = null;
803 }
804
805 sync() {
806 const form = this.querySelector("form");
807 if (form === this._form) return;
808 this.releaseForm();
809 restoreElement(this._managed, this);
810 this._form = form;
811 form?.addEventListener("submit", this._syncValidity);
812 form?.addEventListener("input", this._syncValidity);
813 form?.addEventListener("invalid", this._markInvalid, true);
814 form?.addEventListener("reset", this._onReset);
815 }
816 }
817
818 class FilterableList extends HTMLElement {
819 connectedCallback() {
820 this._managed ||= new Map();
821 this._active = null;
822 this._onInput ||= event => {
823 if (event.target !== this._input) return;
824 if (this.isCombobox) this.setOpen(true);
825 this.filter();
826 };
827 this._onKeydown ||= event => this.onKeydown(event);
828 this._onClick ||= event => this.onClick(event);
829 this._onFocus ||= event => {
830 if (this.isCombobox && event.target === this._input) {
831 this.setOpen(true);
832 this.filter();
833 }
834 };
835 this.addEventListener("input", this._onInput);
836 this.addEventListener("keydown", this._onKeydown);
837 this.addEventListener("click", this._onClick);
838 this.addEventListener("focusin", this._onFocus);
839 if (!this._observer) {
840 this._observer = new MutationObserver(() => this.sync());
841 }
842 this._observer.observe(this, { childList: true, subtree: true });
843 this.sync();
844 }
845
846 disconnectedCallback() {
847 this._observer?.disconnect();
848 this.removeEventListener("input", this._onInput);
849 this.removeEventListener("keydown", this._onKeydown);
850 this.removeEventListener("click", this._onClick);
851 this.removeEventListener("focusin", this._onFocus);
852 restoreAttributes(this._managed);
853 this._input = null;
854 this._list = null;
855 this._items = [];
856 this._active = null;
857 }
858
859 get isCombobox() {
860 return false;
861 }
862
863 get listRole() {
864 return "menu";
865 }
866
867 get itemRole() {
868 return "menuitem";
869 }
870
871 sync() {
872 const input = this.querySelector("input");
873 const list = this.querySelector(`[role="${this.listRole}"]`);
874 if (input !== this._input || list !== this._list) {
875 restoreAttributes(this._managed);
876 this._input = input;
877 this._list = list;
878 this._items = [];
879 this._active = null;
880 this._open = false;
881 }
882 if (!input || !list) return;
883
884 const items = [...list.querySelectorAll(`[role="${this.itemRole}"]`)];
885 for (const item of this._items) {
886 if (!items.includes(item)) restoreElement(this._managed, item);
887 }
888 this._items = items;
889
890 const listId = ensureId(
891 this._managed,
892 list,
893 this.isCombobox ? "zen-listbox" : "zen-command-menu",
894 );
895 if (!input.hasAttribute("aria-controls")) {
896 setManagedAttribute(this._managed, input, "aria-controls", listId);
897 }
898 if (this.isCombobox) {
899 if (!input.hasAttribute("role")) {
900 setManagedAttribute(this._managed, input, "role", "combobox");
901 }
902 if (!input.hasAttribute("aria-autocomplete")) {
903 setManagedAttribute(
904 this._managed,
905 input,
906 "aria-autocomplete",
907 "list",
908 );
909 }
910 this.setOpen(this._open);
911 }
912 this.filter();
913 }
914
915 filter() {
916 if (!this._input || !this._list) return;
917 const query = this._input.value.trim().toLowerCase();
918 for (const item of this._items) {
919 const originallyHidden =
920 originalAttribute(this._managed, item, "hidden") !== null;
921 const matches = item.textContent.toLowerCase().includes(query);
922 setManagedAttribute(
923 this._managed,
924 item,
925 "hidden",
926 originallyHidden || !matches ? "" : null,
927 );
928 }
929 if (this._active?.hidden) this.setActive(null);
930 }
931
932 visibleItems() {
933 return this._items.filter(item => {
934 if (item.hidden || item.closest("[hidden]")) return false;
935 if (item.getAttribute("aria-disabled") === "true") return false;
936 return !("disabled" in item && item.disabled);
937 });
938 }
939
940 setActive(item) {
941 this._active = item;
942 for (const candidate of this._items) {
943 if (this.isCombobox) {
944 setManagedAttribute(
945 this._managed,
946 candidate,
947 "aria-selected",
948 String(candidate === item),
949 );
950 } else {
951 restoreAttribute(this._managed, candidate, "aria-selected");
952 }
953 }
954 if (!this._input) return;
955 if (item) {
956 setManagedAttribute(
957 this._managed,
958 this._input,
959 "aria-activedescendant",
960 ensureId(this._managed, item, "zen-option"),
961 );
962 item.scrollIntoView?.({ block: "nearest" });
963 } else {
964 setManagedAttribute(
965 this._managed,
966 this._input,
967 "aria-activedescendant",
968 null,
969 );
970 }
971 }
972
973 moveActive(offset) {
974 const items = this.visibleItems();
975 if (!items.length) {
976 this.setActive(null);
977 return;
978 }
979 const current = items.indexOf(this._active);
980 const index = current < 0
981 ? (offset > 0 ? 0 : items.length - 1)
982 : (current + offset + items.length) % items.length;
983 this.setActive(items[index]);
984 }
985
986 onKeydown(event) {
987 if (event.target !== this._input) return;
988 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
989 event.preventDefault();
990 if (this.isCombobox) this.setOpen(true);
991 this.moveActive(event.key === "ArrowDown" ? 1 : -1);
992 return;
993 }
994 if (event.key === "Enter") {
995 const item = this._active || this.visibleItems()[0];
996 if (!item) return;
997 event.preventDefault();
998 this.choose(item);
999 }
1000 }
1001
1002 onClick(event) {
1003 const target = event.target instanceof Element
1004 ? event.target.closest(`[role="${this.itemRole}"]`)
1005 : null;
1006 if (!target || !this._list?.contains(target) || target.hidden) return;
1007 if (target.getAttribute("aria-disabled") === "true" ||
1008 ("disabled" in target && target.disabled)) return;
1009 this.choose(target);
1010 }
1011
1012 valueFor(item) {
1013 return item.dataset.value ?? item.getAttribute("value") ??
1014 item.textContent.trim();
1015 }
1016 }
1017
1018 /**
1019 * Filters and selects native option elements from a text input.
1020 *
1021 * @extends {FilterableList}
1022 */
1023 export class ZenCombobox extends FilterableList {
1024 get isCombobox() {
1025 return true;
1026 }
1027
1028 get listRole() {
1029 return "listbox";
1030 }
1031
1032 get itemRole() {
1033 return "option";
1034 }
1035
1036 setOpen(open) {
1037 this._open = Boolean(open);
1038 if (!this._input || !this._list) return;
1039 setManagedAttribute(
1040 this._managed,
1041 this._input,
1042 "aria-expanded",
1043 String(this._open),
1044 );
1045 setManagedAttribute(
1046 this._managed,
1047 this._list,
1048 "hidden",
1049 this._open ? null : "",
1050 );
1051 if (!this._open) this.setActive(null);
1052 }
1053
1054 onKeydown(event) {
1055 if (event.target === this._input && event.key === "Escape") {
1056 if (this._open) event.preventDefault();
1057 this.setOpen(false);
1058 return;
1059 }
1060 super.onKeydown(event);
1061 }
1062
1063 choose(item) {
1064 const value = this.valueFor(item);
1065 this._input.value = value;
1066 this._input.dispatchEvent(new Event("input", {
1067 bubbles: true,
1068 composed: true,
1069 }));
1070 this._input.dispatchEvent(new Event("change", { bubbles: true }));
1071 emit(this, "zen-change", { value });
1072 this.setOpen(false);
1073 }
1074 }
1075
1076 /**
1077 * Filters a keyboard-oriented command menu and emits selected commands.
1078 *
1079 * @extends {FilterableList}
1080 */
1081 export class ZenCommand extends FilterableList {
1082 choose(item) {
1083 emit(this, "zen-command", { value: this.valueFor(item) });
1084 }
1085 }
1086
1087 /**
1088 * Enhances one native one-time-code input with visual slots.
1089 *
1090 * @extends {HTMLElement}
1091 */
1092 export class ZenInputOtp extends HTMLElement {
1093 connectedCallback() {
1094 this._managed ||= new Map();
1095 this._complete ??= false;
1096 this._onInput ||= event => {
1097 if (event.target === this._input) this.update(true);
1098 };
1099 this.addEventListener("input", this._onInput);
1100 if (!this._observer) {
1101 this._observer = new MutationObserver(() => this.sync());
1102 }
1103 this._observer.observe(this, {
1104 attributes: true,
1105 attributeFilter: ["allow", "maxlength", "pattern"],
1106 childList: true,
1107 subtree: true,
1108 });
1109 this.sync();
1110 }
1111
1112 disconnectedCallback() {
1113 this._observer?.disconnect();
1114 this.removeEventListener("input", this._onInput);
1115 const input = this._input;
1116 const complete = this._complete;
1117 this.release();
1118 this._previousInput = input;
1119 this._complete = complete;
1120 }
1121
1122 release() {
1123 restoreAttributes(this._managed);
1124 if (this._slots && this._originalSlots) {
1125 this._slots.replaceChildren(...this._originalSlots);
1126 }
1127 this._input = null;
1128 this._slots = null;
1129 this._originalSlots = null;
1130 this._complete = false;
1131 }
1132
1133 sync() {
1134 const inputs = [...this.querySelectorAll("input")];
1135 const input = inputs.length === 1 ? inputs[0] : null;
1136 const slots = this.querySelector("[data-zen-otp-slots]");
1137 if (input !== this._input || slots !== this._slots) {
1138 const remainedComplete =
1139 (input === this._input || input === this._previousInput) &&
1140 this._complete;
1141 this.release();
1142 this._input = input;
1143 this._slots = slots;
1144 this._originalSlots = slots ? [...slots.childNodes] : null;
1145 this._complete = remainedComplete;
1146 this._previousInput = null;
1147 }
1148 if (!this._input) return;
1149
1150 if (!this._input.hasAttribute("maxlength")) {
1151 setManagedAttribute(this._managed, this._input, "maxlength", "6");
1152 }
1153 if (this.numericOnly() && !this._input.hasAttribute("inputmode")) {
1154 setManagedAttribute(this._managed, this._input, "inputmode", "numeric");
1155 } else if (!this.numericOnly()) {
1156 restoreAttribute(this._managed, this._input, "inputmode");
1157 }
1158 this.update(true);
1159 }
1160
1161 numericOnly() {
1162 return !this.hasAttribute("allow") &&
1163 !this.hasAttribute("pattern") &&
1164 !this._input.hasAttribute("allow") &&
1165 !this._input.hasAttribute("pattern");
1166 }
1167
1168 length() {
1169 const value = Number.parseInt(this._input.getAttribute("maxlength"), 10);
1170 return Number.isFinite(value) && value > 0 ? value : 6;
1171 }
1172
1173 update(notify) {
1174 const length = this.length();
1175 const before = this._input.value;
1176 let value = this.numericOnly() ? before.replace(/\D/g, "") : before;
1177 value = [...value].slice(0, length).join("");
1178 if (value !== before) {
1179 const selection = this._input.selectionStart;
1180 this._input.value = value;
1181 if (selection !== null) {
1182 const preceding = before.slice(0, selection);
1183 const position = this.numericOnly()
1184 ? preceding.replace(/\D/g, "").length
1185 : [...preceding].slice(0, length).join("").length;
1186 try {
1187 this._input.setSelectionRange(position, position);
1188 } catch {
1189 // Some native input types do not expose text selection.
1190 }
1191 }
1192 }
1193 this.renderSlots(value, length);
1194
1195 const complete = [...value].length === length;
1196 if (notify && complete && !this._complete) {
1197 emit(this, "zen-complete", { value });
1198 }
1199 this._complete = complete;
1200 }
1201
1202 renderSlots(value, length) {
1203 if (!this._slots) return;
1204 const characters = [...value];
1205 const current = [...this._slots.children];
1206 const unchanged = current.length === length &&
1207 current.every((slot, index) =>
1208 slot.localName === "span" &&
1209 slot.textContent === (characters[index] || ""));
1210 if (unchanged) return;
1211
1212 const fragment = document.createDocumentFragment();
1213 for (let index = 0; index < length; index++) {
1214 const slot = document.createElement("span");
1215 slot.textContent = characters[index] || "";
1216 fragment.append(slot);
1217 }
1218 this._slots.replaceChildren(fragment);
1219 }
1220 }
1221
1222 const definitions = {
1223 "zen-calendar": ZenCalendar,
1224 "zen-checkbox": ZenCheckbox,
1225 "zen-combobox": ZenCombobox,
1226 "zen-command": ZenCommand,
1227 "zen-date-picker": ZenDatePicker,
1228 "zen-form": ZenForm,
1229 "zen-input-otp": ZenInputOtp,
1230 "zen-radio-group": ZenRadioGroup,
1231 "zen-select": ZenSelect,
1232 "zen-slider": ZenSlider,
1233 "zen-switch": ZenSwitch,
1234 };
1235
1236 if (globalThis.customElements) {
1237 for (const [name, definition] of Object.entries(definitions)) {
1238 if (!customElements.get(name)) customElements.define(name, definition);
1239 }
1240 }