import { html } from 'lit'; import { query } from 'lit/decorators.js'; import styles from './menu.styles.js'; import WebAwesomeElement from '../../internal/webawesome-element.js'; import type { CSSResultGroup } from 'lit'; import type WaMenuItem from '../menu-item/menu-item.component.js'; export interface MenuSelectEventDetail { item: WaMenuItem; } /** * @summary Menus provide a list of options for the user to choose from. * @documentation https://shoelace.style/components/menu * @status stable * @since 2.0 * * @slot - The menu's content, including menu items, menu labels, and dividers. * * @event {{ item: WaMenuItem }} wa-select - Emitted when a menu item is selected. */ export default class WaMenu extends WebAwesomeElement { static styles: CSSResultGroup = styles; @query('slot') defaultSlot: HTMLSlotElement; connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'menu'); } private handleClick(event: MouseEvent) { const menuItemTypes = ['menuitem', 'menuitemcheckbox']; const target = event.composedPath().find((el: Element) => menuItemTypes.includes(el?.getAttribute?.('role') || '')); if (!target) return; // This isn't true. But we use it for TypeScript checks below. const item = target as WaMenuItem; if (item.type === 'checkbox') { item.checked = !item.checked; } this.emit('wa-select', { detail: { item } }); } private handleKeyDown(event: KeyboardEvent) { // Make a selection when pressing enter or space if (event.key === 'Enter' || event.key === ' ') { const item = this.getCurrentItem(); event.preventDefault(); event.stopPropagation(); // Simulate a click to support @click handlers on menu items that also work with the keyboard item?.click(); } // Move the selection when pressing down or up else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { const items = this.getAllItems(); const activeItem = this.getCurrentItem(); let index = activeItem ? items.indexOf(activeItem) : 0; if (items.length > 0) { event.preventDefault(); event.stopPropagation(); if (event.key === 'ArrowDown') { index++; } else if (event.key === 'ArrowUp') { index--; } else if (event.key === 'Home') { index = 0; } else if (event.key === 'End') { index = items.length - 1; } if (index < 0) { index = items.length - 1; } if (index > items.length - 1) { index = 0; } this.setCurrentItem(items[index]); items[index].focus(); } } } private handleMouseDown(event: MouseEvent) { const target = event.target as HTMLElement; if (this.isMenuItem(target)) { this.setCurrentItem(target as WaMenuItem); } } private handleSlotChange() { const items = this.getAllItems(); // Reset the roving tab index when the slotted items change if (items.length > 0) { this.setCurrentItem(items[0]); } } private isMenuItem(item: HTMLElement) { return ( item.tagName.toLowerCase() === 'wa-menu-item' || ['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '') ); } /** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */ getAllItems() { return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => { if (el.inert || !this.isMenuItem(el)) { return false; } return true; }) as WaMenuItem[]; } /** * @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index. * The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item. */ getCurrentItem() { return this.getAllItems().find(i => i.getAttribute('tabindex') === '0'); } /** * @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and * `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item. */ setCurrentItem(item: WaMenuItem) { const items = this.getAllItems(); // Update tab indexes items.forEach(i => { i.setAttribute('tabindex', i === item ? '0' : '-1'); }); } render() { return html` `; } }