(core) Enable search in column pickers

Summary: Adds a search input at the top of columns dropdown. Start typing in the search bar filters the list of column (matching occurences should work similarly as the autocomplete dropdown on Choice column).

Test Plan: Include tests.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3738
This commit is contained in:
Cyprien P 2022-11-15 10:04:43 +01:00
parent 13cb4e8389
commit a04979bede
8 changed files with 258 additions and 72 deletions

View File

@ -7,16 +7,26 @@
* const array = observable([{label: 'foo': value: 0, {label: 'bar', value: 1}]);
* const ctl = popupControl(elem, ctl => SimpleList.create(null, ctl, array, action));
*
* // Enable keyboard navigation by listening to keys on the element that has focus.
* ctl.listenKeys(elem)
*
* // toggle popup
* dom('input', dom.on('click', () => ctl.toggle()));
*/
import { Disposable, dom, Observable, styled } from "grainjs";
import { cssMenuItem, getOptionFull, IOpenController, IOption } from "popweasel";
import { Disposable, dom, DomArg, Observable, styled } from "grainjs";
import { cssMenu, cssMenuItem, cssMenuWrap, getOptionFull, IOpenController, IOption } from "popweasel";
import { attachMouseOverOnMove, findAncestorChild } from "app/client/lib/autocomplete";
import { menuCssClass, menuItem } from "app/client/ui2018/menus";
export type { IOption, IOptionFull } from 'popweasel';
export { getOptionFull } from 'popweasel';
export class SimpleList<T> extends Disposable {
export interface ISimpleListOpt<T, U extends IOption<T> = IOption<T>> {
headerDom?(): DomArg<HTMLElement>;
renderItem?(item: U): DomArg<HTMLElement>;
}
export class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable {
public readonly content: HTMLElement;
private _menuContent: HTMLElement;
@ -25,31 +35,32 @@ export class SimpleList<T> extends Disposable {
private _mouseOver: {reset(): void};
constructor(private _ctl: IOpenController,
private _items: Observable<Array<IOption<T>>>,
private _action: (value: T) => void) {
private _items: Observable<Array<U>>,
private _action: (value: T) => void,
opt: ISimpleListOpt<T, U> = {}) {
super();
const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label);
this.content = cssMenuWrap(
{class: menuCssClass + ' grist-floating-menu'},
this._menuContent = cssMenuList(
dom.forEach(this._items, (i) => {
const item = getOptionFull(i);
return cssOptionRow(
{class: menuItem.className + ' ' + cssMenuItem.className},
dom.on('click', () => this._doAction(item.value)),
item.label,
dom.cls('disabled', Boolean(item.disabled)),
dom.data('itemValue', item.value),
);
}),
dom('div',
{class: menuCssClass + ' grist-floating-menu'},
cssMenu.cls(''),
cssMenuExt.cls(''),
opt.headerDom?.(),
this._menuContent = cssMenuList(
dom.forEach(this._items, (i) => {
const item = getOptionFull(i);
return cssOptionRow(
{class: menuItem.className + ' ' + cssMenuItem.className},
dom.on('click', () => this._doAction(item.value)),
renderItem(i),
dom.cls('disabled', Boolean(item.disabled)),
dom.data('itemValue', item.value),
);
}),
),
),
dom.on('mouseleave', (_ev) => this.setSelected(-1)),
);
this.autoDispose(dom.onKeyElem(_ctl.getTriggerElem() as any, 'keydown', {
Escape: () => this._ctl.close(),
ArrowDown: () => this.setSelected(this._getNextSelectable(1)),
ArrowUp: () => this.setSelected(this._getNextSelectable(-1)),
Enter: () => this._doAction(this._getSelectedData()),
}));
this.autoDispose(_items.addListener(() => this._update()));
this._mouseOver = attachMouseOverOnMove(
this._menuContent,
@ -58,6 +69,15 @@ export class SimpleList<T> extends Disposable {
this._update();
}
public listenKeys(elem: HTMLElement) {
this.autoDispose(dom.onKeyElem(elem, 'keydown', {
Escape: () => this._ctl.close(),
ArrowDown: () => this.setSelected(this._getNextSelectable(1)),
ArrowUp: () => this.setSelected(this._getNextSelectable(-1)),
Enter: () => this._doAction(this._getSelectedData()),
}));
}
// When the selected element changes, update the classes of the formerly and newly-selected
// elements.
public setSelected(index: number) {
@ -112,19 +132,13 @@ export class SimpleList<T> extends Disposable {
return next;
}
}
const cssMenuWrap = styled('div', `
position: absolute;
display: flex;
flex-direction: column;
outline: none;
`);
const cssMenuList = styled('ul', `
overflow: auto;
list-style: none;
outline: none;
padding: 6px 0;
padding: 0;
width: 100%;
margin: 0;
`);
const cssOptionRow = styled('li', `
white-space: nowrap;
@ -132,3 +146,8 @@ const cssOptionRow = styled('li', `
text-overflow: ellipsis;
display: block;
`);
const cssMenuExt = styled('div', `
overflow: hidden;
display: flex;
flex-direction: column;
`);

View File

@ -39,6 +39,7 @@ class RelativeDatesMenu extends Disposable {
private _opt: {valueFormatter(val: any): string}) {
super();
this._dropdownList = SimpleList<IRangeBoundType>.create(this, ctl, this._items, this._action.bind(this));
this._dropdownList.listenKeys(ctl.getTriggerElem() as HTMLElement);
this.content = this._dropdownList.content;
this.autoDispose(this._obs.addListener(() => this._update()));
this._update();

View File

@ -6,9 +6,12 @@ import { attachColumnFilterMenu } from "app/client/ui/ColumnFilterMenu";
import { cssButton } from "app/client/ui2018/buttons";
import { testId, theme, vars } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons";
import { menu, menuItemAsync } from "app/client/ui2018/menus";
import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs";
import { IMenuOptions, PopupControl } from "popweasel";
import { IPopupOptions, PopupControl } from "popweasel";
import { makeT } from "app/client/lib/localization";
import { dropdownWithSearch } from "app/client/ui/searchDropdown";
const t = makeT('FilterBar');
export function filterBar(
_owner: IDisposableOwner,
@ -70,7 +73,7 @@ export interface AddFilterMenuOptions {
/**
* Options that are passed to the menu component.
*/
menuOptions?: IMenuOptions;
menuOptions?: IPopupOptions;
}
export function addFilterMenu(
@ -80,25 +83,18 @@ export function addFilterMenu(
) {
const {allowedColumns, menuOptions} = options;
return (
menu((ctl) => [
...filters.map((filterInfo) => (
menuItemAsync(
() => openFilter(filterInfo, popupControls),
filterInfo.fieldOrColumn.origCol().label.peek(),
dom.cls('disabled', allowedColumns === 'unpinned-or-unfiltered'
? use => use(filterInfo.isPinned) && use(filterInfo.isFiltered)
: use => use(filterInfo.isFiltered)
),
testId('add-filter-item'),
)
)),
// We need to stop click event to propagate otherwise it would cause view section menu to
// close.
dom.on('click', (ev) => {
ctl.close();
ev.stopPropagation();
}),
], menuOptions)
dropdownWithSearch<FilterInfo>({
action: (filterInfo) => openFilter(filterInfo, popupControls),
options: () => filters.map((filterInfo) => ({
label: filterInfo.fieldOrColumn.origCol().label.peek(),
value: filterInfo,
disabled: allowedColumns === 'unpinned-or-unfiltered'
? filterInfo.isPinned.peek() && filterInfo.isFiltered.peek()
: filterInfo.isFiltered.peek()
})),
popupOptions: menuOptions,
placeholder: t('Search Columns'),
})
);
}

View File

@ -5,11 +5,12 @@ import {makeT} from 'app/client/lib/localization';
import {addToSort, updatePositions} from 'app/client/lib/sortUtil';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ObjObservable} from 'app/client/models/modelUtil';
import {dropdownWithSearch} from 'app/client/ui/searchDropdown';
import {cssIcon, cssRow, cssSortFilterColumn} from 'app/client/ui/RightPanelStyles';
import {labeledLeftSquareCheckbox} from 'app/client/ui2018/checkbox';
import {theme} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {menu, menuItem} from 'app/client/ui2018/menus';
import {menu} from 'app/client/ui2018/menus';
import {Sort} from 'app/common/SortSpec';
import {Computed, Disposable, dom, makeTestId, MultiHolder, styled} from 'grainjs';
import difference = require('lodash/difference');
@ -223,21 +224,12 @@ export class SortConfig extends Disposable {
const cols = use(available);
return cssTextBtn(
t("Add Column"),
menu((ctl) => [
...cols.map((col) => (
menuItem(
() => addToSort(this._section.activeSortSpec, col.value, 1),
col.label,
testId('add-menu-row')
)
)),
// We need to stop click event to propagate otherwise it would cause view section menu to
// close.
dom.on('click', (ev) => {
ctl.close();
ev.stopPropagation();
}),
], menuOptions),
dropdownWithSearch({
popupOptions: menuOptions,
options: () => cols.map((col) => ({label: col.label, value: col})),
action: (col) => addToSort(this._section.activeSortSpec, col.value, 1),
placeholder: t('Search Columns'),
}),
dom.on('click', (ev) => { ev.stopPropagation(); }),
testId('add'),
);

View File

@ -0,0 +1,170 @@
// 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";
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 || '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;
`);

View File

@ -331,6 +331,9 @@
"FilterConfig": {
"Add Column": "Add Column"
},
"FilterBar": {
"SearchColumns": "Search columns"
},
"GridOptions": {
"Grid Options": "Grid Options",
"Horizontal Gridlines": "Horizontal Gridlines",
@ -569,7 +572,8 @@
"Empty values last": "Empty values last",
"Natural sort": "Natural sort",
"Update Data": "Update Data",
"Use choice position": "Use choice position"
"Use choice position": "Use choice position",
"Search Columns": "Search columns"
},
"SortFilterConfig": {
"Filter": "FILTER",

View File

@ -318,6 +318,9 @@
"FilterConfig": {
"Add Column": "Ajouter une colonne"
},
"FilterBar": {
"SearchColuns": "Rechercher"
},
"GridOptions": {
"Grid Options": "Options de la grille",
"Vertical Gridlines": "Grille verticale",
@ -554,7 +557,8 @@
"Update Data": "Mettre à jour les données",
"Use choice position": "Use choice position",
"Natural sort": "Natural sort",
"Empty values last": "Valeurs vides en dernier"
"Empty values last": "Valeurs vides en dernier",
"Search Columns": "Rechercher"
},
"SortFilterConfig": {
"Save": "Enregistrer",

View File

@ -2533,7 +2533,7 @@ export async function setRefTable(table: string) {
// Add column to sort.
export async function addColumnToSort(colName: RegExp|string) {
await driver.find(".test-sort-config-add").click();
await driver.findContent(".test-sort-config-add-menu-row", colName).click();
await driver.findContent(".test-sd-searchable-list-item", colName).click();
await driver.findContentWait(".test-sort-config-row", colName, 100);
}