/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { animateTo, stopAnimations } from '../internal/animate'; import { getAnimation, setDefaultAnimation } from '../utilities/animation-registry'; import { HasSlotController } from '../internal/slot'; import { lockBodyScrolling, unlockBodyScrolling } from '../internal/scroll'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; import Modal from '../internal/modal'; import NileElement from '../internal/nile-element'; import { styles } from './nile-command-menu.css'; import '../nile-tag/nile-tag'; import type { CSSResultGroup } from 'lit'; import type { NileCommandMenuItem } from '../nile-command-menu-item/nile-command-menu-item'; import type { NileCommandMenuGroup } from '../nile-command-menu-group/nile-command-menu-group'; let commandMenuItemId = 0; /** A removable search scope shown in the "Searching for" row. */ export interface CommandMenuScope { label: string; value: string; } function scopeFromValue(entry: CommandMenuScope | string): CommandMenuScope { if (typeof entry === 'string') { return { label: entry, value: entry.toLowerCase() }; } return { label: entry.label, value: entry.value ?? entry.label.toLowerCase() }; } /** Normalises the `scopes` attribute (comma-separated string or JSON array) into scope objects. */ const scopesConverter = { fromAttribute(value: string | null): CommandMenuScope[] { if (!value) { return []; } const trimmed = value.trim(); if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed); return (Array.isArray(parsed) ? parsed : []).map(scopeFromValue); } catch { // fall through to comma-separated parsing } } return trimmed .split(',') .map(part => part.trim()) .filter(Boolean) .map(scopeFromValue); }, toAttribute(value: CommandMenuScope[]): string { return (value ?? []).map(scope => scope.label).join(', '); }, }; /** * A keyboard-driven command palette for fast, page-wide search and action execution. * * @tag nile-command-menu * * @summary A modal command menu (command palette) with built-in search, grouping and keyboard navigation. * @status stable * @since 2.0 * * @dependency nile-glyph * @dependency nile-tag * * @slot - The command content: `nile-command-menu-group` and/or `nile-command-menu-item` elements. * @slot empty - Custom content shown when no items match the current query. * @slot footer - Optional footer content. When empty, a built-in keyboard-hint row is shown (unless `nofooterhints`). * * @event nile-show - Emitted when the menu opens. * @event nile-after-show - Emitted after the menu opens and the animation completes. * @event nile-hide - Emitted when the menu starts to close. * @event nile-after-hide - Emitted after the menu closes and the animation completes. * @event nile-request-close - Emitted when the user attempts to close the menu (overlay click / Escape). Cancelable. * @event nile-search - Emitted when the search value changes. `detail: { value }`. * @event nile-select - Emitted when an item is selected. `detail: { value, item }`. * @event nile-scope-remove - Emitted when a scope tag is removed. `detail: { scope, scopes }`. * * @csspart base - The component's outer wrapper. * @csspart overlay - The overlay behind the panel. * @csspart panel - The command panel. * @csspart header - The search header. * @csspart input - The search input. * @csspart scopes - The "Searching for" scopes row. * @csspart scope-tag - Each scope tag in the scopes row. * @csspart list - The scrollable results list. * @csspart empty - The empty state shown when nothing matches. * @csspart footer - The footer. * @csspart hints - The built-in keyboard-hint row inside the footer. * * @cssproperty --width - The maximum width of the panel. * @cssproperty --max-height - The maximum height of the panel. */ @customElement('nile-command-menu') export class NileCommandMenu extends NileElement { static styles: CSSResultGroup = styles; private readonly hasSlotController = new HasSlotController(this, 'footer'); private modal: Modal; private originalTrigger: HTMLElement | null; @query('.command') command: HTMLElement; @query('.command__panel') panel: HTMLElement; @query('.command__overlay') overlay: HTMLElement; @query('.command__input') input: HTMLInputElement; /** Indicates whether the menu is open. Toggle this attribute, or use `show()` / `hide()`. */ @property({ type: Boolean, reflect: true }) open = false; /** Placeholder text shown in the search input. */ @property() placeholder = 'Search commands...'; /** Accessible label for the dialog and the search input. */ @property() label = 'Command menu'; /** Message shown when no items match the current query. Ignored when the `empty` slot is used. */ @property({ attribute: true }) noResultsMessage = 'No results found'; /** The current search value. */ @property() value = ''; /** * When set, the menu does not filter items itself; instead it emits `nile-search` so the host can * manage results. Useful for async/server-side search. */ @property({ type: Boolean, attribute: true }) customSearch = false; /** Keeps the menu open after an item is selected instead of closing it. */ @property({ type: Boolean, attribute: true }) keepOpenOnSelect = false; /** Prevents the menu from closing when the overlay is clicked. */ @property({ type: Boolean, attribute: true }) preventOverlayClose = false; /** Hides the built-in keyboard-hint footer (navigate / select / close). The `footer` slot always overrides it. */ @property({ type: Boolean, attribute: true }) noFooterHints = false; /** * Active search scopes shown as removable tags in the "Searching for" row. Items are filtered so that * only those with no `scope` or whose `scope` matches an active scope are shown. Set via the `scopes` * attribute (comma-separated labels or a JSON array) or the property (array of `{ label, value }`). */ @property({ converter: scopesConverter }) scopes: CommandMenuScope[] = []; /** Label shown before the scope tags. */ @property({ attribute: true }) scopesLabel = 'Searching for'; /** True when the current query matches no items. */ @state() private hasResults = true; connectedCallback() { super.connectedCallback(); this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); this.modal = new Modal(this); } firstUpdated() { this.command.hidden = !this.open; if (this.open) { this.addOpenListeners(); this.modal.activate(); lockBodyScrolling(this); } } disconnectedCallback() { super.disconnectedCallback(); unlockBodyScrolling(this); } /** Gets all command items in the menu (across groups), in document order. */ private getAllItems(): NileCommandMenuItem[] { return [...this.querySelectorAll('nile-command-menu-item')]; } /** Gets the items that are currently visible (matching the query) and not disabled. */ private getVisibleItems(): NileCommandMenuItem[] { return this.getAllItems().filter(item => !item.hidden && !item.disabled); } private getActiveItem(): NileCommandMenuItem | undefined { return this.getVisibleItems().find(item => item.active); } private setActiveItem(item: NileCommandMenuItem | undefined) { this.getAllItems().forEach(i => { i.active = i === item; }); if (item) { if (!item.id) { item.id = `nile-command-menu-item-${++commandMenuItemId}`; } this.input?.setAttribute('aria-activedescendant', item.id); item.scrollIntoView({ block: 'nearest' }); } else { this.input?.removeAttribute('aria-activedescendant'); } } /** Applies the current query to the items, hiding non-matching items and empty groups. */ private filterItems() { const items = this.getAllItems(); if (this.customSearch) { // The host owns visibility; just refresh the empty state and active item. this.hasResults = this.getVisibleItems().length > 0; this.updateGroupVisibility(); this.ensureActiveItem(); return; } const query = this.value.trim().toLowerCase(); const activeScopes = this.scopes.map(scope => scope.value); items.forEach(item => { const matchesQuery = query.length === 0 || item.getFilterText().includes(query); const matchesScope = activeScopes.length === 0 || !item.scope || activeScopes.includes(item.scope); item.hidden = !(matchesQuery && matchesScope); }); this.updateGroupVisibility(); this.hasResults = this.getVisibleItems().length > 0; this.ensureActiveItem(); } /** Hides groups that have no visible items. */ private updateGroupVisibility() { const groups = [...this.querySelectorAll('nile-command-menu-group')]; groups.forEach(group => { const hasVisibleItem = [ ...group.querySelectorAll('nile-command-menu-item'), ].some(item => !item.hidden); group.hidden = !hasVisibleItem; }); } /** Ensures exactly one visible item is active, defaulting to the first. */ private ensureActiveItem() { const visible = this.getVisibleItems(); const current = this.getActiveItem(); if (visible.length === 0) { this.setActiveItem(undefined); return; } if (!current || !visible.includes(current)) { this.setActiveItem(visible[0]); } } private handleInput(event: Event) { this.value = (event.target as HTMLInputElement).value; this.emit('nile-search', { value: this.value }); this.filterItems(); } @watch('scopes', { waitUntilFirstUpdate: true }) handleScopesChange() { this.filterItems(); } private handleScopeRemove(scope: CommandMenuScope) { this.scopes = this.scopes.filter(s => s.value !== scope.value); this.emit('nile-scope-remove', { scope, scopes: this.scopes }); this.filterItems(); } private handleSlotChange() { this.filterItems(); } private handlePanelKeyDown(event: KeyboardEvent) { const visible = this.getVisibleItems(); if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { if (visible.length === 0) { return; } event.preventDefault(); const current = this.getActiveItem(); let index = current ? visible.indexOf(current) : -1; if (event.key === 'ArrowDown') index++; else if (event.key === 'ArrowUp') index--; else if (event.key === 'Home') index = 0; else if (event.key === 'End') index = visible.length - 1; if (index < 0) index = visible.length - 1; if (index > visible.length - 1) index = 0; this.setActiveItem(visible[index]); return; } if (event.key === 'Enter') { const current = this.getActiveItem(); if (current) { event.preventDefault(); this.selectItem(current); } } } private handleListClick(event: MouseEvent) { const item = (event.target as HTMLElement).closest( 'nile-command-menu-item' ); if (item && !item.disabled) { this.selectItem(item); } } private selectItem(item: NileCommandMenuItem) { this.emit('nile-select', { value: item.value, item }); if (!this.keepOpenOnSelect) { this.hide(); } } private requestClose(source: 'overlay' | 'keyboard') { const requestClose = this.emit( 'nile-request-close', { source }, undefined, undefined, true ); if (requestClose.defaultPrevented) { return; } if (source === 'overlay' && this.preventOverlayClose) { return; } this.hide(); } private addOpenListeners() { document.addEventListener('keydown', this.handleDocumentKeyDown); } private removeOpenListeners() { document.removeEventListener('keydown', this.handleDocumentKeyDown); } private handleDocumentKeyDown(event: KeyboardEvent) { if (this.open && event.key === 'Escape') { event.stopPropagation(); this.requestClose('keyboard'); } } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open) { this.emit('nile-show'); this.addOpenListeners(); this.originalTrigger = document.activeElement as HTMLElement; this.modal.activate(); lockBodyScrolling(this); await stopAnimations(this.command); this.command.hidden = false; // Reset query and selection each time the menu opens. this.value = ''; this.filterItems(); requestAnimationFrame(() => { this.input?.focus({ preventScroll: true }); }); const panelAnimation = getAnimation(this, 'command.show', { dir: 'ltr' }); const overlayAnimation = getAnimation(this, 'command.overlay.show', { dir: 'ltr' }); await Promise.all([ animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options), animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options), ]); this.emit('nile-after-show'); } else { this.emit('nile-hide'); this.removeOpenListeners(); this.modal.deactivate(); await stopAnimations(this.command); const panelAnimation = getAnimation(this, 'command.hide', { dir: 'ltr' }); const overlayAnimation = getAnimation(this, 'command.overlay.hide', { dir: 'ltr' }); await Promise.all([ animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options), animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options), ]); this.command.hidden = true; unlockBodyScrolling(this); const trigger = this.originalTrigger; if (typeof trigger?.focus === 'function') { setTimeout(() => trigger.focus()); } this.emit('nile-after-hide'); } } /** Renders the "Searching for" scopes row when scopes are active. */ private renderScopes() { if (this.scopes.length === 0) { return nothing; } return html`
${this.scopesLabel}
${this.scopes.map( scope => html` this.handleScopeRemove(scope)} > ${scope.label} ` )}
`; } /** Renders the built-in keyboard-hint footer (used as the `footer` slot's fallback content). */ private renderHints() { return html`
to navigate to select esc to close
`; } /** Shows the command menu. */ async show() { if (this.open) { return undefined; } this.open = true; return waitForEvent(this, 'nile-after-show'); } /** Hides the command menu. */ async hide() { if (!this.open) { return undefined; } this.open = false; return waitForEvent(this, 'nile-after-hide'); } render() { return html`
this.requestClose('overlay')} >
`; } } setDefaultAnimation('command.show', { keyframes: [ { opacity: 0, scale: 0.95 }, { opacity: 1, scale: 1 }, ], options: { duration: 200, easing: 'ease' }, }); setDefaultAnimation('command.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.95 }, ], options: { duration: 200, easing: 'ease' }, }); setDefaultAnimation('command.overlay.show', { keyframes: [{ opacity: 0 }, { opacity: 1 }], options: { duration: 200 }, }); setDefaultAnimation('command.overlay.hide', { keyframes: [{ opacity: 1 }, { opacity: 0 }], options: { duration: 200 }, }); export default NileCommandMenu; declare global { interface HTMLElementTagNameMap { 'nile-command-menu': NileCommandMenu; } }