comparison design_system/src/components/disclosure.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 nextDisclosureId = 1;
2
3 const HTMLElementBase = globalThis.HTMLElement || class {};
4
5 function rememberAttribute(state, element, name) {
6 let attributes = state.get(element);
7 if (!attributes) {
8 attributes = new Map();
9 state.set(element, attributes);
10 }
11 if (!attributes.has(name)) {
12 attributes.set(name, element.getAttribute(name));
13 }
14 }
15
16 function setManagedAttribute(state, element, name, value) {
17 rememberAttribute(state, element, name);
18 if (value === null) {
19 if (element.hasAttribute(name)) element.removeAttribute(name);
20 } else if (element.getAttribute(name) !== value) {
21 element.setAttribute(name, value);
22 }
23 }
24
25 function restoreAttribute(state, element, name) {
26 const attributes = state.get(element);
27 if (!attributes?.has(name)) return;
28 const value = attributes.get(name);
29 if (value === null) element.removeAttribute(name);
30 else element.setAttribute(name, value);
31 attributes.delete(name);
32 if (!attributes.size) state.delete(element);
33 }
34
35 function restoreElement(state, element) {
36 const attributes = state.get(element);
37 if (!attributes) return;
38 for (const [name, value] of attributes) {
39 if (value === null) element.removeAttribute(name);
40 else element.setAttribute(name, value);
41 }
42 state.delete(element);
43 }
44
45 function restoreAttributes(state) {
46 for (const element of [...state.keys()]) restoreElement(state, element);
47 }
48
49 function pruneAttributes(state, retained) {
50 for (const element of [...state.keys()]) {
51 if (!retained.has(element)) restoreElement(state, element);
52 }
53 }
54
55 function uniqueId(element, prefix) {
56 const document = element.ownerDocument;
57 let id;
58 do id = `${prefix}-${nextDisclosureId++}`;
59 while ([...document.querySelectorAll("[id]")].some(item => item.id === id));
60 return id;
61 }
62
63 function ensureId(state, element, prefix) {
64 const document = element.ownerDocument;
65 const duplicate = element.id &&
66 [...document.querySelectorAll("[id]")].some(
67 item => item !== element && item.id === element.id,
68 );
69 if (!element.id || duplicate) {
70 setManagedAttribute(state, element, "id", uniqueId(element, prefix));
71 }
72 return element.id;
73 }
74
75 function eventElement(event) {
76 const target = event.target;
77 if (target?.nodeType === 1) return target;
78 return target?.parentElement || null;
79 }
80
81 function isDisabled(control) {
82 return Boolean(control.disabled) ||
83 control.getAttribute("aria-disabled") === "true";
84 }
85
86 function emitChange(element, detail) {
87 element.dispatchEvent(new CustomEvent("zen-change", {
88 bubbles: true,
89 detail,
90 }));
91 }
92
93 function ownedElements(host, selector) {
94 return [...host.querySelectorAll(selector)].filter(
95 element => element.closest(host.localName) === host,
96 );
97 }
98
99 function syncDisclosureIcon(details) {
100 const summary = details.querySelector(":scope > summary");
101 if (!summary) return;
102 let icon = summary.querySelector(
103 ":scope > zen-icon[data-zen-disclosure-icon]",
104 );
105 if (!icon) {
106 icon = document.createElement("zen-icon");
107 icon.dataset.zenDisclosureIcon = "";
108 icon.dataset.zenGenerated = "true";
109 summary.append(icon);
110 }
111 if (!icon.hasAttribute("name")) icon.setAttribute("name", "chevron-down");
112 }
113
114 function removeGeneratedDisclosureIcons(host) {
115 for (const icon of host.querySelectorAll(
116 'zen-icon[data-zen-disclosure-icon][data-zen-generated="true"]',
117 )) {
118 icon.remove();
119 }
120 }
121
122 /**
123 * Coordinates native details elements and summary keyboard navigation.
124 *
125 * @extends {HTMLElement}
126 */
127 export class ZenAccordion extends HTMLElementBase {
128 static get observedAttributes() {
129 return ["multiple"];
130 }
131
132 connectedCallback() {
133 this._details ||= new Set();
134 this._onToggle ||= event => {
135 const opened = event.currentTarget;
136 if (!opened.open || this.hasAttribute("multiple")) return;
137 for (const details of this._details) {
138 if (details !== opened) details.open = false;
139 }
140 };
141 this._onKeydown ||= event => {
142 if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
143 return;
144 }
145 const summary = eventElement(event)?.closest("summary");
146 const summaries = this.summaries();
147 const index = summaries.indexOf(summary);
148 if (index < 0) return;
149 event.preventDefault();
150 let next = event.key === "Home" ? 0 : summaries.length - 1;
151 if (event.key === "ArrowUp") {
152 next = (index - 1 + summaries.length) % summaries.length;
153 } else if (event.key === "ArrowDown") {
154 next = (index + 1) % summaries.length;
155 }
156 summaries[next]?.focus();
157 };
158 this.addEventListener("keydown", this._onKeydown);
159 this._observer ||= new MutationObserver(() => this.sync());
160 this._observer.observe(this, { childList: true, subtree: true });
161 this.sync();
162 }
163
164 disconnectedCallback() {
165 this._observer?.disconnect();
166 this.removeEventListener("keydown", this._onKeydown);
167 for (const details of this._details || []) {
168 details.removeEventListener("toggle", this._onToggle);
169 }
170 removeGeneratedDisclosureIcons(this);
171 this._details?.clear();
172 }
173
174 attributeChangedCallback() {
175 if (this.isConnected && this._details) this.sync();
176 }
177
178 summaries() {
179 return [...this._details]
180 .map(details => details.querySelector(":scope > summary"))
181 .filter(Boolean);
182 }
183
184 sync() {
185 const current = new Set(
186 ownedElements(this, "details").filter(details => {
187 const parent = details.parentElement?.closest("details");
188 return !parent || parent.closest("zen-accordion") !== this;
189 }),
190 );
191 for (const details of this._details) {
192 if (!current.has(details)) {
193 details.removeEventListener("toggle", this._onToggle);
194 }
195 }
196 for (const details of current) {
197 if (!this._details.has(details)) {
198 details.addEventListener("toggle", this._onToggle);
199 }
200 syncDisclosureIcon(details);
201 }
202 this._details = current;
203
204 if (!this.hasAttribute("multiple")) {
205 const opened = [...current].filter(details => details.open);
206 for (const details of opened.slice(1)) details.open = false;
207 }
208 }
209 }
210
211 /**
212 * Styles a single native details disclosure without replacing its behavior.
213 *
214 * @extends {HTMLElement}
215 */
216 export class ZenCollapsible extends HTMLElementBase {
217 connectedCallback() {
218 this._observer ||= new MutationObserver(() => this.sync());
219 this._observer.observe(this, { childList: true, subtree: true });
220 this.sync();
221 }
222
223 disconnectedCallback() {
224 this._observer?.disconnect();
225 removeGeneratedDisclosureIcons(this);
226 }
227
228 sync() {
229 for (const details of ownedElements(this, "details")) {
230 syncDisclosureIcon(details);
231 }
232 }
233 }
234
235 /**
236 * Connects an ARIA tablist with its native content panels.
237 *
238 * @extends {HTMLElement}
239 */
240 export class ZenTabs extends HTMLElementBase {
241 connectedCallback() {
242 this._managed ||= new Map();
243 this._onClick ||= event => {
244 const tab = eventElement(event)?.closest('[role="tab"]');
245 if (!this.isTab(tab) || isDisabled(tab)) return;
246 this.activate(tab, true);
247 };
248 this._onKeydown ||= event => {
249 const tab = eventElement(event)?.closest('[role="tab"]');
250 if (!this.isTab(tab)) return;
251 const tabs = this._tabs.filter(candidate => !isDisabled(candidate));
252 const index = tabs.indexOf(tab);
253 if (index < 0) return;
254
255 let next;
256 if (event.key === "Home") next = tabs[0];
257 else if (event.key === "End") next = tabs[tabs.length - 1];
258 else if (event.key === "ArrowLeft") {
259 next = tabs[(index - 1 + tabs.length) % tabs.length];
260 } else if (event.key === "ArrowRight") {
261 next = tabs[(index + 1) % tabs.length];
262 } else {
263 return;
264 }
265
266 event.preventDefault();
267 this._focusTab = next;
268 this.render();
269 next.focus();
270 if (this.getAttribute("activation") !== "manual") {
271 this.activate(next, true);
272 }
273 };
274 this.addEventListener("click", this._onClick);
275 this.addEventListener("keydown", this._onKeydown);
276 this._observer ||= new MutationObserver(() => this.sync());
277 this._observer.observe(this, {
278 attributeFilter: ["aria-controls", "aria-disabled", "disabled", "id"],
279 attributes: true,
280 childList: true,
281 subtree: true,
282 });
283 this.sync();
284 }
285
286 disconnectedCallback() {
287 this._observer?.disconnect();
288 this.removeEventListener("click", this._onClick);
289 this.removeEventListener("keydown", this._onKeydown);
290 restoreAttributes(this._managed);
291 this._tablist = null;
292 this._tabs = [];
293 this._panels = [];
294 this._pairs = new Map();
295 this._active = null;
296 this._focusTab = null;
297 }
298
299 isTab(tab) {
300 return Boolean(
301 tab && this._tabs?.includes(tab) && this._tablist?.contains(tab),
302 );
303 }
304
305 sync() {
306 this._tablist = ownedElements(this, '[role="tablist"]')[0] || null;
307 this._tabs = this._tablist
308 ? [...this._tablist.querySelectorAll('[role="tab"]')].filter(
309 tab => tab.closest("zen-tabs") === this,
310 )
311 : [];
312 this._panels = ownedElements(this, '[role="tabpanel"]');
313 pruneAttributes(
314 this._managed,
315 new Set([...this._tabs, ...this._panels]),
316 );
317
318 for (const tab of this._tabs) {
319 ensureId(this._managed, tab, "zen-tab");
320 }
321 for (const panel of this._panels) {
322 ensureId(this._managed, panel, "zen-tabpanel");
323 }
324
325 const available = new Set(this._panels);
326 this._pairs = new Map();
327 for (const [index, tab] of this._tabs.entries()) {
328 const controlled = tab.getAttribute("aria-controls");
329 let panel = this._panels.find(
330 candidate => available.has(candidate) && candidate.id === controlled,
331 );
332 if (!panel && available.has(this._panels[index])) {
333 panel = this._panels[index];
334 }
335 panel ||= [...available][0] || null;
336 if (panel) {
337 available.delete(panel);
338 this._pairs.set(tab, panel);
339 setManagedAttribute(
340 this._managed,
341 tab,
342 "aria-controls",
343 panel.id,
344 );
345 setManagedAttribute(
346 this._managed,
347 panel,
348 "aria-labelledby",
349 tab.id,
350 );
351 } else {
352 restoreAttribute(this._managed, tab, "aria-controls");
353 }
354 }
355 for (const panel of available) {
356 restoreAttribute(this._managed, panel, "aria-labelledby");
357 }
358
359 if (!this._tabs.includes(this._active) || isDisabled(this._active)) {
360 this._active = this._tabs.find(
361 tab => !isDisabled(tab) && tab.getAttribute("aria-selected") === "true",
362 ) || this._tabs.find(tab => !isDisabled(tab)) || this._tabs[0] || null;
363 }
364 if (!this._tabs.includes(this._focusTab) || isDisabled(this._focusTab)) {
365 this._focusTab = this._active;
366 }
367 this.render();
368 }
369
370 render() {
371 for (const tab of this._tabs) {
372 const active = tab === this._active;
373 setManagedAttribute(
374 this._managed,
375 tab,
376 "aria-selected",
377 String(active),
378 );
379 setManagedAttribute(
380 this._managed,
381 tab,
382 "tabindex",
383 tab === this._focusTab && !isDisabled(tab) ? "0" : "-1",
384 );
385 }
386 const activePanel = this._pairs.get(this._active);
387 for (const panel of this._panels) {
388 setManagedAttribute(
389 this._managed,
390 panel,
391 "hidden",
392 panel === activePanel ? null : "",
393 );
394 }
395 }
396
397 activate(tab, notify) {
398 if (!this.isTab(tab) || isDisabled(tab)) return;
399 this._focusTab = tab;
400 if (tab === this._active) {
401 this.render();
402 return;
403 }
404 this._active = tab;
405 this.render();
406 if (notify) {
407 emitChange(this, {
408 value: tab.dataset.value ?? tab.getAttribute("value") ?? tab.id,
409 });
410 }
411 }
412 }
413
414 /**
415 * Manages the pressed state of one direct native button.
416 *
417 * @extends {HTMLElement}
418 */
419 export class ZenToggle extends HTMLElementBase {
420 static get observedAttributes() {
421 return ["pressed"];
422 }
423
424 get pressed() {
425 return this.hasAttribute("pressed");
426 }
427
428 set pressed(value) {
429 this.toggleAttribute("pressed", Boolean(value));
430 }
431
432 connectedCallback() {
433 this._managed ||= new Map();
434 this._onClick ||= event => {
435 const button = eventElement(event)?.closest("button");
436 if (button !== this._button || isDisabled(button)) return;
437 this.pressed = !this.pressed;
438 emitChange(this, { pressed: this.pressed });
439 };
440 this.addEventListener("click", this._onClick);
441 this._observer ||= new MutationObserver(() => this.sync());
442 this._observer.observe(this, { childList: true });
443 this.sync();
444 }
445
446 disconnectedCallback() {
447 this._observer?.disconnect();
448 this.removeEventListener("click", this._onClick);
449 restoreAttributes(this._managed);
450 this._button = null;
451 }
452
453 attributeChangedCallback() {
454 if (this.isConnected && this._managed) this.sync();
455 }
456
457 sync() {
458 const button = this.querySelector(":scope > button");
459 if (button !== this._button) {
460 if (this._button) restoreElement(this._managed, this._button);
461 this._button = button;
462 }
463 if (button) {
464 setManagedAttribute(
465 this._managed,
466 button,
467 "aria-pressed",
468 String(this.pressed),
469 );
470 }
471 }
472 }
473
474 function buttonValue(button) {
475 return button.getAttribute("value") ?? button.dataset.value ?? "";
476 }
477
478 function parseMultipleValue(value) {
479 if (!value) return [];
480 if (value.trim().startsWith("[")) {
481 try {
482 const parsed = JSON.parse(value);
483 if (Array.isArray(parsed)) return parsed.map(String);
484 } catch {
485 // Fall through to token parsing.
486 }
487 }
488 return value.trim().split(/[\s,]+/).filter(Boolean);
489 }
490
491 /**
492 * Manages single or multiple pressed buttons with roving focus.
493 *
494 * @extends {HTMLElement}
495 */
496 export class ZenToggleGroup extends HTMLElementBase {
497 static get observedAttributes() {
498 return ["type", "value"];
499 }
500
501 get values() {
502 return [...(this._values || [])];
503 }
504
505 set values(values) {
506 this.setValues(Array.isArray(values) ? values.map(String) : [], false);
507 }
508
509 connectedCallback() {
510 this._managed ||= new Map();
511 this._buttons ||= [];
512 if (!this._initialized) {
513 this._values = this.initialValues();
514 this._initialized = true;
515 }
516 this._hasConnected = true;
517 this._onClick ||= event => {
518 const button = eventElement(event)?.closest("button");
519 if (!this._buttons.includes(button) || isDisabled(button)) return;
520 const value = buttonValue(button);
521 this._focusButton = button;
522 let values;
523 if (this.multiple) {
524 values = this._values.includes(value)
525 ? this._values.filter(item => item !== value)
526 : [...this._values, value];
527 } else {
528 values = this._values.includes(value) ? [] : [value];
529 }
530 this.setValues(values, true);
531 };
532 this._onKeydown ||= event => {
533 const button = eventElement(event)?.closest("button");
534 const buttons = this._buttons.filter(candidate => !isDisabled(candidate));
535 const index = buttons.indexOf(button);
536 if (index < 0) return;
537
538 let next;
539 if (event.key === "Home") next = buttons[0];
540 else if (event.key === "End") next = buttons[buttons.length - 1];
541 else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
542 next = buttons[(index - 1 + buttons.length) % buttons.length];
543 } else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
544 next = buttons[(index + 1) % buttons.length];
545 } else {
546 return;
547 }
548 event.preventDefault();
549 this._focusButton = next;
550 this.render();
551 next.focus();
552 };
553 this.addEventListener("click", this._onClick);
554 this.addEventListener("keydown", this._onKeydown);
555 this._observer ||= new MutationObserver(() => this.sync());
556 this._observer.observe(this, {
557 attributeFilter: ["aria-disabled", "data-value", "disabled", "value"],
558 attributes: true,
559 childList: true,
560 subtree: true,
561 });
562 this.sync();
563 }
564
565 disconnectedCallback() {
566 this._observer?.disconnect();
567 this.removeEventListener("click", this._onClick);
568 this.removeEventListener("keydown", this._onKeydown);
569 restoreAttributes(this._managed);
570 this._buttons = [];
571 this._focusButton = null;
572 }
573
574 attributeChangedCallback(name) {
575 if (name === "value" && !this._settingValue &&
576 this.hasAttribute("value")) {
577 this._values = this.readValue();
578 this._initialized = true;
579 } else if (name === "value" && !this._settingValue) {
580 this._values = [];
581 this._initialized = true;
582 } else if (name === "type" && !this._hasConnected &&
583 this.hasAttribute("value")) {
584 this._values = this.readValue();
585 this._initialized = true;
586 }
587 if (this.isConnected && this._managed) this.sync();
588 }
589
590 get multiple() {
591 return this.getAttribute("type") === "multiple";
592 }
593
594 readValue() {
595 const value = this.getAttribute("value");
596 if (value === null) return [];
597 return this.multiple ? parseMultipleValue(value) : [value];
598 }
599
600 initialValues() {
601 if (this.hasAttribute("value")) return this.readValue();
602 const pressed = [...this.querySelectorAll(":scope > button")]
603 .filter(button => button.getAttribute("aria-pressed") === "true");
604 return pressed.map(buttonValue);
605 }
606
607 sync() {
608 const buttons = [...this.querySelectorAll(":scope > button")];
609 pruneAttributes(this._managed, new Set([this, ...buttons]));
610 this._buttons = buttons;
611 if (!this.hasAttribute("role")) {
612 setManagedAttribute(this._managed, this, "role", "group");
613 }
614 if (!this.multiple && this._values.length > 1) {
615 this._values = this._values.slice(0, 1);
616 this.writeValue();
617 }
618 if (!buttons.includes(this._focusButton) ||
619 isDisabled(this._focusButton)) {
620 this._focusButton = buttons.find(
621 button =>
622 !isDisabled(button) &&
623 button.getAttribute("tabindex") === "0",
624 ) || buttons.find(
625 button =>
626 !isDisabled(button) && this._values.includes(buttonValue(button)),
627 ) || buttons.find(button => !isDisabled(button)) || null;
628 }
629 this.render();
630 }
631
632 render() {
633 for (const button of this._buttons) {
634 setManagedAttribute(
635 this._managed,
636 button,
637 "aria-pressed",
638 String(this._values.includes(buttonValue(button))),
639 );
640 setManagedAttribute(
641 this._managed,
642 button,
643 "tabindex",
644 button === this._focusButton ? "0" : "-1",
645 );
646 }
647 }
648
649 writeValue() {
650 this._settingValue = true;
651 const value = this.multiple
652 ? (this._values.some(item => /[\s,]/.test(item))
653 ? JSON.stringify(this._values)
654 : this._values.join(" "))
655 : this._values[0];
656 if (value === undefined || value === "") this.removeAttribute("value");
657 else this.setAttribute("value", value);
658 this._settingValue = false;
659 }
660
661 setValues(values, notify) {
662 const unique = [...new Set(values.map(String))];
663 this._values = this.multiple ? unique : unique.slice(0, 1);
664 this.writeValue();
665 if (this.isConnected) this.render();
666 if (notify) emitChange(this, { values: this.values });
667 }
668 }
669
670 /**
671 * Controls a responsive native sidebar region and its trigger.
672 *
673 * @extends {HTMLElement}
674 */
675 export class ZenSidebar extends HTMLElementBase {
676 static get observedAttributes() {
677 return ["open"];
678 }
679
680 get open() {
681 return this.hasAttribute("open");
682 }
683
684 set open(value) {
685 this.toggleAttribute("open", Boolean(value));
686 }
687
688 connectedCallback() {
689 this._managed ||= new Map();
690 this._onClick ||= event => {
691 const trigger = eventElement(event)?.closest(
692 "[data-zen-sidebar-trigger]",
693 );
694 if (trigger !== this._trigger || isDisabled(trigger)) return;
695 this.setOpen(!this.open, true);
696 };
697 this._onKeydown ||= event => {
698 if (event.key !== "Escape" || !this.open) return;
699 event.preventDefault();
700 this.setOpen(false, true);
701 this._trigger?.focus();
702 };
703 this._onMediaChange ||= () => this.syncState();
704 this.addEventListener("click", this._onClick);
705 this.addEventListener("keydown", this._onKeydown);
706 this._observer ||= new MutationObserver(() => this.sync());
707 this._observer.observe(this, { childList: true });
708 this._media = globalThis.matchMedia?.("(max-width: 640px)") || null;
709 if (this._media?.addEventListener) {
710 this._media.addEventListener("change", this._onMediaChange);
711 } else {
712 this._media?.addListener?.(this._onMediaChange);
713 }
714 this.sync();
715 }
716
717 disconnectedCallback() {
718 this._observer?.disconnect();
719 this.removeEventListener("click", this._onClick);
720 this.removeEventListener("keydown", this._onKeydown);
721 if (this._media?.removeEventListener) {
722 this._media.removeEventListener("change", this._onMediaChange);
723 } else {
724 this._media?.removeListener?.(this._onMediaChange);
725 }
726 restoreAttributes(this._managed);
727 this._trigger = null;
728 this._panel = null;
729 this._media = null;
730 }
731
732 attributeChangedCallback() {
733 if (this.isConnected) this.syncState();
734 }
735
736 sync() {
737 const trigger = ownedElements(
738 this,
739 "[data-zen-sidebar-trigger]",
740 )[0] || null;
741 const panel = ownedElements(
742 this,
743 "[data-zen-sidebar-panel]",
744 )[0] || null;
745 if (trigger !== this._trigger || panel !== this._panel) {
746 if (this._trigger) restoreElement(this._managed, this._trigger);
747 if (this._panel) restoreElement(this._managed, this._panel);
748 this._trigger = trigger;
749 this._panel = panel;
750 }
751 if (!trigger || !panel) return;
752 const panelId = ensureId(this._managed, panel, "zen-sidebar-panel");
753 setManagedAttribute(
754 this._managed,
755 trigger,
756 "aria-controls",
757 panelId,
758 );
759 this.syncState();
760 }
761
762 syncState() {
763 if (!this._trigger || !this._panel) return;
764 setManagedAttribute(
765 this._managed,
766 this._panel,
767 "hidden",
768 this.open ? null : "",
769 );
770 setManagedAttribute(
771 this._managed,
772 this._trigger,
773 "aria-expanded",
774 String(this.open),
775 );
776 }
777
778 setOpen(open, notify) {
779 if (this.open === open) return;
780 this.open = open;
781 this.syncState();
782 if (notify) emitChange(this, { open });
783 }
784 }
785
786 const definitions = {
787 "zen-accordion": ZenAccordion,
788 "zen-collapsible": ZenCollapsible,
789 "zen-sidebar": ZenSidebar,
790 "zen-tabs": ZenTabs,
791 "zen-toggle": ZenToggle,
792 "zen-toggle-group": ZenToggleGroup,
793 };
794
795 const registry = globalThis.customElements;
796 if (registry) {
797 for (const [name, definition] of Object.entries(definitions)) {
798 if (!registry.get(name)) registry.define(name, definition);
799 }
800 }