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/D2919pull/9/head
parent
a07395855a
commit
8d68c1c567
@ -0,0 +1,149 @@
|
||||
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};
|
||||
}
|
||||
`);
|
@ -0,0 +1,75 @@
|
||||
import {MomentTimezone} from 'app/client/lib/imports';
|
||||
import {ACIndexImpl} from 'app/client/lib/ACIndex';
|
||||
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
|
||||
import {testId} from "app/client/ui2018/cssVars";
|
||||
import {nativeCompare} from 'app/common/gutil';
|
||||
import {IDisposableOwner, Observable} from 'grainjs';
|
||||
|
||||
/**
|
||||
* Returns the ordered list of offsets for names at time timestamp. See timezoneOptions for details
|
||||
* on the sorting order.
|
||||
*/
|
||||
// exported for testing
|
||||
export function timezoneOptionsImpl(
|
||||
timestamp: number, names: string[], moment: MomentTimezone
|
||||
): ACSelectItem[] {
|
||||
// What we want is moment(timestamp) but the dynamic import with our compiling settings produces
|
||||
// "moment is not a function". The following is equivalent, and easier than fixing import setup.
|
||||
const m = moment.unix(timestamp / 1000);
|
||||
|
||||
const options = names.map((value) => ({
|
||||
cleanText: value.toLowerCase().trim(),
|
||||
value,
|
||||
label: `(GMT${m.tz(value).format('Z')}) ${value}`,
|
||||
// A quick test reveal that it is a bit more efficient (~0.02ms) to get the offset using
|
||||
// `moment.tz.Zone#parse` than creating a Moment instance for each zone and then getting the
|
||||
// offset with `moment#utcOffset`.
|
||||
offset: -moment.tz.zone(value)!.parse(timestamp)
|
||||
}));
|
||||
options.sort((a, b) => nativeCompare(a.offset, b.offset) || nativeCompare(a.value, b.value));
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of IOptionFull<string> expected by `select` to create the list of timezones
|
||||
* options. The returned list is sorted based on the current offset (GMT-11:00 before GMT-10:00),
|
||||
* and then on alphabetical order of the name.
|
||||
*/
|
||||
function timezoneOptions(moment: MomentTimezone): ACSelectItem[] {
|
||||
return timezoneOptionsImpl(Date.now(), moment.tz.names(), moment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a textbox with an autocomplete dropdown to select a time zone.
|
||||
* Usage: dom.create(buildTZAutocomplete, momentModule, valueObs, saveCallback)
|
||||
*/
|
||||
export function buildTZAutocomplete(
|
||||
owner: IDisposableOwner,
|
||||
moment: MomentTimezone,
|
||||
valueObs: Observable<string>,
|
||||
save: (value: string) => Promise<void>|void
|
||||
) {
|
||||
// Set a large maxResults, since it's sometimes nice to see all supported timezones (there are
|
||||
// fewer than 1000 in practice).
|
||||
const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), 1000, true);
|
||||
|
||||
// Only save valid time zones. If there is no selected item, we'll auto-select and save only
|
||||
// when there is a good match.
|
||||
const saveTZ = (value: string, item: ACSelectItem|undefined) => {
|
||||
if (!item) {
|
||||
const results = acIndex.search(value);
|
||||
if (results.selectIndex >= 0 && results.items.length > 0) {
|
||||
item = results.items[results.selectIndex];
|
||||
value = item.value;
|
||||
}
|
||||
}
|
||||
if (!item) { throw new Error("Invalid time zone"); }
|
||||
if (value !== valueObs.get()) {
|
||||
return save(value);
|
||||
}
|
||||
};
|
||||
return buildACSelect(owner,
|
||||
{acIndex, valueObs, save: saveTZ},
|
||||
testId("tz-autocomplete")
|
||||
);
|
||||
}
|
Loading…
Reference in new issue