import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { query } from 'lit/decorators.js'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup } from 'lit'; import type NileMenuItem from '../nile-menu-item/nile-menu-item'; import { styles } from './nile-menu.css'; import { HasSlotController } from '../internal/slot'; /** * An nile-menu element. * @summary Menus provide a list of options for the user to choose from. * * @slot - The menu's content, including menu items, menu labels, and dividers. * * @event nile-select - Emitted when a menu item is selected. * @fires count-changed - Indicates when the count changes * @slot - This element has a slot * @csspart button - The button */ @customElement('nile-menu') export class NileMenu extends NileElement { static styles: CSSResultGroup = styles; @state() searchValue: string = ''; @state() searchWidth: number = 0; @property({ type: Boolean, reflect: true }) searchEnabled = false; @property({ type: Boolean, reflect: true }) customSearch = false; @property({ type: Boolean }) showNoResults: boolean = false; @property({ type: Boolean }) allowSpaceKey: boolean = false; @property({ type: String }) noResultsMessage: string = 'No results found'; @query('slot:not([name])') defaultSlot!: HTMLSlotElement; private readonly hasSlotController = new HasSlotController( this, 'menu__footer', 'menu__header' ); connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'menu'); } private handleClick(event: MouseEvent) { const target = event.target as HTMLElement; const item = target.closest('nile-menu-item'); if (item?.hasSubMenu) { return; } // if (!item || item.disabled || item.inert) { if (!item || item.disabled) { return; } if (item.type === 'checkbox') { item.checked = !item.checked; } this.emit('nile-select', { value: item.value }); } private handleKeyDown(event: KeyboardEvent) { // Make a selection when pressing enter if (event.key === 'Enter') { const item = this.getCurrentItem(); event.preventDefault(); item?.click(); } // Prevent scrolling when space is pressed (ONLY if not typing) if (!this.allowSpaceKey && event.key === ' ') { event.preventDefault(); } // Move selection 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(); 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 NileMenuItem); } } 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() === 'nile-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 (!this.isMenuItem(el)) { // if (el.inert || !this.isMenuItem(el)) { return false; } return true; } ) as NileMenuItem[]; } /** * @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: NileMenuItem) { const items = this.getAllItems(); // Update tab indexes items.forEach(i => { i.setAttribute('tabindex', i === item ? '0' : '-1'); }); } private handleSearchChange(e: any) { let items = this.getAllItems(); let searchValue = e.target.value; if (this.customSearch) { this.emit('nile-search-value', { value: searchValue }); return; } let counter = 0; items.map(curr_item => { this.searchWidth = Math.max(this.searchWidth, curr_item.offsetWidth); if ( curr_item.innerText.toLowerCase().includes(e.target.value.toLowerCase()) ) { curr_item.style.display = 'block'; counter++; } else { curr_item.style.display = 'none'; } }); this.showNoResults = counter === 0; } render() { return html` ${this.searchEnabled ? html`
` : html``} `; } } export default NileMenu; declare global { interface HTMLElementTagNameMap { 'nile-menu': NileMenu; } }