// 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 { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs"; import { theme, vars } from 'app/client/ui2018/cssVars'; import { ACIndexImpl, ACItem, buildHighlightedDom, HighlightFunc, normalizeText } from "app/client/lib/ACIndex"; import { menuDivider } from "app/client/ui2018/menus"; import { icon } from "app/client/ui2018/icons"; import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel"; import { mergeWith } from "lodash"; import { getOptionFull, SimpleList } from "../lib/simpleList"; import { makeT } from 'app/client/lib/localization'; 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>, // place holder for the search input. Default to 'Search' placeholder?: string; // popup options popupOptions?: IPopupOptions; } export class OptionItem implements ACItem, IOptionFull { public cleanText: string = normalizeText(this.label); constructor( public label: string, public value: T, public disabled?: boolean ) {} } 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(o => new OptionItem(o.label, o.value, o.disabled)); this._acIndex = new ACIndexImpl>(acItems); 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); } 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, {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 [ 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) { 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; `);