gristlabs_grist-core/app/client/lib/ACSelect.ts
Dmitry S 8d68c1c567 (core) Replace time zone selector with one based on the newer autocomplete.
Summary:
Flaky Dates test failures related to the use of JQuery autocomplete for time
zones, which wasn't working well.

This diff replaces that autocomplete (as well as a similar select box in
DocumentSettings) with our newer autocomplete, adding some select-box like
behavior.

Most of the behavior is factored out into ACSelect, which could be more
generally useful.

Adds an option to autocomplete to keep options ordered according to their
initial order.

Unrelated: fix up usage of MultiHolder in Drafts to avoid 'already disposed'
warnings.

Test Plan: Fixed several affected tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D2919
2021-07-23 08:02:05 -04:00

150 lines
4.1 KiB
TypeScript

import {ACIndex, ACItem, buildHighlightedDom} from 'app/client/lib/ACIndex';
import {Autocomplete, IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {colors} from "app/client/ui2018/cssVars";
import {icon} from "app/client/ui2018/icons";
import {menuCssClass} from 'app/client/ui2018/menus';
import {dom, DomElementArg, Holder, IDisposableOwner, Observable, styled} from 'grainjs';
export interface ACSelectItem extends ACItem {
value: string;
label: string;
}
/**
* Builds a text input with an autocomplete dropdown.
* Note that because it is currently only used in the right-side panel, it is designed to avoid
* keeping focus.
*/
export function buildACSelect(
owner: IDisposableOwner,
options: {
acIndex: ACIndex<ACSelectItem>,
valueObs: Observable<string>,
save: (value: string, item: ACSelectItem|undefined) => Promise<void>|void
},
...args: DomElementArg[]
) {
const {acIndex, valueObs, save} = options;
const acHolder = Holder.create<Autocomplete<ACSelectItem>>(owner);
let textInput: HTMLInputElement;
const isOpen = () => !acHolder.isEmpty();
const acOpen = () => acHolder.isEmpty() && Autocomplete.create(acHolder, textInput, acOptions);
const acClose = () => acHolder.clear();
const finish = () => { acClose(); textInput.blur(); };
const revert = () => { textInput.value = valueObs.get(); finish(); };
const commitOrRevert = async () => { (await commitIfValid()) || revert(); };
const openOrCommit = () => { isOpen() ? commitOrRevert().catch(() => {}) : acOpen(); };
const commitIfValid = async () => {
const item = acHolder.get()?.getSelectedItem();
if (item) {
textInput.value = item.value;
}
textInput.disabled = true;
try {
await save(textInput.value, item);
finish();
return true;
} catch (e) {
return false;
} finally {
textInput.disabled = false;
}
};
const onMouseDown = (ev: MouseEvent) => {
ev.preventDefault(); // Don't let it affect focus, since we focus/blur manually.
if (!isOpen()) { textInput.focus(); }
openOrCommit();
};
const acOptions: IAutocompleteOptions<ACSelectItem> = {
menuCssClass: `${menuCssClass} test-acselect-dropdown`,
search: async (term: string) => acIndex.search(term),
renderItem: (item, highlightFunc) =>
cssSelectItem(buildHighlightedDom(item.label, highlightFunc, cssMatchText)),
getItemText: (item) => item.value,
onClick: commitIfValid,
};
return cssSelectBtn(
textInput = cssInput({type: 'text'},
dom.prop('value', valueObs),
dom.on('focus', (ev, elem) => elem.select()),
dom.on('blur', commitOrRevert),
dom.onKeyDown({
Escape: revert,
Enter: openOrCommit,
ArrowDown: acOpen,
Tab: commitIfValid,
}),
dom.on('input', acOpen),
),
dom.on('mousedown', onMouseDown),
cssIcon('Dropdown'),
...args
);
}
const cssSelectBtn = styled('div', `
position: relative;
width: 100%;
height: 30px;
color: ${colors.dark};
--icon-color: ${colors.dark};
`);
const cssSelectItem = styled('li', `
display: block;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: var(--weaseljs-selected-background-color, #5AC09C);
color: var(--weaseljs-selected-color, white);
}
`);
const cssInput = styled('input', `
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
height: 100%;
width: 100%;
padding: 0 6px;
outline: none;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
cursor: pointer;
line-height: 16px;
cursor: pointer;
&:disabled {
color: grey;
background-color: initial;
}
&:focus {
cursor: initial;
outline: none;
box-shadow: 0px 0px 2px 2px #5E9ED6;
}
`);
const cssIcon = styled(icon, `
position: absolute;
right: 6px;
top: calc(50% - 8px);
`);
const cssMatchText = styled('span', `
color: ${colors.lightGreen};
.selected > & {
color: ${colors.lighterGreen};
}
`);