import {LitElement, html, TemplateResult} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; import {repeat} from 'lit/directives/repeat.js'; import {live} from 'lit/directives/live.js'; import {createRef, ref} from 'lit-html/directives/ref.js'; import {classMap} from 'lit/directives/class-map.js'; import hotkeys from 'hotkeys-js'; import './ninja-header.js'; import './ninja-action.js'; import {INinjaAction} from './interfaces/ininja-action.js'; import {NinjaHeader} from './ninja-header.js'; import {NinjaAction} from './ninja-action.js'; import {footerHtml} from './ninja-footer.js'; import {baseStyles} from './base-styles.js'; import {commandScore} from './command-score'; @customElement('ninja-keys') export class NinjaKeys extends LitElement { static override styles = [baseStyles]; _ignorePrefixesSplit: string[] | null = null; /** * Search placeholder text */ @property({type: String}) placeholder = 'Type a command or search...'; /** * If true will register all hotkey for all actions */ @property({type: Boolean}) disableHotkeys = false; /** * Show or hide breadcrumbs on header */ @property({type: Boolean}) hideBreadcrumbs = false; /** * Show or hide breadcrumbs on header */ @property({type: String}) ignorePrefixes = ''; /** * Open or hide shorcut */ @property() openHotkey = 'cmd+k,ctrl+k'; /** * Navigation Up hotkey */ @property() navigationUpHotkey = 'up,shift+tab'; /** * Navigation Down hotkey */ @property() navigationDownHotkey = 'down,tab'; /** * Close hotkey */ @property() closeHotkey = 'esc'; /** * Go back on one level if has parent menu */ @property() goBackHotkey = 'backspace'; /** * Select action and execute handler or open submenu */ @property() selectHotkey = 'enter'; // enter,space /** * Show or hide breadcrumbs on header */ @property({type: Boolean}) hotKeysJoinedView = false; /** * Disable load material icons font on connect * If you use custom icons. * Set this attribute to prevent load default icons font */ @property({type: Boolean}) noAutoLoadMdIcons = false; @property({type: Number}) numRecentActions = 0; /** * Array of actions */ @property({ type: Array, hasChanged() { // Forced to trigger changed event always. // Because of a lot of framework pattern wrap object with an Observer, like vue2. // That's why object passed to web component always same and no render triggered. Issue #9 return true; }, }) data = [] as Array; /** * Public methods */ /** * Show a modal */ open(options: {parent?: string; search?: string} = {}) { this._bump = true; this.visible = true; this._headerRef.value!.focusSearch(); if (this._actionMatches.length > 0) { this._selected = this._actionMatches[0]; } this.setParent(options.parent); this._headerRef.value?.setSearch(options.search ?? ''); this._search = options.search ?? ''; setTimeout(() => { this._wrapperRef.value?.querySelector('.actions-list')?.scrollTo({top: 0}); }, 0); } /** * Close modal */ close() { this._bump = false; this.visible = false; } /** * Navigate to group of actions * @param parent id of parent group/action */ setParent(parent?: string) { if (!parent) { this._currentRoot = undefined; // this.breadcrumbs = []; } else { this._currentRoot = parent; } this._selected = undefined; this._search = ''; this._headerRef.value!.setSearch(''); } /** * Show or hide element */ @state() visible = false; /** * Temproray used for animation effect. TODO: change to animate logic */ @state() private _bump = true; @state() private _actionMatches = [] as Array; @state() private _search = ''; @state() private _currentRoot?: string; @state() private get breadcrumbs() { const path: string[] = []; let parentAction = this._selected?.parent; if (parentAction) { path.push(parentAction); while (parentAction) { // const action = this._flatData.find((a) => a.id === parentAction); const action = ([] as INinjaAction[]).find((a) => a.id === parentAction); if (action?.parent) { path.push(action.parent); } parentAction = action ? action.parent : undefined; } } return path.reverse(); } @state() private _selected?: INinjaAction; override connectedCallback() { super.connectedCallback(); if (!this.noAutoLoadMdIcons) { document.fonts.load('24px Material Icons', 'apps').then(() => {}); } this._registerInternalHotkeys(); } override disconnectedCallback() { super.disconnectedCallback(); this._unregisterInternalHotkeys(); } private _registerInternalHotkeys() { if (this.openHotkey) { hotkeys(this.openHotkey, (event) => { event.preventDefault(); this.visible ? this.close() : this.open(); }); } if (this.selectHotkey) { hotkeys(this.selectHotkey, (event) => { if (!this.visible) { return; } event.preventDefault(); this._actionSelected(this._actionMatches[this._selectedIndex]); }); } if (this.goBackHotkey) { hotkeys(this.goBackHotkey, (event) => { if (!this.visible) { return; } if (!this._search) { event.preventDefault(); this._goBack(); } }); } if (this.navigationDownHotkey) { hotkeys(this.navigationDownHotkey, (event) => { if (!this.visible) { return; } event.preventDefault(); if (this._selectedIndex >= this._actionMatches.length - 1) { this._selected = this._actionMatches[0]; } else { this._selected = this._actionMatches[this._selectedIndex + 1]; } }); } if (this.navigationUpHotkey) { hotkeys(this.navigationUpHotkey, (event) => { if (!this.visible) { return; } event.preventDefault(); if (this._selectedIndex === 0) { this._selected = this._actionMatches[this._actionMatches.length - 1]; } else { this._selected = this._actionMatches[this._selectedIndex - 1]; } }); } if (this.closeHotkey) { hotkeys(this.closeHotkey, () => { if (!this.visible) { return; } this.close(); }); } } private _unregisterInternalHotkeys() { if (this.openHotkey) { hotkeys.unbind(this.openHotkey); } if (this.selectHotkey) { hotkeys.unbind(this.selectHotkey); } if (this.goBackHotkey) { hotkeys.unbind(this.goBackHotkey); } if (this.navigationDownHotkey) { hotkeys.unbind(this.navigationDownHotkey); } if (this.navigationUpHotkey) { hotkeys.unbind(this.navigationUpHotkey); } if (this.closeHotkey) { hotkeys.unbind(this.closeHotkey); } } private _actionFocused(index: INinjaAction, $event: MouseEvent) { // this.selectedIndex = index; this._selected = index; ($event.target as NinjaAction).ensureInView(); } private _onTransitionEnd() { this._bump = false; } private _goBack() { const parent = this.breadcrumbs.length > 1 ? this.breadcrumbs[this.breadcrumbs.length - 2] : undefined; this.setParent(parent); } private _headerRef = createRef(); private _wrapperRef = createRef(); override render() { const classes = { bump: this._bump, 'modal-content': true, }; const menuClasses = { visible: this.visible, modal: true, isLoadingItems: false, }; let searchNoPrefix = this._search; this._ignorePrefixesSplit ??= this.ignorePrefixes !== '' ? this.ignorePrefixes.split(',') : []; this._ignorePrefixesSplit?.some((prefix: string) => { if (searchNoPrefix.startsWith(prefix)) { searchNoPrefix = searchNoPrefix.substring(prefix.length); return true; } return false; }); searchNoPrefix = searchNoPrefix.trim(); const matchInidices: {[label: string]: number[]} = {}; const results: {score: number; item: INinjaAction}[] = []; const items = this._currentRoot ? this.data.find((item) => item.id === this._currentRoot)?.children ?? [] : this.data; items.forEach((item, index) => { if (item === 'loading') { menuClasses.isLoadingItems = true; return; } if (typeof item === 'function') { const parent = this.data.find((item) => item.id === this._currentRoot)!; parent.children?.splice(index, 1, 'loading'); menuClasses.isLoadingItems = true; item().then((result: INinjaAction[]) => { parent.children?.splice(index, 1, ...result); this.render(); }); return; } const result = commandScore(item.title, searchNoPrefix); // global search for items on root // if ((this._currentRoot || !searchNoPrefix) && item.parent !== this._currentRoot) { return; } matchInidices[item.title] = result.indices; if (result.score > 0) { results.push({score: result.score, item: item}); } }); const actionMatches = ( searchNoPrefix ? results.sort((a, b) => { if (a.score === b.score) { return a.item.title.localeCompare(b.item.title); } return b.score - a.score; }) : results ).map((suggestion) => suggestion.item); const sections = actionMatches.reduce( (entryMap, e) => entryMap.set(e.section, [...(entryMap.get(e.section) || []), e]), new Map() ); this._actionMatches = [...sections.values()].flat(); if (this._actionMatches.length > 0 && this._selectedIndex === -1) { this._selected = this._actionMatches[0]; } if (this._actionMatches.length === 0) { this._selected = undefined; } const isShowTitle = !this._currentRoot && this.numRecentActions !== 0 && !searchNoPrefix; const actionsList = (actions: INinjaAction[]) => html` ${repeat( actions, (action) => action.id, (action, index) => { const title = !isShowTitle ? '' : index === 0 ? html`
Recently Used
` : this.numRecentActions === index ? html`
Other Commands
` : ''; return html`${title} this._actionFocused(action, event)} @actionsSelected=${(event: CustomEvent) => this._actionSelected(event.detail)} .action=${action} .matchIndices=${matchInidices[action.title]} >`; } )}`; const itemTemplates: TemplateResult[] = []; sections.forEach((actions, section) => { const header = section ? html`
${section}
` : undefined; itemTemplates.push(html`${header}${actionsList(actions)}`); }); return html`
) => this.setParent(event.detail.parent)} @close=${this.close} > ${footerHtml}
`; } private get _selectedIndex(): number { if (!this._selected) { return -1; } return this._actionMatches.indexOf(this._selected); } private _actionSelected(action?: INinjaAction) { // fire selected event even when action is empty/not selected, // so possible handle api search for example this.dispatchEvent( new CustomEvent('selected', { detail: {search: this._search, action}, bubbles: true, composed: true, }) ); if (!action) { return; } if (action.children && action.children?.length > 0) { this._currentRoot = action.id; this._search = ''; } this._headerRef.value!.setSearch(''); this._headerRef.value!.focusSearch(); if (action.handler) { const result = action.handler(action); if (!result?.keepOpen) { this.close(); } } this._bump = true; } private async _handleInput(event: CustomEvent<{search: string}>) { this._search = event.detail.search; await this.updateComplete; // Focus on the first item on input change // this._selected = this._actionMatches[0]; this.dispatchEvent( new CustomEvent('change', { detail: {search: this._search, actions: this._actionMatches}, bubbles: true, composed: true, }) ); } private _overlayClick(event: Event) { if ((event.target as HTMLElement)?.classList.contains('modal')) { this.close(); } } } declare global { interface HTMLElementTagNameMap { 'ninja-keys': NinjaKeys; } }