mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) move client code to core
Summary: This moves all client code to core, and makes minimal fix-ups to get grist and grist-core to compile correctly. The client works in core, but I'm leaving clean-up around the build and bundles to follow-up. Test Plan: existing tests pass; server-dev bundle looks sane Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
239
app/client/lib/autocomplete.ts
Normal file
239
app/client/lib/autocomplete.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Implements an autocomplete dropdown.
|
||||
*/
|
||||
import {createPopper, Instance as Popper, Modifier, Options as PopperOptions} from '@popperjs/core';
|
||||
import {ACItem, ACResults, HighlightFunc} from 'app/client/lib/ACIndex';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {Disposable, dom, EventCB, IDisposable} from 'grainjs';
|
||||
import {obsArray, onKeyElem, styled} from 'grainjs';
|
||||
import merge = require('lodash/merge');
|
||||
import maxSize from 'popper-max-size-modifier';
|
||||
import {cssMenu} from 'popweasel';
|
||||
|
||||
|
||||
export interface IAutocompleteOptions<Item extends ACItem> {
|
||||
// If provided, applies the css class to the menu container. Could be multiple, space-separated.
|
||||
menuCssClass?: string;
|
||||
|
||||
// A single class name to add for the selected item, or 'selected' by default.
|
||||
selectedCssClass?: string;
|
||||
|
||||
// Popper options for positioning the popup.
|
||||
popperOptions?: Partial<PopperOptions>;
|
||||
|
||||
// Given a search term, return the list of Items to render.
|
||||
search(searchText: string): Promise<ACResults<Item>>;
|
||||
|
||||
// Function to render a single item.
|
||||
renderItem(item: Item, highlightFunc: HighlightFunc): HTMLElement;
|
||||
|
||||
// Get text for the text input for a selected item, i.e. the text to present to the user.
|
||||
getItemText(item: Item): string;
|
||||
|
||||
// A callback triggered when user clicks one of the choices.
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An instance of an open Autocomplete dropdown.
|
||||
*/
|
||||
export class Autocomplete<Item extends ACItem> extends Disposable {
|
||||
// The UL element containing the actual menu items.
|
||||
protected _menuContent: HTMLElement;
|
||||
|
||||
// Index into _items as well as into _menuContent, -1 if nothing selected.
|
||||
protected _selectedIndex: number = -1;
|
||||
|
||||
// Currently selected element.
|
||||
protected _selected: HTMLElement|null = null;
|
||||
|
||||
private _popper: Popper;
|
||||
private _mouseOver: {reset(): void};
|
||||
private _lastAsTyped: string;
|
||||
private _items = this.autoDispose(obsArray<Item>([]));
|
||||
private _highlightFunc: HighlightFunc;
|
||||
|
||||
constructor(
|
||||
private _triggerElem: HTMLInputElement | HTMLTextAreaElement,
|
||||
private readonly options: IAutocompleteOptions<Item>,
|
||||
) {
|
||||
super();
|
||||
|
||||
const content = cssMenuWrap(
|
||||
this._menuContent = cssMenu({class: options.menuCssClass || ''},
|
||||
dom.forEach(this._items, (item) => options.renderItem(item, this._highlightFunc)),
|
||||
dom.style('min-width', _triggerElem.getBoundingClientRect().width + 'px'),
|
||||
dom.on('mouseleave', (ev) => this._setSelected(-1, true)),
|
||||
dom.on('click', (ev) => {
|
||||
this._setSelected(this._findTargetItem(ev.target), true);
|
||||
if (options.onClick) { options.onClick(); }
|
||||
})
|
||||
),
|
||||
// Prevent trigger element from being blurred on click.
|
||||
dom.on('mousedown', (ev) => ev.preventDefault()),
|
||||
);
|
||||
|
||||
this._mouseOver = attachMouseOverOnMove(this._menuContent,
|
||||
(ev) => this._setSelected(this._findTargetItem(ev.target), true));
|
||||
|
||||
// Add key handlers to the trigger element as well as the menu if it is an input.
|
||||
this.autoDispose(onKeyElem(_triggerElem, 'keydown', {
|
||||
ArrowDown: () => this._setSelected(this._getNext(1), true),
|
||||
ArrowUp: () => this._setSelected(this._getNext(-1), true),
|
||||
}));
|
||||
|
||||
// Keeps track of the last value as typed by the user.
|
||||
this.search();
|
||||
this.autoDispose(dom.onElem(_triggerElem, 'input', () => this.search()));
|
||||
|
||||
// Attach the content to the page.
|
||||
document.body.appendChild(content);
|
||||
this.onDispose(() => { dom.domDispose(content); content.remove(); });
|
||||
|
||||
// Prepare and create the Popper instance, which places the content according to the options.
|
||||
const popperOptions = merge({}, defaultPopperOptions, options.popperOptions);
|
||||
this._popper = createPopper(_triggerElem, content, popperOptions);
|
||||
this.onDispose(() => this._popper.destroy());
|
||||
}
|
||||
|
||||
public getSelectedItem(): Item|undefined {
|
||||
return this._items.get()[this._selectedIndex];
|
||||
}
|
||||
|
||||
public search(findMatch?: (items: Item[]) => number) {
|
||||
this._updateChoices(this._triggerElem.value, findMatch).catch(reportError);
|
||||
}
|
||||
|
||||
// When the selected element changes, update the classes of the formerly and newly-selected
|
||||
// elements and optionally update the text input.
|
||||
private _setSelected(index: number, updateValue: boolean) {
|
||||
const elem = (this._menuContent.children[index] as HTMLElement) || null;
|
||||
const prev = this._selected;
|
||||
if (elem !== prev) {
|
||||
const clsName = this.options.selectedCssClass || 'selected';
|
||||
if (prev) { prev.classList.remove(clsName); }
|
||||
if (elem) {
|
||||
elem.classList.add(clsName);
|
||||
elem.scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
}
|
||||
this._selected = elem;
|
||||
this._selectedIndex = elem ? index : -1;
|
||||
|
||||
if (updateValue) {
|
||||
// Update trigger's value with the selected choice, or else with the last typed value.
|
||||
if (elem) {
|
||||
this._triggerElem.value = this.options.getItemText(this.getSelectedItem()!);
|
||||
} else {
|
||||
this._triggerElem.value = this._lastAsTyped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _findTargetItem(target: EventTarget|null): number {
|
||||
// Find immediate child of this._menuContent which is an ancestor of ev.target.
|
||||
const elem = findAncestorChild(this._menuContent, target as Element|null);
|
||||
return Array.prototype.indexOf.call(this._menuContent.children, elem);
|
||||
}
|
||||
|
||||
private _getNext(step: 1 | -1): number {
|
||||
// Pretend there is an extra element at the end to mean "nothing selected".
|
||||
const xsize = this._items.get().length + 1;
|
||||
const next = (this._selectedIndex + step + xsize) % xsize;
|
||||
return (next === xsize - 1) ? -1 : next;
|
||||
}
|
||||
|
||||
private async _updateChoices(inputVal: string, findMatch?: (items: Item[]) => number): Promise<void> {
|
||||
this._lastAsTyped = inputVal;
|
||||
// TODO We should perhaps debounce the search() call in some clever way, to avoid unnecessary
|
||||
// searches while typing. Today, search() is synchronous in practice, so it doesn't matter.
|
||||
const acResults = await this.options.search(inputVal);
|
||||
this._highlightFunc = acResults.highlightFunc;
|
||||
this._items.set(acResults.items);
|
||||
|
||||
// Plain update() (which is deferred) may be better, but if _setSelected() causes scrolling
|
||||
// before the positions are updated, it causes the entire page to scroll horizontally.
|
||||
this._popper.forceUpdate();
|
||||
|
||||
this._mouseOver.reset();
|
||||
|
||||
let index: number;
|
||||
if (findMatch) {
|
||||
index = findMatch(this._items.get());
|
||||
} else {
|
||||
index = inputVal ? acResults.selectIndex : -1;
|
||||
}
|
||||
this._setSelected(index, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// The maxSize modifiers follow recommendations at https://www.npmjs.com/package/popper-max-size-modifier
|
||||
const calcMaxSize = {
|
||||
...maxSize,
|
||||
options: {padding: 4},
|
||||
};
|
||||
|
||||
const applyMaxSize: Modifier<any, any> = {
|
||||
name: 'applyMaxSize',
|
||||
enabled: true,
|
||||
phase: 'beforeWrite',
|
||||
requires: ['maxSize'],
|
||||
fn({state}: any) {
|
||||
// The `maxSize` modifier provides this data
|
||||
const {height} = state.modifiersData.maxSize;
|
||||
Object.assign(state.styles.popper, {
|
||||
maxHeight: `${Math.max(160, height)}px`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const defaultPopperOptions: Partial<PopperOptions> = {
|
||||
placement: 'bottom-start',
|
||||
modifiers: [
|
||||
calcMaxSize,
|
||||
applyMaxSize,
|
||||
{name: "computeStyles", options: {gpuAcceleration: false}},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Helper function which returns the direct child of ancestor which is an ancestor of elem, or
|
||||
* null if elem is not a descendant of ancestor.
|
||||
*/
|
||||
function findAncestorChild(ancestor: Element, elem: Element|null): Element|null {
|
||||
while (elem && elem.parentElement !== ancestor) {
|
||||
elem = elem.parentElement;
|
||||
}
|
||||
return elem;
|
||||
}
|
||||
|
||||
/**
|
||||
* A version of dom.onElem('mouseover') that doesn't start firing until there is first a 'mousemove'.
|
||||
* This way if an element is created under the mouse cursor (triggered by the keyboard, for
|
||||
* instance) it's not immediately highlighted, but only when a user moves the mouse.
|
||||
* Returns an object with a reset() method, which restarts the wait for mousemove.
|
||||
*/
|
||||
function attachMouseOverOnMove<T extends EventTarget>(elem: T, callback: EventCB<MouseEvent, T>) {
|
||||
let lis: IDisposable|undefined;
|
||||
function setListener(eventType: 'mouseover'|'mousemove', cb: EventCB<MouseEvent, T>) {
|
||||
if (lis) { lis.dispose(); }
|
||||
lis = dom.onElem(elem, eventType, cb);
|
||||
}
|
||||
function reset() {
|
||||
setListener('mousemove', (ev, _elem) => {
|
||||
setListener('mouseover', callback);
|
||||
callback(ev, _elem);
|
||||
});
|
||||
}
|
||||
reset();
|
||||
return {reset};
|
||||
}
|
||||
|
||||
const cssMenuWrap = styled('div', `
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
`);
|
||||
Reference in New Issue
Block a user