comparison design_system/src/components/overlays.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 nextOverlayId = 1;
2
3 function uniqueId(prefix) {
4 let id;
5 do id = `${prefix}-${nextOverlayId++}`;
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 restoreAttributes(state) {
28 for (const [element, attributes] of state) {
29 for (const [name, value] of attributes) {
30 if (value === null) element.removeAttribute(name);
31 else element.setAttribute(name, value);
32 }
33 }
34 state.clear();
35 }
36
37 function resetManagedAttribute(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 }
44
45 function ensureId(state, element, prefix) {
46 const existing = element.id && document.getElementById(element.id);
47 if (!element.id || (existing && existing !== element)) {
48 setManagedAttribute(state, element, "id", uniqueId(prefix));
49 }
50 return element.id;
51 }
52
53 function eventElement(event) {
54 return event.target instanceof Element ? event.target : null;
55 }
56
57 const OVERLAY_COMPONENTS = [
58 "zen-dialog",
59 "zen-alert-dialog",
60 "zen-sheet",
61 "zen-drawer",
62 "zen-popover",
63 "zen-hover-card",
64 "zen-tooltip",
65 ].join(",");
66
67 function ownedElement(host, selector) {
68 return [...host.querySelectorAll(selector)].find(
69 element => element.closest(OVERLAY_COMPONENTS) === host,
70 ) || null;
71 }
72
73 /**
74 * Wires a native modal dialog to explicit trigger and close controls.
75 *
76 * @extends {HTMLElement}
77 */
78 export class ZenDialog extends HTMLElement {
79 static dialogRole = null;
80
81 constructor() {
82 super();
83 this._managed = new Map();
84 this._open = false;
85 this._openedByComponent = false;
86 this._onTriggerClick = event => {
87 event.preventDefault();
88 this.open();
89 };
90 this._onDialogClick = event => {
91 const control = eventElement(event)?.closest("[data-zen-close]");
92 if (!control || !this._dialog?.contains(control)) return;
93 const value = control.hasAttribute("value")
94 ? control.getAttribute("value")
95 : undefined;
96 this.close(value);
97 };
98 this._onNativeClose = () => this.syncOpen(false);
99 }
100
101 connectedCallback() {
102 this._observer ||= new MutationObserver(() => this.sync());
103 this._observer.observe(this, { childList: true, subtree: true });
104 this.sync();
105 }
106
107 sync() {
108 const trigger = ownedElement(this, "button[data-zen-trigger]");
109 const dialog = ownedElement(this, "dialog");
110 if (trigger === this._trigger && dialog === this._dialog) return;
111 if (this._openedByComponent) this.close();
112 this.unbind();
113 this._trigger = trigger;
114 this._dialog = dialog;
115 if (!this._trigger || !this._dialog) return;
116
117 const id = ensureId(this._managed, this._dialog, this.localName);
118 setManagedAttribute(
119 this._managed,
120 this._trigger,
121 "aria-haspopup",
122 "dialog",
123 );
124 setManagedAttribute(this._managed, this._trigger, "aria-controls", id);
125 if (this.constructor.dialogRole) {
126 setManagedAttribute(
127 this._managed,
128 this._dialog,
129 "role",
130 this.constructor.dialogRole,
131 );
132 }
133 this._trigger.addEventListener("click", this._onTriggerClick);
134 this._dialog.addEventListener("click", this._onDialogClick);
135 this._dialog.addEventListener("close", this._onNativeClose);
136 this.syncOpen(this._dialog.open);
137 }
138
139 unbind() {
140 this._trigger?.removeEventListener("click", this._onTriggerClick);
141 this._dialog?.removeEventListener("click", this._onDialogClick);
142 this._dialog?.removeEventListener("close", this._onNativeClose);
143 restoreAttributes(this._managed);
144 this._trigger = null;
145 this._dialog = null;
146 this._open = false;
147 this._openedByComponent = false;
148 }
149
150 disconnectedCallback() {
151 this._observer?.disconnect();
152 if (this._openedByComponent) this.close();
153 this.unbind();
154 }
155
156 open() {
157 if (!this._dialog || this._dialog.open || this._open) return;
158 try {
159 if (typeof this._dialog.showModal === "function") {
160 this._dialog.showModal();
161 } else {
162 setManagedAttribute(this._managed, this._dialog, "open", "");
163 }
164 this._openedByComponent = true;
165 this.syncOpen(true);
166 } catch {
167 this.syncOpen(Boolean(this._dialog.open));
168 }
169 }
170
171 close(returnValue) {
172 if (!this._dialog || (!this._dialog.open && !this._open)) return;
173 if (typeof this._dialog.close === "function") {
174 if (returnValue === undefined) this._dialog.close();
175 else this._dialog.close(returnValue);
176 } else {
177 setManagedAttribute(this._managed, this._dialog, "open", null);
178 }
179 this.syncOpen(false);
180 }
181
182 syncOpen(open) {
183 this._open = open;
184 if (!open) this._openedByComponent = false;
185 if (this._trigger) {
186 setManagedAttribute(
187 this._managed,
188 this._trigger,
189 "aria-expanded",
190 String(open),
191 );
192 }
193 }
194 }
195
196 /**
197 * Presents a native modal with alert-dialog semantics.
198 *
199 * @extends {ZenDialog}
200 */
201 export class ZenAlertDialog extends ZenDialog {
202 static dialogRole = "alertdialog";
203 }
204
205 /**
206 * Presents a native dialog as an edge-aligned sheet.
207 *
208 * @extends {ZenDialog}
209 */
210 export class ZenSheet extends ZenDialog {}
211
212 /**
213 * Presents a native dialog as a drawer.
214 *
215 * @extends {ZenDialog}
216 */
217 export class ZenDrawer extends ZenDialog {}
218
219 function isPopoverOpen(element) {
220 try {
221 return element.matches(":popover-open");
222 } catch {
223 return false;
224 }
225 }
226
227 /**
228 * Connects a trigger to a native popover with an accessible fallback.
229 *
230 * @extends {HTMLElement}
231 */
232 export class ZenPopover extends HTMLElement {
233 constructor() {
234 super();
235 this._managed = new Map();
236 this._open = false;
237 this._onClick = event => {
238 const target = eventElement(event);
239 if (!target) return;
240 if (this._trigger?.contains(target)) {
241 event.preventDefault();
242 this.toggle();
243 } else if (this._content?.contains(target) &&
244 target.closest("[data-zen-close]")) {
245 this.hide();
246 }
247 };
248 this._onToggle = event => {
249 this.syncOpen(event.newState
250 ? event.newState === "open"
251 : isPopoverOpen(this._content));
252 };
253 this._onDocumentPointerDown = event => {
254 if (!this._native && this._open && !this.contains(event.target)) {
255 this.hide();
256 }
257 };
258 this._onDocumentKeyDown = event => {
259 if (!this._native && this._open && event.key === "Escape") {
260 this.hide();
261 this._trigger?.focus();
262 }
263 };
264 }
265
266 connectedCallback() {
267 this.addEventListener("click", this._onClick);
268 document.addEventListener("pointerdown", this._onDocumentPointerDown);
269 document.addEventListener("keydown", this._onDocumentKeyDown);
270 this._observer ||= new MutationObserver(() => this.sync());
271 this._observer.observe(this, { childList: true, subtree: true });
272 this.sync();
273 }
274
275 sync() {
276 const trigger = ownedElement(this, "[data-zen-trigger]");
277 const content = ownedElement(
278 this,
279 "[data-zen-content], [popover]",
280 );
281 if (trigger === this._trigger && content === this._content) return;
282 if (this._open) this.hide();
283 this.unbindElements();
284 this._trigger = trigger;
285 this._content = content;
286 if (!this._trigger || !this._content) return;
287
288 const id = ensureId(this._managed, this._content, "zen-popover");
289 setManagedAttribute(this._managed, this._trigger, "aria-controls", id);
290 this._native = typeof this._content.showPopover === "function" &&
291 typeof this._content.hidePopover === "function";
292 if (this._native && !this._content.hasAttribute("popover")) {
293 setManagedAttribute(this._managed, this._content, "popover", "auto");
294 }
295 if (this._native && "popoverTargetElement" in this._trigger) {
296 this._originalPopoverTarget = this._trigger.popoverTargetElement;
297 this._trigger.popoverTargetElement = this._content;
298 }
299 if (this._native) {
300 setManagedAttribute(this._managed, this._content, "hidden", null);
301 } else {
302 setManagedAttribute(this._managed, this._content, "hidden", "");
303 }
304
305 this._content.addEventListener("toggle", this._onToggle);
306 this.syncOpen(this._native && isPopoverOpen(this._content));
307 }
308
309 unbindElements() {
310 this._content?.removeEventListener("toggle", this._onToggle);
311 if (this._trigger && "popoverTargetElement" in this._trigger) {
312 this._trigger.popoverTargetElement = this._originalPopoverTarget || null;
313 }
314 restoreAttributes(this._managed);
315 this._trigger = null;
316 this._content = null;
317 this._native = false;
318 this._open = false;
319 this._originalPopoverTarget = null;
320 }
321
322 disconnectedCallback() {
323 this._observer?.disconnect();
324 this.removeEventListener("click", this._onClick);
325 document.removeEventListener("pointerdown", this._onDocumentPointerDown);
326 document.removeEventListener("keydown", this._onDocumentKeyDown);
327 if (this._open) this.hide();
328 this.unbindElements();
329 }
330
331 toggle() {
332 if (this._open || isPopoverOpen(this._content)) this.hide();
333 else this.show();
334 }
335
336 show() {
337 if (!this._content || this._open) return;
338 if (this._native) {
339 try {
340 this._content.showPopover();
341 } catch {
342 this._native = false;
343 setManagedAttribute(this._managed, this._content, "hidden", null);
344 }
345 } else {
346 setManagedAttribute(this._managed, this._content, "hidden", null);
347 }
348 this.syncOpen(true);
349 }
350
351 hide() {
352 if (!this._content || !this._open) return;
353 if (this._native) {
354 try {
355 this._content.hidePopover();
356 } catch {
357 this._native = false;
358 setManagedAttribute(this._managed, this._content, "hidden", "");
359 }
360 } else {
361 setManagedAttribute(this._managed, this._content, "hidden", "");
362 }
363 this.syncOpen(false);
364 }
365
366 syncOpen(open) {
367 this._open = open;
368 if (this._trigger) {
369 setManagedAttribute(
370 this._managed,
371 this._trigger,
372 "aria-expanded",
373 String(open),
374 );
375 }
376 if (!this._native && this._content) {
377 setManagedAttribute(
378 this._managed,
379 this._content,
380 "hidden",
381 open ? null : "",
382 );
383 }
384 }
385 }
386
387 class ZenTimedOverlay extends HTMLElement {
388 static delay = 150;
389 static tooltip = false;
390
391 constructor() {
392 super();
393 this._managed = new Map();
394 this._timer = null;
395 this._open = false;
396 this._pointerWithin = false;
397 this._focusWithin = false;
398 this._onPointerEnter = () => {
399 this._pointerWithin = true;
400 this.schedule(true);
401 };
402 this._onPointerLeave = event => {
403 if (event.relatedTarget instanceof Node &&
404 this.contains(event.relatedTarget)) return;
405 this._pointerWithin = false;
406 this.schedule(this._focusWithin);
407 };
408 this._onFocusIn = () => {
409 this._focusWithin = true;
410 this.schedule(true);
411 };
412 this._onFocusOut = event => {
413 if (event.relatedTarget instanceof Node &&
414 this.contains(event.relatedTarget)) return;
415 this._focusWithin = false;
416 this.schedule(this._pointerWithin);
417 };
418 this._onKeyDown = event => {
419 if (event.key !== "Escape" || !this._open) return;
420 clearTimeout(this._timer);
421 this._timer = null;
422 this.setOpen(false);
423 };
424 }
425
426 connectedCallback() {
427 this.addEventListener("keydown", this._onKeyDown);
428 this._observer ||= new MutationObserver(() => this.sync());
429 this._observer.observe(this, { childList: true, subtree: true });
430 this.sync();
431 }
432
433 sync() {
434 const trigger = ownedElement(this, "[data-zen-trigger]");
435 const content = ownedElement(this, "[data-zen-content]");
436 if (trigger === this._trigger && content === this._content) return;
437 this.unbindElements();
438 this._trigger = trigger;
439 this._content = content;
440 if (!this._trigger || !this._content) return;
441
442 const id = ensureId(this._managed, this._content, this.localName);
443 setManagedAttribute(this._managed, this._content, "hidden", "");
444 if (this.constructor.tooltip) {
445 setManagedAttribute(this._managed, this._content, "role", "tooltip");
446 const ids = new Set(
447 (this._trigger.getAttribute("aria-describedby") || "")
448 .split(/\s+/)
449 .filter(Boolean),
450 );
451 ids.add(id);
452 setManagedAttribute(
453 this._managed,
454 this._trigger,
455 "aria-describedby",
456 [...ids].join(" "),
457 );
458 } else {
459 setManagedAttribute(this._managed, this._trigger, "aria-controls", id);
460 setManagedAttribute(
461 this._managed,
462 this._trigger,
463 "aria-expanded",
464 "false",
465 );
466 }
467
468 for (const element of [this._trigger, this._content]) {
469 element.addEventListener("pointerenter", this._onPointerEnter);
470 element.addEventListener("pointerleave", this._onPointerLeave);
471 element.addEventListener("focusin", this._onFocusIn);
472 element.addEventListener("focusout", this._onFocusOut);
473 }
474 }
475
476 unbindElements() {
477 clearTimeout(this._timer);
478 this._timer = null;
479 for (const element of [this._trigger, this._content]) {
480 element?.removeEventListener("pointerenter", this._onPointerEnter);
481 element?.removeEventListener("pointerleave", this._onPointerLeave);
482 element?.removeEventListener("focusin", this._onFocusIn);
483 element?.removeEventListener("focusout", this._onFocusOut);
484 }
485 restoreAttributes(this._managed);
486 this._trigger = null;
487 this._content = null;
488 this._open = false;
489 this._pointerWithin = false;
490 this._focusWithin = false;
491 }
492
493 disconnectedCallback() {
494 this._observer?.disconnect();
495 this.removeEventListener("keydown", this._onKeyDown);
496 this.unbindElements();
497 }
498
499 schedule(open) {
500 clearTimeout(this._timer);
501 this._timer = setTimeout(
502 () => this.setOpen(open),
503 this.constructor.delay,
504 );
505 }
506
507 setOpen(open) {
508 if (!this._content || open === this._open) return;
509 this._open = open;
510 setManagedAttribute(
511 this._managed,
512 this._content,
513 "hidden",
514 open ? null : "",
515 );
516 if (!this.constructor.tooltip) {
517 setManagedAttribute(
518 this._managed,
519 this._trigger,
520 "aria-expanded",
521 String(open),
522 );
523 }
524 }
525 }
526
527 /**
528 * Shows supplemental interactive content on hover or focus.
529 *
530 * @extends {ZenTimedOverlay}
531 */
532 export class ZenHoverCard extends ZenTimedOverlay {
533 static delay = 150;
534 }
535
536 /**
537 * Shows a delayed non-interactive description on hover or focus.
538 *
539 * @extends {ZenTimedOverlay}
540 */
541 export class ZenTooltip extends ZenTimedOverlay {
542 static delay = 350;
543 static tooltip = true;
544 }
545
546 const MENU_COMPONENTS = [
547 "zen-dropdown-menu",
548 "zen-context-menu",
549 "zen-menubar",
550 "zen-navigation-menu",
551 ].join(",");
552 const MENU_SELECTOR = "[data-zen-menu], [role='menu'], [data-zen-content]";
553 const ITEM_SELECTOR = [
554 "[role='menuitem']",
555 "[role='menuitemcheckbox']",
556 "[role='menuitemradio']",
557 "[data-zen-menu-item]",
558 "a[href]",
559 "button",
560 "input[type='button']",
561 "input[type='submit']",
562 ].join(",");
563
564 class ZenMenu extends HTMLElement {
565 static contextMenu = false;
566 static menubar = false;
567 static multiple = false;
568
569 constructor() {
570 super();
571 this._managed = new Map();
572 this._pairs = [];
573 this._openPair = null;
574 this._onClick = event => {
575 const target = eventElement(event);
576 if (!target) return;
577 const pair = this._pairs.find(({ trigger }) =>
578 trigger.contains(target));
579 if (pair) {
580 event.preventDefault();
581 if (this._openPair === pair) this.close(false);
582 else this.open(pair, 0, this.constructor.contextMenu ? event : null);
583 return;
584 }
585 const item = target.closest(ITEM_SELECTOR);
586 if (this._openPair?.menu.contains(target) &&
587 item &&
588 !item.hasAttribute("disabled") &&
589 item.getAttribute("aria-disabled") !== "true") {
590 this.close(true);
591 }
592 };
593 this._onContextMenu = event => {
594 if (!this.constructor.contextMenu ||
595 this._pairs.some(({ menu }) => menu.contains(event.target))) return;
596 event.preventDefault();
597 this.open(this._pairs[0], 0, event);
598 };
599 this._onKeyDown = event => this.handleKeyDown(event);
600 this._onDocumentPointerDown = event => {
601 if (!this._openPair) return;
602 const { trigger, menu } = this._openPair;
603 if (!trigger.contains(event.target) && !menu.contains(event.target)) {
604 this.close(false);
605 }
606 };
607 }
608
609 connectedCallback() {
610 this.setupPairs();
611 this.addEventListener("click", this._onClick);
612 this.addEventListener("contextmenu", this._onContextMenu);
613 this.addEventListener("keydown", this._onKeyDown);
614 document.addEventListener("pointerdown", this._onDocumentPointerDown);
615 this._observer ||= new MutationObserver(() => this.refreshPairs());
616 this._observer.observe(this, { childList: true, subtree: true });
617 }
618
619 disconnectedCallback() {
620 this._observer?.disconnect();
621 this.removeEventListener("click", this._onClick);
622 this.removeEventListener("contextmenu", this._onContextMenu);
623 this.removeEventListener("keydown", this._onKeyDown);
624 document.removeEventListener("pointerdown", this._onDocumentPointerDown);
625 this._openPair = null;
626 restoreAttributes(this._managed);
627 this._pairs = [];
628 }
629
630 refreshPairs() {
631 if (this._openPair) this.close(false);
632 restoreAttributes(this._managed);
633 this._pairs = [];
634 this.setupPairs();
635 }
636
637 ownedElements(selector) {
638 return [...this.querySelectorAll(selector)].filter(element =>
639 element.closest(MENU_COMPONENTS) === this);
640 }
641
642 setupPairs() {
643 let triggers = this.ownedElements("[data-zen-trigger]");
644 const menus = this.ownedElements(MENU_SELECTOR);
645 triggers = triggers.filter(trigger =>
646 !menus.some(menu => menu.contains(trigger)));
647 if (!this.constructor.multiple) triggers = triggers.slice(0, 1);
648
649 const unused = new Set(menus);
650 for (const trigger of triggers) {
651 const controlled = trigger.getAttribute("aria-controls");
652 let menu = controlled
653 ? menus.find(candidate => candidate.id === controlled)
654 : null;
655 if (!menu) menu = [...unused][0];
656 if (!menu) continue;
657 unused.delete(menu);
658
659 const triggerId = ensureId(this._managed, trigger, `${this.localName}-trigger`);
660 const menuId = ensureId(this._managed, menu, `${this.localName}-menu`);
661 setManagedAttribute(this._managed, trigger, "aria-haspopup", "menu");
662 setManagedAttribute(this._managed, trigger, "aria-controls", menuId);
663 setManagedAttribute(this._managed, trigger, "aria-expanded", "false");
664 setManagedAttribute(this._managed, menu, "role", "menu");
665 setManagedAttribute(this._managed, menu, "aria-labelledby", triggerId);
666 setManagedAttribute(this._managed, menu, "hidden", "");
667 if (this.constructor.menubar) {
668 setManagedAttribute(this._managed, trigger, "role", "menuitem");
669 }
670 const pair = { trigger, menu };
671 this._pairs.push(pair);
672 this.prepareItems(pair);
673 }
674 if (this.constructor.menubar) {
675 setManagedAttribute(this._managed, this, "role", "menubar");
676 this.setMenubarTabStop(this._pairs[0]);
677 }
678 }
679
680 prepareItems(pair) {
681 const items = [...pair.menu.querySelectorAll(ITEM_SELECTOR)].filter(item =>
682 item.closest(MENU_SELECTOR) === pair.menu &&
683 !item.hidden &&
684 !item.hasAttribute("disabled") &&
685 item.getAttribute("aria-disabled") !== "true");
686 for (const item of items) {
687 if (!item.hasAttribute("role")) {
688 setManagedAttribute(this._managed, item, "role", "menuitem");
689 }
690 setManagedAttribute(this._managed, item, "tabindex", "-1");
691 }
692 return items;
693 }
694
695 open(pair, itemIndex = 0, pointerEvent = null) {
696 if (!pair) return;
697 if (this._openPair && this._openPair !== pair) this.close(false);
698 this._openPair = pair;
699 if (this.constructor.menubar) this.setMenubarTabStop(pair);
700 if (pointerEvent) {
701 rememberAttribute(this._managed, pair.menu, "style");
702 pair.menu.style.position = "fixed";
703 pair.menu.style.left = `${Number(pointerEvent.clientX) || 0}px`;
704 pair.menu.style.top = `${Number(pointerEvent.clientY) || 0}px`;
705 } else {
706 resetManagedAttribute(this._managed, pair.menu, "style");
707 }
708 setManagedAttribute(this._managed, pair.menu, "hidden", null);
709 setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "true");
710 const items = this.prepareItems(pair);
711 if (items.length) {
712 const index = itemIndex < 0 ? items.length - 1 : itemIndex;
713 setManagedAttribute(this._managed, items[index], "tabindex", "0");
714 items[index].focus();
715 } else {
716 setManagedAttribute(this._managed, pair.menu, "tabindex", "-1");
717 pair.menu.focus();
718 }
719 }
720
721 close(restoreFocus) {
722 if (!this._openPair) return;
723 const pair = this._openPair;
724 this._openPair = null;
725 setManagedAttribute(this._managed, pair.menu, "hidden", "");
726 setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "false");
727 resetManagedAttribute(this._managed, pair.menu, "style");
728 if (restoreFocus) pair.trigger.focus();
729 }
730
731 moveItem(pair, offset, edge = null) {
732 const active = document.activeElement;
733 const items = this.prepareItems(pair);
734 if (!items.length) return;
735 let index = items.indexOf(active);
736 if (edge === "first") index = 0;
737 else if (edge === "last") index = items.length - 1;
738 else index = (Math.max(index, 0) + offset + items.length) % items.length;
739 setManagedAttribute(this._managed, items[index], "tabindex", "0");
740 items[index].focus();
741 }
742
743 moveMenubarTrigger(pair, offset, openMenu) {
744 const index = this._pairs.indexOf(pair);
745 const next = this._pairs[
746 (index + offset + this._pairs.length) % this._pairs.length
747 ];
748 this.setMenubarTabStop(next);
749 if (openMenu) this.open(next);
750 else next.trigger.focus();
751 }
752
753 setMenubarTabStop(activePair) {
754 for (const pair of this._pairs) {
755 setManagedAttribute(
756 this._managed,
757 pair.trigger,
758 "tabindex",
759 pair === activePair ? "0" : "-1",
760 );
761 }
762 }
763
764 handleKeyDown(event) {
765 const target = eventElement(event);
766 if (!target) return;
767 const triggerPair = this._pairs.find(({ trigger }) =>
768 trigger.contains(target));
769 if (triggerPair) {
770 if (this.constructor.menubar &&
771 (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
772 event.preventDefault();
773 this.moveMenubarTrigger(
774 triggerPair,
775 event.key === "ArrowRight" ? 1 : -1,
776 false,
777 );
778 } else if (["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) {
779 event.preventDefault();
780 this.open(triggerPair, event.key === "ArrowUp" ? -1 : 0);
781 } else if (event.key === "Escape" && this._openPair) {
782 event.preventDefault();
783 this.close(true);
784 }
785 return;
786 }
787
788 const pair = this._pairs.find(({ menu }) => menu.contains(target));
789 if (!pair || this._openPair !== pair) return;
790 if (event.key === "ArrowDown") {
791 event.preventDefault();
792 this.moveItem(pair, 1);
793 } else if (event.key === "ArrowUp") {
794 event.preventDefault();
795 this.moveItem(pair, -1);
796 } else if (event.key === "Home" || event.key === "End") {
797 event.preventDefault();
798 this.moveItem(pair, 0, event.key === "Home" ? "first" : "last");
799 } else if (event.key === "Escape") {
800 event.preventDefault();
801 this.close(true);
802 } else if (event.key === "Tab") {
803 this.close(false);
804 } else if (this.constructor.menubar &&
805 (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
806 event.preventDefault();
807 this.moveMenubarTrigger(
808 pair,
809 event.key === "ArrowRight" ? 1 : -1,
810 true,
811 );
812 }
813 }
814 }
815
816 /**
817 * Provides an accessible action menu opened from a native trigger.
818 *
819 * @extends {ZenMenu}
820 */
821 export class ZenDropdownMenu extends ZenMenu {}
822
823 /**
824 * Opens an accessible action menu at the pointer location.
825 *
826 * @extends {ZenMenu}
827 */
828 export class ZenContextMenu extends ZenMenu {
829 static contextMenu = true;
830 }
831
832 /**
833 * Coordinates multiple keyboard-accessible application menus.
834 *
835 * @extends {ZenMenu}
836 */
837 export class ZenMenubar extends ZenMenu {
838 static menubar = true;
839 static multiple = true;
840 }
841
842 /**
843 * Provides keyboard-accessible navigation flyouts.
844 *
845 * @extends {ZenMenu}
846 */
847 export class ZenNavigationMenu extends ZenMenu {}
848
849 const definitions = {
850 "zen-dialog": ZenDialog,
851 "zen-alert-dialog": ZenAlertDialog,
852 "zen-sheet": ZenSheet,
853 "zen-drawer": ZenDrawer,
854 "zen-popover": ZenPopover,
855 "zen-hover-card": ZenHoverCard,
856 "zen-tooltip": ZenTooltip,
857 "zen-dropdown-menu": ZenDropdownMenu,
858 "zen-context-menu": ZenContextMenu,
859 "zen-menubar": ZenMenubar,
860 "zen-navigation-menu": ZenNavigationMenu,
861 };
862
863 for (const [name, constructor] of Object.entries(definitions)) {
864 if (!customElements.get(name)) customElements.define(name, constructor);
865 }