// A dropdown with a search input to better navigate long list with // keyboard. Dropdown features a search input and reoders the list of // items to bring best matches at the top. import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc, normalizeText } from "app/client/lib/ACIndex"; import { makeT } from 'app/client/lib/localization'; import { getOptionFull, SimpleList } from "app/client/lib/simpleList"; import { theme, vars } from 'app/client/ui2018/cssVars'; import { icon } from "app/client/ui2018/icons"; import { menuDivider } from "app/client/ui2018/menus"; import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs"; import mergeWith from "lodash/mergeWith"; import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel"; const t = makeT('searchDropdown'); const testId = makeTestId('test-sd-'); export type { HighlightFunc } from "app/client/lib/ACIndex"; export type IOption = (T & string) | IOptionFull; export interface IDropdownWithSearchOptions { // the callback to trigger on selection action: (value: T) => void; // list of options options: () => Array>, /** Called when the dropdown menu is disposed. */ onClose?: () => void; // place holder for the search input. Default to 'Search' placeholder?: string; // popup options popupOptions?: IPopupOptions; /** ACIndexOptions to use for indexing and searching items. */ acOptions?: ACIndexOptions; /** * If set, the width of the dropdown menu will be equal to that of * the trigger element. */ matchTriggerElemWidth?: boolean; } export interface OptionItemParams { /** Item label. Normalized and used by ACIndex for indexing and searching. */ label: string; /** Item value. */ value: T; /** Defaults to false. */ disabled?: boolean; /** * If true, marks this item as the "placeholder" item. * * The placeholder item is excluded from indexing, so it's label doesn't * match search inputs. However, it's still shown when the search input is * empty. * * Defaults to false. */ placeholder?: boolean; } export class OptionItem implements ACItem, IOptionFull { public label = this._params.label; public value = this._params.value; public disabled = this._params.disabled; public placeholder = this._params.placeholder; public cleanText = this.placeholder ? '' : normalizeText(this.label); constructor(private _params: OptionItemParams) { } } export function dropdownWithSearch(options: IDropdownWithSearchOptions): DomElementMethod { return (elem) => { const popupOptions = mergeWith( {}, defaultMenuOptions, options.popupOptions, (_objValue: any, srcValue: any) => Array.isArray(srcValue) ? srcValue : undefined ); setPopupToFunc( elem, (ctl) => (DropdownWithSearch).create(null, ctl, options), popupOptions ); }; } class DropdownWithSearch extends Disposable { private _items: Observable[]>; private _acIndex: ACIndexImpl>; private _inputElem: HTMLInputElement; private _simpleList: SimpleList; private _highlightFunc: HighlightFunc; constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions) { super(); const acItems = _options.options().map(getOptionFull).map((params) => new OptionItem(params)); this._acIndex = new ACIndexImpl>(acItems, this._options.acOptions); this._items = Observable.create[]>(this, acItems); this._highlightFunc = () => []; this._simpleList = this._buildSimpleList(); this._simpleList.listenKeys(this._inputElem); this._update(); // auto-focus the search input setTimeout(() => this._inputElem.focus(), 1); this._ctl.onDispose(() => _options.onClose?.()); } public get content(): HTMLElement { return this._simpleList.content; } private _buildSimpleList() { const action = this._action.bind(this); const headerDom = this._buildHeader.bind(this); const renderItem = this._buildItem.bind(this); return (SimpleList).create(this, this._ctl, this._items, action, { matchTriggerElemWidth: this._options.matchTriggerElemWidth, headerDom, renderItem, }); } private _buildHeader() { return [ cssMenuHeader( cssSearchIcon('Search'), this._inputElem = cssSearch( {placeholder: this._options.placeholder || t('Search')}, dom.on('input', () => { this._update(); }), dom.on('blur', () => setTimeout(() => this._inputElem.focus(), 0)), ), // Prevents click on header to close menu dom.on('click', ev => ev.stopPropagation()), testId('search'), ), cssMenuDivider(), ]; } private _buildItem(item: OptionItem) { return [ item.placeholder ? cssPlaceholderItem(item.label) : buildHighlightedDom(item.label, this._highlightFunc, cssMatchText), testId('searchable-list-item'), ]; } private _update() { const acResults = this._acIndex.search(this._inputElem?.value || ''); this._highlightFunc = acResults.highlightFunc; this._items.set(acResults.items); this._simpleList.setSelected(acResults.selectIndex); } private _action(value: T | null) { // If value is null, simply close the menu. This happens when pressing enter with no element // selected. if (value !== null) { this._options.action(value); } this._ctl.close(); } } const cssMatchText = styled('span', ` color: ${theme.autocompleteMatchText}; .${cssMenuItem.className}-sel > & { color: ${theme.autocompleteSelectedMatchText}; } `); const cssMenuHeader = styled('div', ` display: flex; padding: 13px 17px 15px 17px; `); const cssSearchIcon = styled(icon, ` --icon-color: ${theme.lightText}; flex-shrink: 0; margin-left: auto; margin-right: 4px; `); const cssSearch = styled('input', ` color: ${theme.inputFg}; background-color: ${theme.inputBg}; flex-grow: 1; min-width: 1px; -webkit-appearance: none; -moz-appearance: none; font-size: ${vars.mediumFontSize}; margin: 0px 16px 0px 8px; padding: 0px; border: none; outline: none; &::placeholder { color: ${theme.inputPlaceholderFg}; } `); const cssMenuDivider = styled(menuDivider, ` flex-shrink: 0; margin: 0; `); const cssPlaceholderItem = styled('div', ` color: ${theme.inputPlaceholderFg}; .${cssMenuItem.className}-sel > & { color: ${theme.menuItemSelectedFg}; } `);