gristlabs_grist-core/app/client/ui2018/menus.ts

624 lines
20 KiB
TypeScript
Raw Permalink Normal View History

import { Command } from 'app/client/components/commands';
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
import { textButton } from 'app/client/ui2018/buttons';
import { cssCheckboxSquare, cssLabel, cssLabelText } from 'app/client/ui2018/checkbox';
import { testId, theme, vars } from 'app/client/ui2018/cssVars';
import { IconName } from 'app/client/ui2018/IconList';
import { icon } from 'app/client/ui2018/icons';
import { cssSelectBtn } from 'app/client/ui2018/select';
import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs';
import * as weasel from 'popweasel';
export interface IOptionFull<T> {
value: T;
label: string;
disabled?: boolean;
icon?: IconName;
}
let _lastOpenedController: weasel.IOpenController|null = null;
// Close opened menu if any, otherwise do nothing.
(core) Fix prevent auto-expansion when user is resizing browser window Summary: Diff fixes an annoying issue when the left panel would sometimes auto-expand when the user is resizing the window. Alghouth it is quite easy to reproduce the issue still would happen a bit randomly. Here are what was done to fix it: 1) The most annoying manifestation of the issue happens with doc menu page. Indeed the panel is not meant to be collapsing hence triggering overlapping expansion messes UI a great deal. This was fix by asserting that the panel was collapsible before triggering expansion `if (left.hideOpener) { return; }`. 2) To prevent issue to happen also with doc page, we first test whether the user is actually dragging using `ev1.buttons !== 0` and also we've added a `isScreeResizingObs` observable. Although this seems to have fixed the problem for me, other developers still reports occurence of the issue on there machine but at a lesser frequence. It is unknown what this solution does not fully work, still situation seems acceptable now (most annoying part was 1st item). Diff also brings another small improvement when using Grist in a split screen setup when Grist is on the right. Moving cursor back and forth between the two windows would frequently leave the left panel inadvertandly expanded. Diff added a fix to allow panel to collapse when cursor leave window. Test Plan: Tested manually as it is hard to test when selenium. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3562
2022-09-01 08:07:15 +00:00
// WARN: current implementation does not handle submenus correctly. Does not seem a problem as of
// today though, as there is no submenu in UI.
export function closeRegisteredMenu() {
if (_lastOpenedController) { _lastOpenedController.close(); }
}
// Register `ctl` to make sure it is closed when `closeMenu()` is called.
export function registerMenuOpen(ctl: weasel.IOpenController) {
_lastOpenedController = ctl;
ctl.onDispose(() => _lastOpenedController = null);
}
// For string options, we can use a string for label and value without wrapping into an object.
export type IOption<T> = (T & string) | IOptionFull<T>;
export function menu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOptions): DomElementMethod {
const wrappedCreateFunc = (ctl: weasel.IOpenController) => {
registerMenuOpen(ctl);
return createFunc(ctl);
};
return weasel.menu(wrappedCreateFunc, {...defaults, ...options});
}
// TODO Weasel doesn't allow other options for submenus, but probably should.
export type ISubMenuOptions = weasel.ISubMenuOptions & weasel.IPopupOptions;
export function menuItemSubmenu(
submenu: weasel.MenuCreateFunc,
options: ISubMenuOptions,
...args: DomElementArg[]
): Element {
return weasel.menuItemSubmenu(submenu, {...defaults, ...options}, ...args);
}
export const cssMenuElem = styled('div', `
font-family: ${vars.fontFamily};
font-size: ${vars.mediumFontSize};
line-height: initial;
max-width: 400px;
padding: 8px 0px 16px 0px;
box-shadow: 0 2px 20px 0 ${theme.menuShadow};
min-width: 160px;
z-index: 999;
--weaseljs-selected-background-color: ${theme.menuItemSelectedBg};
--weaseljs-menu-item-padding: 8px 24px;
background-color: ${theme.menuBg};
@media print {
& {
display: none;
}
}
`);
const menuItemStyle = `
justify-content: flex-start;
align-items: center;
color: ${theme.menuItemFg};
--icon-color: ${theme.accentIcon};
.${weasel.cssMenuItem.className}-sel {
color: ${theme.menuItemSelectedFg};
--icon-color: ${theme.menuItemSelectedFg};
}
&.disabled {
cursor: default;
color: ${theme.menuItemDisabledFg};
--icon-color: ${theme.menuItemDisabledFg};
}
`;
export const menuCssClass = cssMenuElem.className;
// Add grist-floating-menu class to support existing browser tests
const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
/**
* Creates a select dropdown widget. The observable `obs` reflects the value of the selected
* option, and `optionArray` is an array (regular or observable) of option values and labels.
* These may be either strings, or {label, value, icon, disabled} objects. Icons are optional
* and must be IconName strings from 'app/client/ui2018/IconList'.
*
* The type of value may be any type at all; it is opaque to this widget.
*
* If obs is set to an invalid or disabled value, then defLabel option is used to determine the
* label that the select box will show, blank by default.
*
* Usage:
* const fruit = observable("apple");
* select(fruit, ["apple", "banana", "mango"]);
*
* const employee = observable(17);
* const allEmployees = Observable.create(owner, [
* {value: 12, label: "Bob", disabled: true},
* {value: 17, label: "Alice"},
* {value: 21, label: "Eve"},
* ]);
* select(employee, allEmployees, {defLabel: "Select employee:"});
*
* Note that this select element is not compatible with browser address autofill for usage in
* forms, and that formSelect should be used for this purpose.
*/
export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
options: weasel.ISelectUserOptions = {}) {
const _menu = cssSelectMenuElem(testId('select-menu'));
const _btn = cssSelectBtn(testId('select-open'));
const {menuCssClass: menuClass, ...otherOptions} = options;
const selectOptions = {
buttonArrow: cssInlineCollapseIcon('Collapse'),
menuCssClass: _menu.className + ' ' + (menuClass || ''),
buttonCssClass: _btn.className,
...otherOptions,
};
return weasel.select(obs, optionArray, selectOptions, (op) =>
cssOptionRow(
op.icon ? cssOptionRowIcon(op.icon) : null,
cssOptionLabel(op.label),
testId('select-row')
)
) as HTMLElement; // TODO: should be changed in weasel
}
/**
* Same as select(), but the main element looks like a link rather than a button.
*/
export function linkSelect<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
options: weasel.ISelectUserOptions = {}) {
const _btn = cssSelectBtnLink(testId('select-open'));
const elem = select(obs, optionArray, {buttonCssClass: _btn.className, ...options});
// It feels strange to have focus stay on this link; remove tabIndex that makes it focusable.
elem.removeAttribute('tabIndex');
return elem;
}
export interface IMultiSelectUserOptions {
placeholder?: string;
error?: Observable<boolean>;
}
/**
* Creates a select dropdown widget that supports selecting multiple options.
*
* The observable array `selectedOptions` reflects the selected options, and
* `availableOptions` is an array (normal or observable) of selectable options.
* These may either be strings, or {label, value} objects.
*/
export function multiSelect<T>(selectedOptions: MutableObsArray<T>,
availableOptions: MaybeObsArray<IOption<T>>,
options: IMultiSelectUserOptions = {},
...domArgs: DomElementArg[]) {
const selectedOptionsSet = Computed.create(null, selectedOptions, (_use, opts) => new Set(opts));
const selectedOptionsText = Computed.create(null, selectedOptionsSet, (use, selectedOpts) => {
if (selectedOpts.size === 0) {
return options.placeholder ?? 'Select fields';
}
const optionArray = Array.isArray(availableOptions) ? availableOptions : use(availableOptions);
return optionArray
.filter(opt => selectedOpts.has(weasel.getOptionFull(opt).value))
.map(opt => weasel.getOptionFull(opt).label)
.join(', ');
});
function buildMultiSelectMenu(ctl: weasel.IOpenController) {
return cssMultiSelectMenu(
{ tabindex: '-1' }, // Allow menu to be focused.
dom.cls(menuCssClass),
dom.onKeyDown({
Enter: () => ctl.close(),
Escape: () => ctl.close()
}),
elem => {
// Set focus on open, so that keyboard events work.
setTimeout(() => elem.focus(), 0);
// Sets menu width to match parent container (button) width.
const style = elem.style;
style.minWidth = ctl.getTriggerElem().getBoundingClientRect().width + 'px';
style.marginLeft = style.marginRight = '0';
},
dom.domComputed(selectedOptionsSet, selectedOpts => {
return dom.forEach(availableOptions, option => {
const fullOption = weasel.getOptionFull(option);
return cssCheckboxLabel(
cssCheckboxSquare(
{type: 'checkbox'},
dom.prop('checked', selectedOpts.has(fullOption.value)),
dom.on('change', (_ev, elem) => {
if (elem.checked) {
selectedOptions.push(fullOption.value);
} else {
selectedOpts.delete(fullOption.value);
selectedOptions.set([...selectedOpts]);
}
}),
dom.style('position', 'relative'),
testId('multi-select-menu-option-checkbox')
),
cssCheckboxText(fullOption.label, testId('multi-select-menu-option-text')),
testId('multi-select-menu-option')
);
});
}),
testId('multi-select-menu')
);
}
return cssSelectBtn(
dom.autoDispose(selectedOptionsSet),
dom.autoDispose(selectedOptionsText),
cssMultiSelectSummary(
dom.text(selectedOptionsText),
cssMultiSelectSummary.cls('-placeholder', use => use(selectedOptionsSet).size === 0)
),
icon('Dropdown'),
elem => {
weasel.setPopupToCreateDom(elem, ctl => buildMultiSelectMenu(ctl), weasel.defaultMenuOptions);
},
dom.style('border', use => {
return options.error && use(options.error)
? `1px solid ${theme.selectButtonBorderInvalid}`
: `1px solid ${theme.selectButtonBorder}`;
}),
...domArgs
);
}
/**
* Creates a select dropdown widget that is more ideal for forms. Implemented using the <select>
* element to work with browser form autofill and typing in the desired value to quickly set it.
* The appearance of the opened menu is OS dependent.
*
* The observable `obs` reflects the value of the selected option, and `optionArray` is an
* array (regular or observable) of option values and labels. These may be either strings,
* or {label, value} objects.
*
* If obs is set to an empty string value, then defLabel option is used to determine the
* label that the select box will show, blank by default.
*
* Usage:
* const fruit = observable("");
* formSelect(fruit, ["apple", "banana", "mango"], {defLabel: "Select fruit:"});
*/
export function formSelect(obs: Observable<string>, optionArray: MaybeObsArray<IOption<string>>,
options: {defaultLabel?: string} = {}) {
const {defaultLabel = ""} = options;
const container: Element = cssSelectBtnContainer(
dom('select', {class: cssSelectBtn.className, style: 'height: 42px; padding: 12px 30px 12px 12px;'},
dom.prop('value', obs),
dom.on('change', (_, elem) => { obs.set(elem.value); }),
dom('option', {value: '', hidden: 'hidden'}, defaultLabel),
dom.forEach(optionArray, (option) => {
const obj: weasel.IOptionFull<string> = weasel.getOptionFull(option);
return dom('option', {value: obj.value}, obj.label);
})
),
cssCollapseIcon('Collapse')
);
return container;
}
export function inputMenu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOptions): DomElementMethod {
// Triggers the input menu on 'input' events, if the input has text inside.
function inputTrigger(triggerElem: Element, ctl: weasel.PopupControl): void {
dom.onElem(triggerElem, 'input', () => {
(triggerElem as HTMLInputElement).value.length > 0 ? ctl.open() : ctl.close();
});
}
return weasel.inputMenu(createFunc, {
trigger: [inputTrigger],
menuCssClass: `${cssMenuElem.className} ${cssInputButtonMenuElem.className}`,
...options
});
}
// A menu item that leads to the billing page if the desired operation requires an upgrade.
// Such menu items are marked with a little sparkle unicode.
export function upgradableMenuItem(needUpgrade: boolean, action: () => void, ...rem: any[]) {
if (needUpgrade) {
return menuItem(() => reportError(new NeedUpgradeError()), ...rem, " *");
} else {
return menuItem(action, ...rem);
}
}
export function upgradeText(needUpgrade: boolean, onClick: () => void) {
if (!needUpgrade) { return null; }
return menuText(dom('span', '* Workspaces are available on team plans. ',
cssUpgradeTextButton('Upgrade now', dom.on('click', () => onClick()))));
}
/**
* Create an autocomplete element and tie it to an input or textarea element.
*
* Usage:
* const employees = ['Thomas', 'June', 'Bethany', 'Mark', 'Marjorey', 'Zachary'];
* const inputElem = input(...);
* autocomplete(inputElem, employees);
*/
export function autocomplete(
inputElem: HTMLInputElement,
choices: MaybeObsArray<string>,
options: weasel.IAutocompleteOptions = {}
) {
return weasel.autocomplete(inputElem, choices, {
...defaults, ...options,
menuCssClass: defaults.menuCssClass + ' ' + cssSelectMenuElem.className + ' ' + (options.menuCssClass || '')
});
}
/**
* Creates simple (not reactive) static menu that looks like a select-box.
* Primary usage is for menus, where you want to control how the options and a default
* label will look. Label is not updated or changed when one of the option is clicked, for those
* use cases use a select component.
* Icons are optional, can use custom elements instead of labels and options.
*
* Usage:
*
* selectMenu(selectTitle("Title", "Script"), () => [
* selectOption(() => ..., "Option1", "Database"),
* selectOption(() => ..., "Option2", "Script"),
* ]);
*
* // Control disabled state (if the menu will be opened or not)
*
* const disabled = observable(false);
* selectMenu(selectTitle("Title", "Script"), () => [
* selectOption(() => ..., "Option1", "Database"),
* selectOption(() => ..., "Option2", "Script"),
* ], disabled);
*
*/
export function selectMenu(
label: DomElementArg,
items: () => DomElementArg[],
...args: IDomArgs<HTMLDivElement>
) {
const _menu = cssSelectMenuElem(testId('select-menu'));
return cssSelectBtn(
label,
icon('Dropdown'),
menu(
items,
{
...weasel.defaultMenuOptions,
menuCssClass: _menu.className + ' grist-floating-menu',
stretchToSelector : `.${cssSelectBtn.className}`,
trigger : [(triggerElem, ctl) => {
const isDisabled = () => triggerElem.classList.contains('disabled');
dom.onElem(triggerElem, 'click', () => isDisabled() || ctl.toggle());
dom.onKeyElem(triggerElem as HTMLElement, 'keydown', {
ArrowDown: () => isDisabled() || ctl.open(),
ArrowUp: () => isDisabled() || ctl.open()
});
}]
},
),
...args,
);
}
export function selectTitle(label: BindableValue<string>, iconName?: BindableValue<IconName>) {
return cssOptionRow(
iconName ? dom.domComputed(iconName, (name) => cssOptionRowIcon(name)) : null,
dom.text(label)
);
}
export function selectOption(
action: (item: HTMLElement) => void,
label: BindableValue<string>,
iconName?: BindableValue<IconName>,
...args: IDomArgs<HTMLElement>) {
return menuItem(action, selectTitle(label, iconName), ...args);
}
export const menuSubHeader = styled('div', `
color: ${theme.menuSubheaderFg};
font-size: ${vars.xsmallFontSize};
text-transform: uppercase;
font-weight: ${vars.bigControlTextWeight};
padding: 8px 24px 16px 24px;
cursor: default;
`);
export const menuText = styled('div', `
display: flex;
align-items: center;
font-size: ${vars.smallFontSize};
color: ${theme.menuText};
padding: 8px 24px 4px 24px;
max-width: 250px;
cursor: default;
`);
export const menuItem = styled(weasel.menuItem, menuItemStyle);
export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle);
/**
* A version of menuItem which runs the action on next tick, allowing the menu to close even when
* the action causes the disabling of the element being clicked.
* TODO disabling the element should not prevent the menu from closing; once fixed in weasel, this
* can be removed.
*/
export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
return menuItem(() => setTimeout(action, 0), ...args);
};
export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) {
return menuItem(
cmd.run,
dom('span', label, testId('cmd-name')),
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
cssMenuItemCmd.cls(''), // overrides some menu item styles
...args
);
}
export function menuAnnotate(text: string, ...args: DomElementArg[]) {
return cssAnnotateMenuItem(text, ...args);
}
export const menuDivider = styled(weasel.cssMenuDivider, `
background-color: ${theme.menuBorder};
margin: 8px 0;
`);
export const menuIcon = styled(icon, `
flex: none;
margin-right: 8px;
`);
const cssSelectMenuElem = styled(cssMenuElem, `
max-height: 400px;
overflow-y: auto;
--weaseljs-menu-item-padding: 8px 16px;
`);
const cssSelectBtnContainer = styled('div', `
position: relative;
width: 100%;
`);
const cssSelectBtnLink = styled('div', `
display: flex;
align-items: center;
font-size: ${vars.mediumFontSize};
color: ${theme.controlFg};
--icon-color: ${theme.controlFg};
width: initial;
height: initial;
line-height: inherit;
background-color: initial;
padding: initial;
border: initial;
border-radius: initial;
box-shadow: initial;
cursor: pointer;
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
&:hover, &:focus, &:active {
color: ${theme.controlHoverFg};
--icon-color: ${theme.controlHoverFg};
box-shadow: initial;
}
`);
const cssOptionIcon = styled(icon, `
height: 16px;
width: 16px;
background-color: ${theme.menuItemIconFg};
margin: -3px 8px 0 2px;
`);
export const cssOptionRow = styled('span', `
display: flex;
align-items: center;
width: 100%;
`);
export const cssOptionRowIcon = styled(cssOptionIcon, `
margin: 0 8px 0 0;
flex: none;
.${weasel.cssMenuItem.className}-sel & {
background-color: ${theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
background-color: ${theme.menuItemDisabledFg};
}
`);
const cssOptionLabel = styled('div', `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.${weasel.cssMenuItem.className} & {
color: ${theme.menuItemFg};
}
.${weasel.cssMenuItem.className}-sel & {
color: ${theme.menuItemSelectedFg};
background-color: ${theme.menuItemSelectedBg};
}
.${weasel.cssMenuItem.className}.disabled & {
color: ${theme.menuItemDisabledFg};
}
`);
const cssInlineCollapseIcon = styled(icon, `
margin: 0 2px;
pointer-events: none;
`);
const cssCollapseIcon = styled(icon, `
position: absolute;
right: 12px;
top: calc(50% - 8px);
pointer-events: none;
background-color: ${theme.selectButtonFg};
`);
const cssInputButtonMenuElem = styled(cssMenuElem, `
padding: 4px 0px;
`);
const cssMenuItemCmd = styled('div', `
justify-content: space-between;
`);
const cssCmdKey = styled('span', `
margin-left: 16px;
color: ${theme.menuItemIconFg};
margin-right: -12px;
.${weasel.cssMenuItem.className}-sel > & {
color: ${theme.menuItemIconSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled > & {
color: ${theme.menuItemDisabledFg};
}
`);
const cssAnnotateMenuItem = styled('span', `
color: ${theme.accentText};
text-transform: uppercase;
font-size: 8px;
vertical-align: super;
margin-top: -4px;
margin-left: 4px;
font-weight: bold;
.${weasel.cssMenuItem.className}-sel > & {
color: ${theme.menuItemIconSelectedFg};
}
`);
const cssMultiSelectSummary = styled('div', `
flex: 1 1 0px;
overflow: hidden;
text-overflow: ellipsis;
color: ${theme.selectButtonFg};
&-placeholder {
color: ${theme.selectButtonPlaceholderFg};
}
`);
const cssMultiSelectMenu = styled(weasel.cssMenu, `
display: flex;
flex-direction: column;
max-height: calc(max(300px, 95vh - 300px));
max-width: 400px;
padding-bottom: 0px;
background-color: ${theme.menuBg};
`);
const cssCheckboxLabel = styled(cssLabel, `
padding: 8px 16px;
`);
const cssCheckboxText = styled(cssLabelText, `
margin-right: 12px;
color: ${theme.text};
white-space: pre;
`);
const cssUpgradeTextButton = styled(textButton, `
font-size: ${vars.smallFontSize};
`);