2022-11-15 09:04:43 +00:00
|
|
|
// 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';
|
2024-03-20 14:51:59 +00:00
|
|
|
import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc,
|
|
|
|
normalizeText } from "app/client/lib/ACIndex";
|
2022-11-15 09:04:43 +00:00
|
|
|
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";
|
2023-07-16 16:52:13 +00:00
|
|
|
import { makeT } from 'app/client/lib/localization';
|
|
|
|
|
|
|
|
const t = makeT('searchDropdown');
|
2022-11-15 09:04:43 +00:00
|
|
|
|
|
|
|
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>>,
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
/** Called when the dropdown menu is disposed. */
|
|
|
|
onClose?: () => void;
|
|
|
|
|
2022-11-15 09:04:43 +00:00
|
|
|
// place holder for the search input. Default to 'Search'
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
|
|
// popup options
|
|
|
|
popupOptions?: IPopupOptions;
|
2024-03-20 14:51:59 +00:00
|
|
|
|
|
|
|
/** 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<T> {
|
|
|
|
/** 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;
|
2022-11-15 09:04:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export class OptionItem<T> implements ACItem, IOptionFull<T> {
|
2024-03-20 14:51:59 +00:00
|
|
|
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<T>) {
|
|
|
|
|
|
|
|
}
|
2022-11-15 09:04:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
2024-03-20 14:51:59 +00:00
|
|
|
(ctl) => (DropdownWithSearch<T>).create(null, ctl, options),
|
2022-11-15 09:04:43 +00:00
|
|
|
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();
|
2024-03-20 14:51:59 +00:00
|
|
|
const acItems = _options.options().map(getOptionFull).map((params) => new OptionItem(params));
|
|
|
|
this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems, this._options.acOptions);
|
2022-11-15 09:04:43 +00:00
|
|
|
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);
|
2024-03-20 14:51:59 +00:00
|
|
|
this._ctl.onDispose(() => _options.onClose?.());
|
2022-11-15 09:04:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
2024-03-20 14:51:59 +00:00
|
|
|
return (SimpleList<T>).create(this, this._ctl, this._items, action, {
|
|
|
|
matchTriggerElemWidth: this._options.matchTriggerElemWidth,
|
|
|
|
headerDom,
|
|
|
|
renderItem,
|
|
|
|
});
|
2022-11-15 09:04:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private _buildHeader() {
|
|
|
|
return [
|
|
|
|
cssMenuHeader(
|
|
|
|
cssSearchIcon('Search'),
|
|
|
|
this._inputElem = cssSearch(
|
2023-07-16 16:52:13 +00:00
|
|
|
{placeholder: this._options.placeholder || t('Search')},
|
2022-11-15 09:04:43 +00:00
|
|
|
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 [
|
2024-03-20 14:51:59 +00:00
|
|
|
item.placeholder
|
|
|
|
? cssPlaceholderItem(item.label)
|
|
|
|
: buildHighlightedDom(item.label, this._highlightFunc, cssMatchText),
|
2022-11-15 09:04:43 +00:00
|
|
|
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.
|
2024-03-20 14:51:59 +00:00
|
|
|
if (value !== null) {
|
2022-11-15 09:04:43 +00:00
|
|
|
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;
|
|
|
|
`);
|
2024-03-20 14:51:59 +00:00
|
|
|
const cssPlaceholderItem = styled('div', `
|
|
|
|
color: ${theme.inputPlaceholderFg};
|
|
|
|
|
|
|
|
.${cssMenuItem.className}-sel > & {
|
|
|
|
color: ${theme.menuItemSelectedFg};
|
|
|
|
}
|
|
|
|
`);
|