258 lines
6.3 KiB
TypeScript
258 lines
6.3 KiB
TypeScript
|
import { ApplicationController } from './application_controller';
|
||
|
|
||
|
export class MenuButtonController extends ApplicationController {
|
||
|
static targets = ['button', 'menu'];
|
||
|
|
||
|
declare readonly buttonTarget: HTMLButtonElement;
|
||
|
declare readonly menuTarget: HTMLElement;
|
||
|
|
||
|
#isOpen = false;
|
||
|
#teardown?: () => void;
|
||
|
|
||
|
connect() {
|
||
|
this.setup();
|
||
|
}
|
||
|
|
||
|
disconnect(): void {
|
||
|
this.#teardown?.();
|
||
|
}
|
||
|
|
||
|
private get isMenu() {
|
||
|
return !(this.element as HTMLElement).dataset.popover;
|
||
|
}
|
||
|
|
||
|
private setup() {
|
||
|
this.buttonTarget.setAttribute(
|
||
|
'aria-haspopup',
|
||
|
this.isMenu ? 'menu' : 'true'
|
||
|
);
|
||
|
this.buttonTarget.setAttribute('aria-controls', this.menuTarget.id);
|
||
|
if (!this.buttonTarget.id) {
|
||
|
this.buttonTarget.id = `${this.menuTarget.id}_button`;
|
||
|
}
|
||
|
|
||
|
this.menuTarget.setAttribute('aria-labelledby', this.buttonTarget.id);
|
||
|
this.menuTarget.setAttribute('role', this.isMenu ? 'menu' : 'region');
|
||
|
this.menuTarget.classList.add('fade-in-down');
|
||
|
this.menuTarget.setAttribute('tab-index', '-1');
|
||
|
|
||
|
if (this.isMenu) {
|
||
|
for (const menuItem of this.menuTarget.querySelectorAll('a')) {
|
||
|
menuItem.setAttribute('role', 'menuitem');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
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);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
private open(focusMenuItem: 'first' | 'last' = 'first') {
|
||
|
this.buttonTarget.setAttribute('aria-expanded', '');
|
||
|
this.menuTarget.parentElement?.classList.add('open');
|
||
|
this.menuTarget.focus();
|
||
|
|
||
|
const onClickBody = (event: Event) => {
|
||
|
const target = event.target as HTMLElement;
|
||
|
if (this.isClickOutside(target)) {
|
||
|
this.menuTarget.classList.remove('fade-in-down');
|
||
|
this.close();
|
||
|
}
|
||
|
};
|
||
|
requestAnimationFrame(() => {
|
||
|
if (focusMenuItem == 'first') {
|
||
|
this.setFocusToFirstMenuitem();
|
||
|
} else {
|
||
|
this.setFocusToLastMenuitem();
|
||
|
}
|
||
|
document.body.addEventListener('click', onClickBody);
|
||
|
});
|
||
|
|
||
|
this.#isOpen = true;
|
||
|
this.#teardown = () =>
|
||
|
document.body.removeEventListener('click', onClickBody);
|
||
|
}
|
||
|
|
||
|
private close() {
|
||
|
this.buttonTarget.removeAttribute('aria-expanded');
|
||
|
this.menuTarget.parentElement?.classList.remove('open');
|
||
|
this.#teardown?.();
|
||
|
this.setFocusToMenuitem(null);
|
||
|
this.#isOpen = false;
|
||
|
}
|
||
|
|
||
|
private isClickOutside(target: HTMLElement) {
|
||
|
return (
|
||
|
target.isConnected &&
|
||
|
!this.element.contains(target) &&
|
||
|
!target.closest('reach-portal') &&
|
||
|
this.#isOpen
|
||
|
);
|
||
|
}
|
||
|
|
||
|
private get currentMenuItem() {
|
||
|
return this.menuTarget.querySelector<HTMLElement>(
|
||
|
'[role="menuitem"]:focus'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
private get menuItems() {
|
||
|
return [
|
||
|
...this.menuTarget.querySelectorAll<HTMLElement>('[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':
|
||
|
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();
|
||
|
}
|
||
|
}
|
||
|
}
|