import { ApplicationController } from './application_controller'; export class MenuButtonController extends ApplicationController { static targets = ['button', 'menu']; declare readonly buttonTarget: HTMLButtonElement; declare readonly menuTarget: HTMLElement; connect() { this.setup(); } private get isOpen() { return (this.element as HTMLElement).classList.contains('open'); } private get isMenu() { return this.menuTarget.getAttribute('role') == 'menu'; } private setup() { // see: // To progressively enhance this navigation widget that is by default accessible, // the class to hide the menu and the inclusion of tabindex="-1" on the interactive menuitem // content should be added with JavaScript on load. this.menuTarget.classList.add('fade-in-down'); if (this.isMenu) { this.menuItems.map((menuItem) => menuItem.setAttribute('tabindex', '-1')); } this.on('click', (event) => { const target = event.target as HTMLElement; if (this.buttonTarget == target || this.buttonTarget.contains(target)) { event.preventDefault(); if (this.isOpen) { this.close(); } else { this.open(); } } }); this.on('keydown', (event: KeyboardEvent) => { const target = event.target as HTMLElement; if (this.buttonTarget == target) { this.onButtonKeydown(event); } else if ( this.isMenu && (this.menuTarget == target || this.menuTarget.contains(target)) ) { this.onMenuKeydown(event); } }); this.on(document.body, 'click', (event) => { const target = event.target as HTMLElement; if (this.isOpen && this.isClickOutside(target)) { this.menuTarget.classList.remove('fade-in-down'); this.close(); } }); } private open(focusMenuItem: 'first' | 'last' = 'first') { this.buttonTarget.setAttribute('aria-expanded', 'true'); this.menuTarget.parentElement?.classList.add('open'); this.menuTarget.focus(); requestAnimationFrame(() => { if (focusMenuItem == 'first') { this.setFocusToFirstMenuitem(); } else { this.setFocusToLastMenuitem(); } }); } private close() { this.buttonTarget.setAttribute('aria-expanded', 'false'); this.menuTarget.parentElement?.classList.remove('open'); this.setFocusToMenuitem(null); } private isClickOutside(target: HTMLElement) { return ( target.isConnected && !this.element.contains(target) && !target.closest('reach-portal') && this.isOpen ); } private get currentMenuItem() { return this.menuTarget.querySelector( '[role="menuitem"]:focus' ); } private get menuItems() { return [ ...this.menuTarget.querySelectorAll('[role="menuitem"]') ]; } private setFocusToMenuitem(menuItem: HTMLElement | null) { if (menuItem) { menuItem.focus(); } else { this.buttonTarget.focus(); } } private setFocusToFirstMenuitem() { this.setFocusToMenuitem(this.menuItems[0]); } private setFocusToLastMenuitem() { const length = this.menuItems.length; this.setFocusToMenuitem(this.menuItems[length - 1]); } setFocusToPreviousMenuitem() { const { currentMenuItem, menuItems } = this; if (currentMenuItem) { const index = menuItems.indexOf(currentMenuItem); if (index == 0) { this.setFocusToLastMenuitem(); } else { this.setFocusToMenuitem(menuItems[index - 1]); } } } setFocusToNextMenuitem() { const { currentMenuItem, menuItems } = this; if (currentMenuItem) { const index = menuItems.indexOf(currentMenuItem); if (index == menuItems.length - 1) { this.setFocusToFirstMenuitem(); } else { this.setFocusToMenuitem(menuItems[index + 1]); } } } performMenuAction(target: EventTarget | null) { target?.dispatchEvent(new Event('click')); } private onButtonKeydown(event: KeyboardEvent) { let stopPropagation = false; switch (event.key) { case ' ': case 'Enter': if (this.isOpen) { this.close(); } else { this.open(); } stopPropagation = true; break; case 'ArrowDown': case 'Down': this.open(); stopPropagation = true; break; case 'Esc': case 'Escape': this.close(); stopPropagation = true; break; case 'Up': case 'ArrowUp': this.open('last'); stopPropagation = true; break; default: break; } if (stopPropagation) { event.stopPropagation(); event.preventDefault(); } } onMenuKeydown(event: KeyboardEvent) { let stopPropagation = false; if (event.ctrlKey || event.altKey || event.metaKey) { return; } if (event.shiftKey) { if (event.key == 'Tab') { this.close(); stopPropagation = true; } } else { switch (event.key) { case ' ': this.performMenuAction(event.target); stopPropagation = true; break; case 'Esc': case 'Escape': this.close(); stopPropagation = true; break; case 'Up': case 'ArrowUp': this.setFocusToPreviousMenuitem(); stopPropagation = true; break; case 'ArrowDown': case 'Down': this.setFocusToNextMenuitem(); stopPropagation = true; break; case 'Home': case 'PageUp': this.setFocusToFirstMenuitem(); stopPropagation = true; break; case 'End': case 'PageDown': this.setFocusToLastMenuitem(); stopPropagation = true; break; case 'Tab': this.close(); break; default: break; } } if (stopPropagation) { event.stopPropagation(); event.preventDefault(); } } }