2022-05-11 09:50:57 +02:00
|
|
|
import { ApplicationController } from './application_controller';
|
|
|
|
|
|
|
|
export class MenuButtonController extends ApplicationController {
|
|
|
|
static targets = ['button', 'menu'];
|
|
|
|
|
|
|
|
declare readonly buttonTarget: HTMLButtonElement;
|
|
|
|
declare readonly menuTarget: HTMLElement;
|
|
|
|
|
|
|
|
#teardown?: () => void;
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
this.setup();
|
|
|
|
}
|
|
|
|
|
|
|
|
disconnect(): void {
|
|
|
|
this.#teardown?.();
|
|
|
|
}
|
|
|
|
|
2023-01-16 21:31:07 +01:00
|
|
|
private get isOpen() {
|
|
|
|
return (this.element as HTMLElement).classList.contains('open');
|
|
|
|
}
|
|
|
|
|
2022-05-11 09:50:57 +02:00
|
|
|
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');
|
|
|
|
}
|
2022-11-02 17:17:00 +01:00
|
|
|
for (const dropdownItems of this.menuTarget.querySelectorAll(
|
|
|
|
'.dropdown-items'
|
|
|
|
)) {
|
|
|
|
dropdownItems.setAttribute('role', 'none');
|
|
|
|
}
|
|
|
|
for (const dropdownItems of this.menuTarget.querySelectorAll(
|
|
|
|
'.dropdown-items > li'
|
|
|
|
)) {
|
|
|
|
dropdownItems.setAttribute('role', 'none');
|
|
|
|
}
|
2022-05-11 09:50:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
this.on('click', (event) => {
|
|
|
|
const target = event.target as HTMLElement;
|
|
|
|
if (this.buttonTarget == target || this.buttonTarget.contains(target)) {
|
|
|
|
event.preventDefault();
|
|
|
|
|
2023-01-16 21:31:07 +01:00
|
|
|
if (this.isOpen) {
|
2022-05-11 09:50:57 +02:00
|
|
|
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') {
|
2022-10-06 16:06:10 +02:00
|
|
|
this.buttonTarget.setAttribute('aria-expanded', 'true');
|
2022-05-11 09:50:57 +02:00
|
|
|
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.#teardown = () =>
|
|
|
|
document.body.removeEventListener('click', onClickBody);
|
|
|
|
}
|
|
|
|
|
|
|
|
private close() {
|
|
|
|
this.buttonTarget.removeAttribute('aria-expanded');
|
|
|
|
this.menuTarget.parentElement?.classList.remove('open');
|
|
|
|
this.#teardown?.();
|
|
|
|
this.setFocusToMenuitem(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
private isClickOutside(target: HTMLElement) {
|
|
|
|
return (
|
|
|
|
target.isConnected &&
|
|
|
|
!this.element.contains(target) &&
|
|
|
|
!target.closest('reach-portal') &&
|
2023-01-16 21:31:07 +01:00
|
|
|
this.isOpen
|
2022-05-11 09:50:57 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|