gristlabs_grist-core/app/client/ui2018/menus.ts
Jakub Serafin 2521db4c55 (core) New Columns Menu
Summary:
A menu to be shown when new colum button is added. It's give access to various diffrent shortcuts, like adding new column, unhiding existing ones, fast adding lookup columns or trigger one (authoriship or timestamp). Design document can be found here: https://grist.quip.com/CTgxAQv9Ghjt/Add-Columns-more-easily
To turn on this menu flag GRIST_NEW_COLUMN_MENU to 1

Test Plan: UI tests suite under nbrowser/GridViewNewColumnMenu.ts

Reviewers: jarek, georgegevoian

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4074
2023-10-13 22:35:36 +02:00

672 lines
21 KiB
TypeScript

import { Command } from 'app/client/components/commands';
import { FocusLayer } from 'app/client/lib/FocusLayer';
import { makeT } from 'app/client/lib/localization';
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';
const t = makeT('menus');
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.
// 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});
}
const cssSearchField = styled('input',
'border: none;'+
'background-color: transparent;'+
'padding: 8px 24px 4px 24px;'+
'&:focus {outline: none;}'
);
export function enhanceBySearch( menuFunc: (searchCriteria: Observable<string>) => DomElementArg[]): DomElementArg[]
{
const searchCriteria = Observable.create(null, '');
const searchInput = [
menuItemStatic(
cssSearchField(
dom.on('input', (_ev, elem) => searchCriteria.set(elem.value)),
{placeholder: '🔍\uFE0E\t' + t("Search columns")}
)
),
menuDivider(),
];
return [...searchInput, ...menuFunc(searchCriteria)];
}
// 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: ${vars.menuZIndex};
--weaseljs-selected-background-color: ${theme.menuItemSelectedBg};
--weaseljs-menu-item-padding: 8px 24px;
background-color: ${theme.menuBg};
@media print {
& {
display: none;
}
}
`);
export 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 menuItemStatic = styled('div', menuItemStyle);
export const menuCssClass = cssMenuElem.className;
// Add grist-floating-menu class to support existing browser tests
const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
export interface SelectOptions<T> extends weasel.ISelectUserOptions {
/** Additional DOM element args to pass to each select option. */
renderOptionArgs?: (option: IOptionFull<T | null>) => DomElementArg;
}
/**
* 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:"});
*
* const name = observable("alice");
* const names = ["alice", "bob", "carol"];
* select(name, names, {renderOptionArgs: (op) => console.log(`Rendered option ${op.value}`)});
*
* 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: SelectOptions<T> = {}) {
const {renderOptionArgs, ...weaselOptions} = options;
const _menu = cssSelectMenuElem(testId('select-menu'));
const _btn = cssSelectBtn(testId('select-open'));
const {menuCssClass: menuClass, ...otherOptions} = weaselOptions;
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(t(op.label)),
renderOptionArgs ? renderOptionArgs(op) : null,
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 ?? t("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),
FocusLayer.attach({pauseMousetrap: true}),
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', t("* Workspaces are available on team plans. "),
cssUpgradeTextButton(t("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>
) {
return cssSelectBtn(
label,
icon('Dropdown'),
listOfMenuItems(items),
...args,
);
}
export function listOfMenuItems(items: () => DomElementArg[],) {
const _menu = cssSelectMenuElem(testId('select-menu'));
return 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()
});
}]
},
);
}
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;
}
`);
export const cssOptionRow = styled('span', `
display: flex;
align-items: center;
width: 100%;
`);
export const cssOptionRowIcon = styled(icon, `
height: 16px;
width: 16px;
background-color: var(--icon-color, ${theme.menuItemIconFg});
margin: -3px 8px 0 2px;
margin: 0 8px 0 0;
flex: none;
.${weasel.cssMenuItem.className}-sel & {
background-color: ${theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
background-color: ${theme.menuItemDisabledFg};
}
`);
export const cssOptionLabel = styled('div', `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
--grist-option-label-color: ${theme.menuItemFg};
--grist-option-label-color-sel: ${theme.menuItemSelectedFg};
--grist-option-label-color-disabled: ${theme.menuItemDisabledFg};
.${weasel.cssMenuItem.className} & {
color: var(--grist-option-label-color);
}
.${weasel.cssMenuItem.className}-sel & {
color: var(--grist-option-label-color-sel);
background-color: ${theme.menuItemSelectedBg};
}
.${weasel.cssMenuItem.className}.disabled & {
color: var(--grist-option-label-color-disabled);
}
`);
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};
`);