You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/ui/searchDropdown.ts

174 lines
5.1 KiB

// 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> = (T & string) | IOptionFull<T>;
export interface IDropdownWithSearchOptions<T> {
// the callback to trigger on selection
action: (value: T) => void;
// list of options
options: () => Array<IOption<T>>,
// place holder for the search input. Default to 'Search'
placeholder?: string;
// popup options
popupOptions?: IPopupOptions;
}
export class OptionItem<T> implements ACItem, IOptionFull<T> {
public cleanText: string = normalizeText(this.label);
constructor(
public label: string,
public value: T,
public disabled?: boolean
) {}
}
export function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): DomElementMethod {
return (elem) => {
const popupOptions = mergeWith(
{}, defaultMenuOptions, options.popupOptions,
(_objValue: any, srcValue: any) => Array.isArray(srcValue) ? srcValue : undefined
);
setPopupToFunc(
elem,
(ctl) => DropdownWithSearch<T>.create(null, ctl, options),
popupOptions
);
};
}
class DropdownWithSearch<T> extends Disposable {
private _items: Observable<OptionItem<T>[]>;
private _acIndex: ACIndexImpl<OptionItem<T>>;
private _inputElem: HTMLInputElement;
private _simpleList: SimpleList<T>;
private _highlightFunc: HighlightFunc;
constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions<T>) {
super();
const acItems = _options.options().map(getOptionFull).map(o => new OptionItem(o.label, o.value, o.disabled));
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems);
this._items = Observable.create<OptionItem<T>[]>(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<T>.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<T>) {
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;
`);