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 { 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}); } const cssSearchField = styled('input', 'border: none;'+ 'background-color: transparent;'+ 'padding: 8px 24px 4px 24px;'+ '&:focus {outline: none;}' ); export function enhanceBySearch( menuFunc: (searchCriteria: Observable) => 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 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