(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 array = observable([{label: 'foo': value: 0, {label: 'bar', value: 1}]);
* const ctl = popupControl(elem, ctl => SimpleList.create(null, ctl, array, action)); * 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())); * dom('input', dom.on('click', () => ctl.toggle()));
*/ */
import { Disposable, dom, Observable, styled } from "grainjs"; import { Disposable, dom, DomArg, Observable, styled } from "grainjs";
import { cssMenuItem, getOptionFull, IOpenController, IOption } from "popweasel"; import { cssMenu, cssMenuItem, cssMenuWrap, getOptionFull, IOpenController, IOption } from "popweasel";
import { attachMouseOverOnMove, findAncestorChild } from "app/client/lib/autocomplete"; import { attachMouseOverOnMove, findAncestorChild } from "app/client/lib/autocomplete";
import { menuCssClass, menuItem } from "app/client/ui2018/menus"; import { menuCssClass, menuItem } from "app/client/ui2018/menus";
export type { IOption, IOptionFull } from 'popweasel'; 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; public readonly content: HTMLElement;
private _menuContent: HTMLElement; private _menuContent: HTMLElement;
@ -25,31 +35,32 @@ export class SimpleList<T> extends Disposable {
private _mouseOver: {reset(): void}; private _mouseOver: {reset(): void};
constructor(private _ctl: IOpenController, constructor(private _ctl: IOpenController,
private _items: Observable<Array<IOption<T>>>, private _items: Observable<Array<U>>,
private _action: (value: T) => void) { private _action: (value: T) => void,
opt: ISimpleListOpt<T, U> = {}) {
super(); super();
const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label);
this.content = cssMenuWrap( this.content = cssMenuWrap(
dom('div',
{class: menuCssClass + ' grist-floating-menu'}, {class: menuCssClass + ' grist-floating-menu'},
cssMenu.cls(''),
cssMenuExt.cls(''),
opt.headerDom?.(),
this._menuContent = cssMenuList( this._menuContent = cssMenuList(
dom.forEach(this._items, (i) => { dom.forEach(this._items, (i) => {
const item = getOptionFull(i); const item = getOptionFull(i);
return cssOptionRow( return cssOptionRow(
{class: menuItem.className + ' ' + cssMenuItem.className}, {class: menuItem.className + ' ' + cssMenuItem.className},
dom.on('click', () => this._doAction(item.value)), dom.on('click', () => this._doAction(item.value)),
item.label, renderItem(i),
dom.cls('disabled', Boolean(item.disabled)), dom.cls('disabled', Boolean(item.disabled)),
dom.data('itemValue', item.value), dom.data('itemValue', item.value),
); );
}), }),
), ),
),
dom.on('mouseleave', (_ev) => this.setSelected(-1)), 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.autoDispose(_items.addListener(() => this._update()));
this._mouseOver = attachMouseOverOnMove( this._mouseOver = attachMouseOverOnMove(
this._menuContent, this._menuContent,
@ -58,6 +69,15 @@ export class SimpleList<T> extends Disposable {
this._update(); 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 // When the selected element changes, update the classes of the formerly and newly-selected
// elements. // elements.
public setSelected(index: number) { public setSelected(index: number) {
@ -112,19 +132,13 @@ export class SimpleList<T> extends Disposable {
return next; return next;
} }
} }
const cssMenuWrap = styled('div', `
position: absolute;
display: flex;
flex-direction: column;
outline: none;
`);
const cssMenuList = styled('ul', ` const cssMenuList = styled('ul', `
overflow: auto; overflow: auto;
list-style: none; list-style: none;
outline: none; outline: none;
padding: 6px 0; padding: 0;
width: 100%; width: 100%;
margin: 0;
`); `);
const cssOptionRow = styled('li', ` const cssOptionRow = styled('li', `
white-space: nowrap; white-space: nowrap;
@ -132,3 +146,8 @@ const cssOptionRow = styled('li', `
text-overflow: ellipsis; text-overflow: ellipsis;
display: block; 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}) { private _opt: {valueFormatter(val: any): string}) {
super(); super();
this._dropdownList = SimpleList<IRangeBoundType>.create(this, ctl, this._items, this._action.bind(this)); 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.content = this._dropdownList.content;
this.autoDispose(this._obs.addListener(() => this._update())); this.autoDispose(this._obs.addListener(() => this._update()));
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 { cssButton } from "app/client/ui2018/buttons";
import { testId, theme, vars } from "app/client/ui2018/cssVars"; import { testId, theme, vars } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons"; import { icon } from "app/client/ui2018/icons";
import { menu, menuItemAsync } from "app/client/ui2018/menus";
import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs"; 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( export function filterBar(
_owner: IDisposableOwner, _owner: IDisposableOwner,
@ -70,7 +73,7 @@ export interface AddFilterMenuOptions {
/** /**
* Options that are passed to the menu component. * Options that are passed to the menu component.
*/ */
menuOptions?: IMenuOptions; menuOptions?: IPopupOptions;
} }
export function addFilterMenu( export function addFilterMenu(
@ -80,25 +83,18 @@ export function addFilterMenu(
) { ) {
const {allowedColumns, menuOptions} = options; const {allowedColumns, menuOptions} = options;
return ( return (
menu((ctl) => [ dropdownWithSearch<FilterInfo>({
...filters.map((filterInfo) => ( action: (filterInfo) => openFilter(filterInfo, popupControls),
menuItemAsync( options: () => filters.map((filterInfo) => ({
() => openFilter(filterInfo, popupControls), label: filterInfo.fieldOrColumn.origCol().label.peek(),
filterInfo.fieldOrColumn.origCol().label.peek(), value: filterInfo,
dom.cls('disabled', allowedColumns === 'unpinned-or-unfiltered' disabled: allowedColumns === 'unpinned-or-unfiltered'
? use => use(filterInfo.isPinned) && use(filterInfo.isFiltered) ? filterInfo.isPinned.peek() && filterInfo.isFiltered.peek()
: use => use(filterInfo.isFiltered) : filterInfo.isFiltered.peek()
), })),
testId('add-filter-item'), popupOptions: menuOptions,
) placeholder: t('Search Columns'),
)), })
// 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)
); );
} }

View File

@ -5,11 +5,12 @@ import {makeT} from 'app/client/lib/localization';
import {addToSort, updatePositions} from 'app/client/lib/sortUtil'; import {addToSort, updatePositions} from 'app/client/lib/sortUtil';
import {ViewSectionRec} from 'app/client/models/DocModel'; import {ViewSectionRec} from 'app/client/models/DocModel';
import {ObjObservable} from 'app/client/models/modelUtil'; import {ObjObservable} from 'app/client/models/modelUtil';
import {dropdownWithSearch} from 'app/client/ui/searchDropdown';
import {cssIcon, cssRow, cssSortFilterColumn} from 'app/client/ui/RightPanelStyles'; import {cssIcon, cssRow, cssSortFilterColumn} from 'app/client/ui/RightPanelStyles';
import {labeledLeftSquareCheckbox} from 'app/client/ui2018/checkbox'; import {labeledLeftSquareCheckbox} from 'app/client/ui2018/checkbox';
import {theme} from 'app/client/ui2018/cssVars'; import {theme} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList'; 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 {Sort} from 'app/common/SortSpec';
import {Computed, Disposable, dom, makeTestId, MultiHolder, styled} from 'grainjs'; import {Computed, Disposable, dom, makeTestId, MultiHolder, styled} from 'grainjs';
import difference = require('lodash/difference'); import difference = require('lodash/difference');
@ -223,21 +224,12 @@ export class SortConfig extends Disposable {
const cols = use(available); const cols = use(available);
return cssTextBtn( return cssTextBtn(
t("Add Column"), t("Add Column"),
menu((ctl) => [ dropdownWithSearch({
...cols.map((col) => ( popupOptions: menuOptions,
menuItem( options: () => cols.map((col) => ({label: col.label, value: col})),
() => addToSort(this._section.activeSortSpec, col.value, 1), action: (col) => addToSort(this._section.activeSortSpec, col.value, 1),
col.label, placeholder: t('Search Columns'),
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),
dom.on('click', (ev) => { ev.stopPropagation(); }), dom.on('click', (ev) => { ev.stopPropagation(); }),
testId('add'), 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": { "FilterConfig": {
"Add Column": "Add Column" "Add Column": "Add Column"
}, },
"FilterBar": {
"SearchColumns": "Search columns"
},
"GridOptions": { "GridOptions": {
"Grid Options": "Grid Options", "Grid Options": "Grid Options",
"Horizontal Gridlines": "Horizontal Gridlines", "Horizontal Gridlines": "Horizontal Gridlines",
@ -569,7 +572,8 @@
"Empty values last": "Empty values last", "Empty values last": "Empty values last",
"Natural sort": "Natural sort", "Natural sort": "Natural sort",
"Update Data": "Update Data", "Update Data": "Update Data",
"Use choice position": "Use choice position" "Use choice position": "Use choice position",
"Search Columns": "Search columns"
}, },
"SortFilterConfig": { "SortFilterConfig": {
"Filter": "FILTER", "Filter": "FILTER",

View File

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

View File

@ -2533,7 +2533,7 @@ export async function setRefTable(table: string) {
// Add column to sort. // Add column to sort.
export async function addColumnToSort(colName: RegExp|string) { export async function addColumnToSort(colName: RegExp|string) {
await driver.find(".test-sort-config-add").click(); 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); await driver.findContentWait(".test-sort-config-row", colName, 100);
} }