@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
JavaScript
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);
-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} =${(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 =${() => 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 =${() => 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 };