UNPKG

@3mo/command-palette

Version:

Command-palettes are a common way to provide users with a list of actions they can perform in a given context.

336 lines (319 loc) 10.2 kB
var CommandPalette_1; import { __decorate } from "tslib"; import { Component, bind, component, css, eventListener, html, query, state } from '@a11d/lit'; import { ApplicationTopLayer } from '@a11d/lit-application'; import { FetcherController } from '@3mo/fetcher-controller'; let CommandPalette = CommandPalette_1 = class CommandPalette extends Component { constructor() { super(...arguments); this.popover = 'auto'; this.keyword = ''; this.dataSources = [...CommandPalette_1.dataSources].sort((a, b) => a.order - b.order); this.fetcherController = new FetcherController(this, { throttle: 500, fetch: () => this.keyword ? this.getKeywordDataTemplate() : this.getInitialDataTemplate(), }); } static get instance() { return this._instance ?? (this._instance = new CommandPalette_1()); } static open() { ApplicationTopLayer.instance.appendChild(this.instance); this.instance.showPopover(); } handleKeyDown(event) { if (!this.matches(':popover-open')) { return; } if (event.key === 'Tab') { event.preventDefault(); const reverse = event.shiftKey; const list = [undefined, ...this.dataSources.map(ds => ds.id)]; const currentIndex = !this.filteredDataSourceId ? 0 : list.indexOf(this.filteredDataSourceId); const nextIndex = ((currentIndex + (reverse ? -1 : 1)) + list.length) % list.length; this.filteredDataSourceId = list[nextIndex % list.length]; } } handleToggle(event) { if (event.newState === 'open') { this.searchField.focus(); this.searchField.select(); if (this.list) { this.list.focusController.focusIn(); this.list.focusController.focusedItemIndex = 0; } } else if (event.newState === 'closed') { if (this.list) { this.list.focusController.focusedItemIndex = undefined; this.list.focusController.focusOut(); } } } // Handling "close on outside click" manually because the host IS the backdrop as :host(::backdrop) does not work. handleClick(event) { if (event.target === this) { this.hidePopover(); } } async getInitialDataTemplate() { const all = await Promise.allSettled(this.dataSources.map(ds => ds.fetchData().then(data => ({ source: ds, data })))); return all.filter(r => r.status === 'fulfilled').map(r => r.value); } async getKeywordDataTemplate() { const all = await Promise.allSettled(this.dataSources.map(ds => ds.searchData(this.keyword).then(data => ({ source: ds, data })))); return all.filter(r => r.status === 'fulfilled').map(r => r.value); } updated(props) { super.updated(props); this.list?.focusController.focusIn(); } static get styles() { return css ` :host { border: none; padding: 0; /* :host(::backdrop) does not work, using the host as backdrop instead. */ align-items: center; justify-content: center; backdrop-filter: brightness(0.5) blur(3px) saturate(0.5); background: transparent; width: 100%; height: 100%; } :host(:popover-open) { display: flex; mo-card { opacity: 1; transform: scale(1); @starting-style { opacity: 0; transform: scale(0.7); } } } mo-card { width: clamp(300px, 80vw, 600px); height: clamp(300px, 65vh, 800px); transition: 150ms ease-in-out opacity, 150ms ease-in-out transform; box-sizing: border-box; background: var(--mo-color-background); --mo-card-body-padding: 0; } mo-tab-bar { mo-tab { &:not([active]) { opacity: 0.5; } mo-icon { font-size: 18px; } } } mo-scroller { height: 100%; background: var(--mo-color-transparent-gray-1); } mo-list { display: flex; flex-direction: column; &[data-fetching] mo-list-item { opacity: 0.25; background: var(--mo-color-transparent-gray); } mo-list-item { padding: 10px; border-radius: 4px; font-size: 0.9em; width: 100%; &:first-child { margin-top: 6px; } mo-icon { color: var(--mo-color-gray); align-self: baseline; } .secondary { opacity: 0.5; font-size: 0.75em; } } } mo-empty-state { width: 100%; flex: 1; } .match { color: var(--mo-color-accent); font-weight: bold; } #buttons { z-index: 1; margin-top: auto; mo-button { flex: 1; border: 1px solid var(--mo-color-transparent-gray-3); border-inline-end: none; border-radius: 0px; --mo-button-accent-color: var(--mo-color-gray); --_container-height: 26px; span { font-size: small; } &:first-child { border-inline-start: none; } } } #guidance { display: flex; align-items: center; padding-inline: 10px; padding-block: 11.5px; color: var(--mo-color-gray); span { font-size: small; display: flex; align-items: center; gap: 6px; } } `; } get template() { const refocusSearch = () => { this.searchField.focus(); this.list?.focusController.focusIn(); }; return html ` <mo-card type='outlined' ?data-fetching=${this.fetcherController.isFetching} @click=${(e) => e.stopPropagation()}> <mo-flex style='height: 100%'> <mo-command-palette-search-field ?fetching=${this.fetcherController.isFetching} ${bind(this, 'keyword')}></mo-command-palette-search-field> <mo-tab-bar ${bind(this, 'filteredDataSourceId', { sourceUpdated: () => refocusSearch() })}> <mo-tab>${t('All')}</mo-tab> ${this.dataSources.map(ds => html ` <mo-tab value=${ds.id} .inlineIcon=${true}> <mo-icon icon=${ds.icon}></mo-icon> ${ds.name} </mo-tab> `)} </mo-tab-bar> <mo-scroller> <mo-flex style='height: 100%'> ${this.listTemplate} </mo-flex> </mo-scroller> ${this.newItemsTemplate} ${this.guidanceTemplate} </mo-flex> </mo-card> `; } get listTemplate() { const selectedDataSource = this.dataSources.find(ds => ds.id === this.filteredDataSourceId); const data = (this.fetcherController.data ?? []) .filter(i => !this.filteredDataSourceId || i.source.id === this.filteredDataSourceId) .flatMap(i => i.data) ?? []; return !data.length && !this.fetcherController.isFetching ? html ` <mo-empty-state icon=${selectedDataSource?.icon ?? 'search'}>${t('No results')}</mo-empty-state> ` : html ` <mo-list ?data-fetching=${this.fetcherController.isFetching}> ${data.map(item => html ` <mo-list-item @click=${() => this.executeCommand(item.command)}> <mo-icon icon=${item.icon}></mo-icon> <mo-flex> <span class='label'>${this.getSearchedTemplate(item.label)}</span> ${!item.secondaryLabel ? html.nothing : html `<span class='label secondary'>${this.getSearchedTemplate(item.secondaryLabel)}</span>`} </mo-flex> </mo-list-item> `)} </mo-list> `; } getSearchedTemplate(text) { const keyword = this.keyword?.trim().toLowerCase() ?? ''; const match = text.toLowerCase().indexOf(keyword); // It is important that the span is not in the next line, otherwise a space is added return match === -1 ? html `${text}` : html ` ${text.slice(0, match)}<span class='match'>${text.slice(match, match + keyword.length)}</span>${text.slice(match + keyword.length)} `; } get newItemsTemplate() { const items = this.dataSources .filter(ds => ds.getNewItem) .map(ds => ds.getNewItem(this.keyword)); return html ` <mo-flex id='buttons' direction='horizontal'> ${items.map(item => html ` <mo-button @click=${() => this.executeCommand(item.command)}> <mo-flex> <mo-icon icon=${item.icon}></mo-icon> <span>${item.label}</span> </mo-flex> </mo-button> `)} </mo-flex> `; } executeCommand(command) { command(); this.hidePopover(); } get guidanceTemplate() { return html ` <mo-flex id='guidance' direction='horizontal' gap='24px'> <span> <mo-keyboard-key key='Escape'></mo-keyboard-key> ${t('Close')} </span> <span> <mo-keyboard-key key='ArrowUp + ArrowDown'></mo-keyboard-key> ${t('Navigate list')} </span> <span> <mo-keyboard-key key='Tab'></mo-keyboard-key> ${t('Navigate tabs')} </span> </mo-flex> `; } }; CommandPalette.dataSources = new Set(); CommandPalette.dataSource = () => { return (Constructor) => { CommandPalette_1.dataSources.add(new Constructor()); }; }; (() => { document.addEventListener('keydown', (event) => { if (['p', 'k'].includes(event.key) && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.stopImmediatePropagation(); ApplicationTopLayer.instance.appendChild(CommandPalette_1.instance); CommandPalette_1.instance.togglePopover(); } }); })(); __decorate([ state({ updated() { this.fetcherController.fetch(); } }) ], CommandPalette.prototype, "keyword", void 0); __decorate([ state() ], CommandPalette.prototype, "filteredDataSourceId", void 0); __decorate([ query('mo-command-palette-search-field') ], CommandPalette.prototype, "searchField", void 0); __decorate([ query('mo-list') ], CommandPalette.prototype, "list", void 0); __decorate([ eventListener({ target: window, type: 'keydown' }) ], CommandPalette.prototype, "handleKeyDown", null); __decorate([ eventListener('toggle') ], CommandPalette.prototype, "handleToggle", null); __decorate([ eventListener('click') ], CommandPalette.prototype, "handleClick", null); CommandPalette = CommandPalette_1 = __decorate([ component('mo-command-palette') ], CommandPalette); export { CommandPalette };