gristlabs_grist-core/app/client/lib/ACSelect.ts

161 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

import {ACIndex, ACItem, buildHighlightedDom} from 'app/client/lib/ACIndex';
import {Autocomplete, IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {theme} 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: {
disabled?: Observable<boolean>,
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 (options.disabled?.get()) {
return;
}
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.prop("disabled", (use) => options.disabled ? use(options.disabled) : false),
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: ${theme.selectButtonFg};
--icon-color: ${theme.selectButtonFg};
`);
export const cssSelectItem = styled('li', `
color: ${theme.menuItemFg};
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: ${theme.menuItemSelectedBg};
color: ${theme.menuItemSelectedFg};
}
`);
const cssInput = styled('input', `
color: ${theme.inputFg};
background-color: ${theme.inputBg};
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
height: 100%;
width: 100%;
padding: 0 6px;
outline: none;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
cursor: pointer;
line-height: 16px;
cursor: pointer;
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
}
&:focus {
cursor: initial;
outline: none;
box-shadow: 0px 0px 2px 2px ${theme.inputFocus};
}
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
`);
const cssIcon = styled(icon, `
position: absolute;
right: 6px;
top: calc(50% - 8px);
`);
const cssMatchText = styled('span', `
color: ${theme.autocompleteMatchText};
.selected > & {
color: ${theme.autocompleteSelectedMatchText};
}
`);