import { MenuCommand } from 'app/client/components/commandList'; 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, DomContents, DomElementArg, DomElementMethod, IDomArgs, MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs'; import debounce from 'lodash/debounce'; import * as weasel from 'popweasel'; const t = makeT('menus'); export interface IOptionFull { 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 & string) | IOptionFull; 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}); } export interface SearchableMenuOptions { searchInputPlaceholder?: string; } export interface SearchableMenuItem { cleanText: string; builder?: () => Element; label?: string; action?: (item: HTMLElement) => void; args?: DomElementArg[]; } export function searchableMenu( menuItems: MaybeObsArray, options: SearchableMenuOptions = {} ): DomElementArg[] { const {searchInputPlaceholder} = options; const searchValue = Observable.create(null, ''); const setSearchValue = debounce((value) => { searchValue.set(value); }, 100); return [ menuItemStatic( cssMenuSearch( cssMenuSearchIcon('Search'), cssMenuSearchInput( dom.autoDispose(searchValue), dom.on('input', (_ev, elem) => { setSearchValue(elem.value); }), {placeholder: searchInputPlaceholder}, testId('searchable-menu-input'), ), ), ), menuDivider(), dom.domComputed(searchValue, (value) => { const cleanSearchValue = value.trim().toLowerCase(); return dom.forEach(menuItems, (item) => { if (!item.cleanText.includes(cleanSearchValue)) { return null; } if (item.label && item.action) { return menuItem(item.action, item.label, ...(item.args || [])); } else if (item.builder) { return item.builder(); } else { throw new Error('Invalid menu item'); } }); }), testId('searchable-menu'), ]; } // TODO Weasel doesn't allow other options for submenus, but probably should. export type ISubMenuOptions = weasel.ISubMenuOptions & weasel.IPopupOptions & {allowNothingSelected?: boolean}; /** * Menu item with submenu */ export function menuItemSubmenu( submenu: weasel.MenuCreateFunc, options: ISubMenuOptions, ...args: DomElementArg[] ): Element { return weasel.menuItemSubmenu( submenu, { ...defaults, expandIcon: () => cssExpandIcon('Expand'), menuCssClass: `${cssSubMenuElem.className} ${defaults.menuCssClass}`, ...options, }, dom.cls(cssMenuItemSubmenu.className), ...args ); } /** * Subheader as a menu item. */ export function menuSubHeaderMenu( submenu: weasel.MenuCreateFunc, options: ISubMenuOptions, ...args: DomElementArg[] ): Element { return menuItemSubmenu( submenu, { ...options, }, menuSubHeader.cls(''), cssPointer.cls(''), ...args, ); } export const cssEllipsisLabel = styled('div', ` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; `); export const cssExpandIcon = styled(icon, ` position: absolute; right: 4px; `); const cssSubMenuElem = styled('div', ` white-space: nowrap; min-width: 200px; `); 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 extends weasel.ISelectUserOptions { /** Additional DOM element args to pass to each select option. */ renderOptionArgs?: (option: IOptionFull) => 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(obs: Observable, optionArray: MaybeObsArray>, options: SelectOptions = {}) { 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(obs: Observable, optionArray: MaybeObsArray>, 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; } /** * 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(selectedOptions: MutableObsArray, availableOptions: MaybeObsArray>, 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