import type { Column, DOMMouseOrTouchEvent, GridMenuCommandItemCallbackArgs, GridMenuEventWithElementCallbackArgs, GridMenuItem, GridMenuOption, GridOption, MenuCommandItem, onGridMenuColumnsChangedCallbackArgs } from '../models/index.js'; import { BindingEventService as BindingEventService_, SlickEvent as SlickEvent_, Utils as Utils_ } from '../slick.core.js'; import type { SlickGrid } from '../slick.grid.js'; // for (iife) load Slick methods from global Slick object, or use imports for (esm) const BindingEventService = IIFE_ONLY ? Slick.BindingEventService : BindingEventService_; const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; /** * A control to add a Grid Menu (hambuger menu on top-right of the grid) * * USAGE: * * Add the slick.gridmenu.(js|css) files and register it with the grid. * * To specify a menu in a column header, extend the column definition like so: * let gridMenuControl = new Slick.Controls.GridMenu(columns, grid, options); * * Available grid options, by defining a gridMenu object: * * let options = { * enableCellNavigation: true, * gridMenu: { * commandTitle: "Command List", // default to empty string * columnTitle: "Columns", // default to empty string * iconImage: "some-image.png", // this is the Grid Menu icon (hamburger icon) * iconCssClass: "fa fa-bars", // you can provide iconImage OR iconCssClass * leaveOpen: false, // do we want to leave the Grid Menu open after a command execution? (false by default) * menuWidth: 18, // width (icon) that will be use to resize the column header container (18 by default) * contentMinWidth: 0, // defaults to 0 (auto), minimum width of grid menu content (command, column list) * marginBottom: 15, // defaults to 15, margin to use at the bottom of the grid when using max-height (default) * resizeOnShowHeaderRow: false, // false by default * showButton: true, // true by default - it allows the user to control if the * // default gridMenu button (located on the top right corner by default CSS) * // should be created or omitted * useClickToRepositionMenu: true, // true by default * * // the last 2 checkboxes titles * hideForceFitButton: false, // show/hide checkbox near the end "Force Fit Columns" * hideSyncResizeButton: false, // show/hide checkbox near the end "Synchronous Resize" * forceFitTitle: "Force fit columns", // default to "Force fit columns" * syncResizeTitle: "Synchronous resize", // default to "Synchronous resize" * * commandItems: [ * { * // command menu item options * }, * { * // command menu item options * } * ] * } * }; * * * Available menu options: * hideForceFitButton: Hide the "Force fit columns" button (defaults to false) * hideSyncResizeButton: Hide the "Synchronous resize" button (defaults to false) * forceFitTitle: Text of the title "Force fit columns" * contentMinWidth: minimum width of grid menu content (command, column list), defaults to 0 (auto) * height: Height of the Grid Menu content, when provided it will be used instead of the max-height (defaults to undefined) * menuWidth: Grid menu button width (defaults to 18) * resizeOnShowHeaderRow: Do we want to resize on the show header row event * syncResizeTitle: Text of the title "Synchronous resize" * useClickToRepositionMenu: Use the Click offset to reposition the Grid Menu (defaults to true), when set to False it will use the icon offset to reposition the grid menu * menuUsabilityOverride: Callback method that user can override the default behavior of enabling/disabling the menu from being usable (must be combined with a custom formatter) * marginBottom: Margin to use at the bottom of the grid menu, only in effect when height is undefined (defaults to 15) * subItemChevronClass: CSS class that can be added on the right side of a sub-item parent (typically a chevron-right icon) * subMenuOpenByEvent: defaults to "mouseover", what event type shoud we use to open sub-menu(s), 2 options are available: "mouseover" or "click" * * Available command menu item options: * action: Optionally define a callback function that gets executed when item is chosen (and/or use the onCommand event) * title: Menu item text. * divider: Whether the current item is a divider, not an actual command. * disabled: Whether the item/command is disabled. * hidden: Whether the item/command is hidden. * tooltip: Item tooltip. * command: A command identifier to be passed to the onCommand event handlers. * cssClass: A CSS class to be added to the menu item container. * iconCssClass: A CSS class to be added to the menu item icon. * iconImage: A url to the icon image. * textCssClass: A CSS class to be added to the menu item text. * subMenuTitle: Optional sub-menu title that will shows up when sub-menu commmands/options list is opened * subMenuTitleCssClass: Optional sub-menu title CSS class to use with `subMenuTitle` * itemVisibilityOverride: Callback method that user can override the default behavior of showing/hiding an item from the list * itemUsabilityOverride: Callback method that user can override the default behavior of enabling/disabling an item from the list * * * The plugin exposes the following events: * * onAfterMenuShow: Fired after the menu is shown. You can customize the menu or dismiss it by returning false. * * ONLY works with a JS event (as per slick.core code), so we cannot notify when it's a button event (when grid menu is attached to an external button, not the hamburger menu) * Event args: * grid: Reference to the grid. * column: Column definition. * menu: Menu options. Note that you can change the menu items here. * * onBeforeMenuShow: Fired before the menu is shown. You can customize the menu or dismiss it by returning false. * * ONLY works with a JS event (as per slick.core code), so we cannot notify when it's a button event (when grid menu is attached to an external button, not the hamburger menu) * Event args: * grid: Reference to the grid. * column: Column definition. * menu: Menu options. Note that you can change the menu items here. * * onMenuClose: Fired when the menu is closing. * Event args: * grid: Reference to the grid. * column: Column definition. * menu: Menu options. Note that you can change the menu items here. * * onCommand: Fired on menu item click for buttons with 'command' specified. * Event args: * grid: Reference to the grid. * column: Column definition. * command: Button command identified. * button: Button options. Note that you can change the button options in your * event handler, and the column header will be automatically updated to * reflect them. This is useful if you want to implement something like a * toggle button. */ export class SlickGridMenu { // -- // public API onAfterMenuShow = new SlickEvent('onAfterMenuShow'); onBeforeMenuShow = new SlickEvent('onBeforeMenuShow'); onMenuClose = new SlickEvent('onMenuClose'); onCommand = new SlickEvent('onCommand'); onColumnsChanged = new SlickEvent('onColumnsChanged'); // -- // protected props protected _bindingEventService: BindingEventService_; protected _gridOptions: GridOption; protected _gridUid: string; protected _isMenuOpen = false; protected _columnCheckboxes: HTMLInputElement[] = []; protected _columnTitleElm!: HTMLElement; protected _commandTitleElm!: HTMLElement; protected _commandListElm!: HTMLDivElement; protected _headerElm: HTMLDivElement | null = null; protected _listElm!: HTMLElement; protected _buttonElm!: HTMLElement; protected _menuElm!: HTMLElement; protected _subMenuParentId = ''; protected _gridMenuOptions: GridMenuOption | null = null; protected _defaults: GridMenuOption = { showButton: true, hideForceFitButton: false, hideSyncResizeButton: false, forceFitTitle: 'Force fit columns', marginBottom: 15, menuWidth: 18, contentMinWidth: 0, resizeOnShowHeaderRow: false, subMenuOpenByEvent: 'mouseover', syncResizeTitle: 'Synchronous resize', useClickToRepositionMenu: true, headerColumnValueExtractor: (columnDef: Column) => Utils.getHtmlStringOutput(columnDef.name || '', 'innerHTML'), }; constructor(protected columns: Column[], protected readonly grid: SlickGrid, gridOptions: GridOption) { this._gridUid = grid.getUID(); this._gridOptions = gridOptions; this._gridMenuOptions = Utils.extend({}, this._defaults, gridOptions.gridMenu); this._bindingEventService = new BindingEventService(); // when a grid optionally changes from a regular grid to a frozen grid, we need to destroy & recreate the grid menu // we do this change because the Grid Menu is on the left container for a regular grid, it is however on the right container for a frozen grid grid.onSetOptions.subscribe((_e, args) => { if (args && args.optionsBefore && args.optionsAfter) { const switchedFromRegularToFrozen = args.optionsBefore.frozenColumn! >= 0 && args.optionsAfter.frozenColumn === -1; const switchedFromFrozenToRegular = args.optionsBefore.frozenColumn === -1 && args.optionsAfter.frozenColumn! >= 0; if (switchedFromRegularToFrozen || switchedFromFrozenToRegular) { this.recreateGridMenu(); } } }); this.init(this.grid); } init(grid: SlickGrid) { this._gridOptions = grid.getOptions(); Utils.addSlickEventPubSubWhenDefined(grid.getPubSubService(), this); this.createGridMenu(); if (this._gridMenuOptions?.customItems || this._gridMenuOptions?.customTitle) { console.warn('[SlickGrid] Grid Menu "customItems" and "customTitle" were deprecated to align with other Menu plugins, please use "commandItems" and "commandTitle" instead.'); } // subscribe to the grid, when it's destroyed, we should also destroy the Grid Menu grid.onBeforeDestroy.subscribe(this.destroy.bind(this)); } setOptions(newOptions: GridMenuOption) { this._gridMenuOptions = Utils.extend({}, this._gridMenuOptions, newOptions); } protected createGridMenu() { const gridMenuWidth = (this._gridMenuOptions?.menuWidth) || this._defaults.menuWidth; if (this._gridOptions && this._gridOptions.hasOwnProperty('frozenColumn') && this._gridOptions.frozenColumn! >= 0) { this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-right`); } else { this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-left`); } this._headerElm!.style.width = `calc(100% - ${gridMenuWidth}px)`; // if header row is enabled, we need to resize its width also const enableResizeHeaderRow = (Utils.isDefined(this._gridMenuOptions?.resizeOnShowHeaderRow)) ? this._gridMenuOptions!.resizeOnShowHeaderRow : this._defaults.resizeOnShowHeaderRow; if (enableResizeHeaderRow && this._gridOptions.showHeaderRow) { const headerRow = document.querySelector(`.${this._gridUid}.slick-headerrow`); if (headerRow) { headerRow.style.width = `calc(100% - ${gridMenuWidth}px)`; } } const showButton = (this._gridMenuOptions?.showButton !== undefined) ? this._gridMenuOptions.showButton : this._defaults.showButton; if (showButton) { this._buttonElm = document.createElement('button'); this._buttonElm.className = 'slick-gridmenu-button'; this._buttonElm.ariaLabel = 'Grid Menu'; if (this._gridMenuOptions?.iconCssClass) { this._buttonElm.classList.add(...Utils.classNameToList(this._gridMenuOptions.iconCssClass)); } else { const iconImageElm = document.createElement('img'); iconImageElm.src = (this._gridMenuOptions?.iconImage) ? this._gridMenuOptions.iconImage : '../images/drag-handle.png'; this._buttonElm.appendChild(iconImageElm); } // add the grid menu button in the preheader (when exists) or always in the column header (default) const buttonContainerTarget = this._gridMenuOptions?.iconButtonContainer === 'preheader' ? 'firstChild' : 'lastChild'; this._headerElm!.parentElement!.insertBefore(this._buttonElm, this._headerElm!.parentElement![buttonContainerTarget]); // add on click handler for the Grid Menu itself this._bindingEventService.bind(this._buttonElm, 'click', this.showGridMenu.bind(this) as EventListener); } this._menuElm = this.createMenu(0); this.populateColumnPicker(); document.body.appendChild(this._menuElm); // Hide the menu on outside click. this._bindingEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener); // destroy the picker if user leaves the page this._bindingEventService.bind(document.body, 'beforeunload', this.destroy.bind(this)); } /** Create the menu or sub-menu(s) but without the column picker which is a separate single process */ createMenu(level = 0, item?: GridMenuItem | MenuCommandItem | 'divider') { // create a new cell menu const maxHeight = isNaN(this._gridMenuOptions?.maxHeight as number) ? this._gridMenuOptions?.maxHeight : `${this._gridMenuOptions?.maxHeight ?? 0}px`; const width = isNaN(this._gridMenuOptions?.width as number) ? this._gridMenuOptions?.width : `${this._gridMenuOptions?.maxWidth ?? 0}px`; // to avoid having multiple sub-menu trees opened, // we need to somehow keep trace of which parent menu the tree belongs to // and we should keep ref of only the first sub-menu parent, we can use the command name (remove any whitespaces though) const subMenuCommand = (item as GridMenuItem)?.command; let subMenuId = (level === 1 && subMenuCommand) ? subMenuCommand.replaceAll(' ', '') : ''; if (subMenuId) { this._subMenuParentId = subMenuId; } if (level > 1) { subMenuId = this._subMenuParentId; } const menuClasses = `slick-gridmenu slick-menu-level-${level} ${this._gridUid}`; const bodyMenuElm = document.body.querySelector(`.slick-gridmenu.slick-menu-level-${level}${this.getGridUidSelector()}`); // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all if (bodyMenuElm) { if (bodyMenuElm.dataset.subMenuParent === subMenuId) { return bodyMenuElm; } this.destroySubMenus(); } const menuElm = document.createElement('div'); menuElm.role = 'menu'; menuElm.className = menuClasses; if (level > 0) { menuElm.classList.add('slick-submenu'); if (subMenuId) { menuElm.dataset.subMenuParent = subMenuId; } } menuElm.ariaLabel = level > 1 ? 'SubMenu' : 'Grid Menu'; if (width) { menuElm.style.width = width as string; } if (maxHeight) { menuElm.style.maxHeight = maxHeight as string; } menuElm.style.display = 'none'; let closeButtonElm: HTMLButtonElement | null = null; if (level === 0) { closeButtonElm = document.createElement('button'); closeButtonElm.type = 'button'; closeButtonElm.className = 'close'; closeButtonElm.dataset.dismiss = 'slick-gridmenu'; closeButtonElm.ariaLabel = 'Close'; const spanCloseElm = document.createElement('span'); spanCloseElm.className = 'close'; spanCloseElm.ariaHidden = 'true'; spanCloseElm.textContent = '×'; closeButtonElm.appendChild(spanCloseElm); menuElm.appendChild(closeButtonElm); } // -- Command List section this._commandListElm = document.createElement('div'); this._commandListElm.className = `slick-gridmenu-custom slick-gridmenu-command-list slick-menu-level-${level}`; this._commandListElm.role = 'menu'; menuElm.appendChild(this._commandListElm); const commandItems = (item as GridMenuItem)?.commandItems ?? (item as GridMenuItem)?.customItems ?? this._gridMenuOptions?.commandItems ?? this._gridMenuOptions?.customItems ?? []; if (commandItems.length > 0) { // when creating sub-menu add its sub-menu title when exists if (item && level > 0) { this.addSubMenuTitleWhenExists(item, this._commandListElm); // add sub-menu title when exists } } this.populateCommandsMenu(commandItems, this._commandListElm, { grid: this.grid, level }); // increment level for possible next sub-menus if exists level++; return menuElm; } /** Destroy the plugin by unsubscribing every events & also delete the menu DOM elements */ destroy() { this.onAfterMenuShow.unsubscribe(); this.onBeforeMenuShow.unsubscribe(); this.onMenuClose.unsubscribe(); this.onCommand.unsubscribe(); this.onColumnsChanged.unsubscribe(); this.grid.onColumnsReordered.unsubscribe(this.updateColumnOrder.bind(this)); this.grid.onBeforeDestroy.unsubscribe(); this.grid.onSetOptions.unsubscribe(); this._bindingEventService.unbindAll(); this._menuElm?.remove(); this.deleteMenu(); } /** Delete the menu DOM element but without unsubscribing any events */ deleteMenu() { this._bindingEventService.unbindAll(); const gridMenuElm = document.querySelector(`div.slick-gridmenu.${this._gridUid}`); if (gridMenuElm) { gridMenuElm.style.display = 'none'; } if (this._headerElm) { // put back original width (fixes width and frozen+gridMenu on left header) this._headerElm.style.width = '100%'; } this._buttonElm?.remove(); this._menuElm?.remove(); } /** Close and destroy all previously opened sub-menus */ destroySubMenus() { this._bindingEventService.unbindAll('sub-menu'); document.querySelectorAll(`.slick-gridmenu.slick-submenu${this.getGridUidSelector()}`) .forEach(subElm => subElm.remove()); } /** Construct the custom command menu items. */ protected populateCommandsMenu(commandItems: Array, commandListElm: HTMLElement, args: { grid: SlickGrid, level: number }) { // user could pass a title on top of the custom section const level = args?.level || 0; const isSubMenu = level > 0; if (!isSubMenu && (this._gridMenuOptions?.commandTitle || this._gridMenuOptions?.customTitle)) { this._commandTitleElm = document.createElement('div'); this._commandTitleElm.className = 'title'; this.grid.applyHtmlCode(this._commandTitleElm, this.grid.sanitizeHtmlString((this._gridMenuOptions.commandTitle || this._gridMenuOptions.customTitle) as string)); commandListElm.appendChild(this._commandTitleElm); } for (let i = 0, ln = commandItems.length; i < ln; i++) { let addClickListener = true; const item = commandItems[i]; const callbackArgs = { grid: this.grid, menu: this._menuElm, columns: this.columns, visibleColumns: this.getVisibleColumns() }; // run each override functions to know if the item is visible and usable const isItemVisible = this.runOverrideFunctionWhenExists((item as GridMenuItem).itemVisibilityOverride, callbackArgs); const isItemUsable = this.runOverrideFunctionWhenExists((item as GridMenuItem).itemUsabilityOverride, callbackArgs); // if the result is not visible then there's no need to go further if (!isItemVisible) { continue; } // when the override is defined, we need to use its result to update the disabled property // so that "handleMenuItemClick" has the correct flag and won't trigger a command clicked event if (Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { (item as GridMenuItem).disabled = isItemUsable ? false : true; } const liElm = document.createElement('div'); liElm.className = 'slick-gridmenu-item'; liElm.role = 'menuitem'; if ((item as GridMenuItem).divider || item === 'divider') { liElm.classList.add('slick-gridmenu-item-divider'); addClickListener = false; } if ((item as GridMenuItem).disabled) { liElm.classList.add('slick-gridmenu-item-disabled'); } if ((item as GridMenuItem).hidden) { liElm.classList.add('slick-gridmenu-item-hidden'); } if ((item as GridMenuItem).cssClass) { liElm.classList.add(...Utils.classNameToList((item as GridMenuItem).cssClass)); } if ((item as GridMenuItem).tooltip) { liElm.title = (item as GridMenuItem).tooltip || ''; } const iconElm = document.createElement('div'); iconElm.className = 'slick-gridmenu-icon'; liElm.appendChild(iconElm); if ((item as GridMenuItem).iconCssClass) { iconElm.classList.add(...Utils.classNameToList((item as GridMenuItem).iconCssClass)); } if ((item as GridMenuItem).iconImage) { iconElm.style.backgroundImage = `url(${(item as GridMenuItem).iconImage})`; } const textElm = document.createElement('span'); textElm.className = 'slick-gridmenu-content'; this.grid.applyHtmlCode(textElm, this.grid.sanitizeHtmlString((item as GridMenuItem).title || '')); liElm.appendChild(textElm); if ((item as GridMenuItem).textCssClass) { textElm.classList.add(...Utils.classNameToList((item as GridMenuItem).textCssClass)); } commandListElm.appendChild(liElm); if (addClickListener) { const eventGroup = isSubMenu ? 'sub-menu' : 'parent-menu'; this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item, level) as EventListener, undefined, eventGroup); } // optionally open sub-menu(s) by mouseover if (this._gridMenuOptions?.subMenuOpenByEvent === 'mouseover') { this._bindingEventService.bind(liElm, 'mouseover', ((e: DOMMouseOrTouchEvent) => { if ((item as GridMenuItem).commandItems || (item as GridMenuItem).customItems) { this.repositionSubMenu(item, level, e); } else if (!isSubMenu) { this.destroySubMenus(); } }) as EventListener); } // the option/command item could be a sub-menu if it has another list of commands/options if ((item as GridMenuItem).commandItems || (item as GridMenuItem).customItems) { const chevronElm = document.createElement('span'); chevronElm.className = 'sub-item-chevron'; if (this._gridMenuOptions?.subItemChevronClass) { chevronElm.classList.add(...Utils.classNameToList(this._gridMenuOptions.subItemChevronClass)); } else { chevronElm.textContent = '⮞'; // ⮞ or ▸ } liElm.classList.add('slick-submenu-item'); liElm.appendChild(chevronElm); continue; } } } /** Build the column picker, the code comes almost untouched from the file "slick.columnpicker.js" */ protected populateColumnPicker() { this.grid.onColumnsReordered.subscribe(this.updateColumnOrder.bind(this)); // user could pass a title on top of the columns list if (this._gridMenuOptions?.columnTitle) { this._columnTitleElm = document.createElement('div'); this._columnTitleElm.className = 'title'; this.grid.applyHtmlCode(this._columnTitleElm, this.grid.sanitizeHtmlString(this._gridMenuOptions.columnTitle)); this._menuElm.appendChild(this._columnTitleElm); } this._bindingEventService.bind(this._menuElm, 'click', this.updateColumn.bind(this) as EventListener); this._listElm = document.createElement('span'); this._listElm.className = 'slick-gridmenu-list'; this._listElm.role = 'menu'; } /** Delete and then Recreate the Grid Menu (for example when we switch from regular to a frozen grid) */ recreateGridMenu() { this.deleteMenu(); this.init(this.grid); } showGridMenu(e: DOMMouseOrTouchEvent) { const targetEvent = e.touches ? e.touches[0] : e; e.preventDefault(); // empty both the picker list & the command list Utils.emptyElement(this._listElm); Utils.emptyElement(this._commandListElm); const commandItems = this._gridMenuOptions?.commandItems ?? this._gridMenuOptions?.customItems ?? []; this.populateCommandsMenu(commandItems, this._commandListElm, { grid: this.grid, level: 0 }); this.updateColumnOrder(); this._columnCheckboxes = []; const callbackArgs = { grid: this.grid, menu: this._menuElm, allColumns: this.columns, visibleColumns: this.getVisibleColumns() }; // run the override function (when defined), if the result is false it won't go further if (this._gridMenuOptions && !this.runOverrideFunctionWhenExists(this._gridMenuOptions.menuUsabilityOverride, callbackArgs)) { return; } // notify of the onBeforeMenuShow only works when // this mean that we cannot notify when the grid menu is attach to a button event if (typeof e.stopPropagation === 'function') { if (this.onBeforeMenuShow.notify(callbackArgs, e, this).getReturnValue() === false) { return; } } let columnId, columnLabel, excludeCssClass; for (let i = 0; i < this.columns.length; i++) { columnId = this.columns[i].id; excludeCssClass = this.columns[i].excludeFromGridMenu ? 'hidden' : ''; const colName: string = this.columns[i].name instanceof HTMLElement ? (this.columns[i].name as HTMLElement).innerHTML : (this.columns[i].name || '') as string; const liElm = document.createElement('li'); liElm.className = excludeCssClass; liElm.ariaLabel = colName; const checkboxElm = document.createElement('input'); checkboxElm.type = 'checkbox'; checkboxElm.id = `${this._gridUid}-gridmenu-colpicker-${columnId}`; checkboxElm.dataset.columnid = String(this.columns[i].id); liElm.appendChild(checkboxElm); if (Utils.isDefined(this.grid.getColumnIndex(this.columns[i].id)) && !this.columns[i].hidden) { checkboxElm.checked = true; } this._columnCheckboxes.push(checkboxElm); // get the column label from the picker value extractor (user can optionally provide a custom extractor) columnLabel = (this._gridMenuOptions?.headerColumnValueExtractor) ? this._gridMenuOptions.headerColumnValueExtractor(this.columns[i], this._gridOptions) : this._defaults.headerColumnValueExtractor!(this.columns[i]); const labelElm = document.createElement('label'); labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-${columnId}`; this.grid.applyHtmlCode(labelElm, this.grid.sanitizeHtmlString(Utils.getHtmlStringOutput(columnLabel || ''))); liElm.appendChild(labelElm); this._listElm.appendChild(liElm); } if (this._gridMenuOptions && (!this._gridMenuOptions.hideForceFitButton || !this._gridMenuOptions.hideSyncResizeButton)) { this._listElm.appendChild(document.createElement('hr')); } if (!(this._gridMenuOptions?.hideForceFitButton)) { const forceFitTitle = (this._gridMenuOptions?.forceFitTitle) || this._defaults.forceFitTitle as string; const liElm = document.createElement('li'); liElm.ariaLabel = forceFitTitle; liElm.role = 'menuitem'; this._listElm.appendChild(liElm); const forceFitCheckboxElm = document.createElement('input'); forceFitCheckboxElm.type = 'checkbox'; forceFitCheckboxElm.id = `${this._gridUid}-gridmenu-colpicker-forcefit`; forceFitCheckboxElm.dataset.option = 'autoresize'; liElm.appendChild(forceFitCheckboxElm); const labelElm = document.createElement('label'); labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-forcefit`; labelElm.textContent = forceFitTitle; liElm.appendChild(labelElm); if (this.grid.getOptions().forceFitColumns) { forceFitCheckboxElm.checked = true; } } if (!(this._gridMenuOptions?.hideSyncResizeButton)) { const syncResizeTitle = (this._gridMenuOptions?.syncResizeTitle) || this._defaults.syncResizeTitle as string; const liElm = document.createElement('li'); liElm.ariaLabel = syncResizeTitle; this._listElm.appendChild(liElm); const syncResizeCheckboxElm = document.createElement('input'); syncResizeCheckboxElm.type = 'checkbox'; syncResizeCheckboxElm.id = `${this._gridUid}-gridmenu-colpicker-syncresize`; syncResizeCheckboxElm.dataset.option = 'syncresize'; liElm.appendChild(syncResizeCheckboxElm); const labelElm = document.createElement('label'); labelElm.htmlFor = `${this._gridUid}-gridmenu-colpicker-syncresize`; labelElm.textContent = syncResizeTitle; liElm.appendChild(labelElm); if (this.grid.getOptions().syncColumnCellResize) { syncResizeCheckboxElm.checked = true; } } let buttonElm = (e.target.nodeName === 'BUTTON' ? e.target : e.target.querySelector('button')) as HTMLButtonElement; // get button element if (!buttonElm) { buttonElm = e.target.parentElement as HTMLButtonElement; // external grid menu might fall in this last case if wrapped in a span/div } // we need to display the menu to properly calculate its width but we can however make it invisible this._menuElm.style.display = 'block'; this._menuElm.style.opacity = '0'; this.repositionMenu(e, this._menuElm, buttonElm); // set "height" when defined OR ELSE use the "max-height" with available window size and optional margin bottom const menuMarginBottom = (this._gridMenuOptions?.marginBottom !== undefined) ? this._gridMenuOptions.marginBottom : this._defaults.marginBottom as number; if (this._gridMenuOptions?.height !== undefined) { this._menuElm.style.height = `${this._gridMenuOptions.height}px`; } else { this._menuElm.style.maxHeight = `${window.innerHeight - targetEvent.clientY - menuMarginBottom}px`; } this._menuElm.style.display = 'block'; this._menuElm.style.opacity = '1'; // restore menu visibility this._menuElm.appendChild(this._listElm); this._isMenuOpen = true; if (typeof e.stopPropagation === 'function') { if (this.onAfterMenuShow.notify(callbackArgs, e, this).getReturnValue() === false) { return; } } } protected getGridUidSelector() { const gridUid = this.grid.getUID() || ''; return gridUid ? `.${gridUid}` : ''; } protected handleBodyMouseDown(e: DOMMouseOrTouchEvent) { // did we click inside the menu or any of its sub-menu(s) let isMenuClicked = false; if (this._menuElm?.contains(e.target)) { isMenuClicked = true; } if (!isMenuClicked) { document .querySelectorAll(`.slick-gridmenu.slick-submenu${this.getGridUidSelector()}`) .forEach(subElm => { if (subElm.contains(e.target)) { isMenuClicked = true; } }); } if ((this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented && this._isMenuOpen) || e.target.className === 'close') { this.hideMenu(e); } } protected handleMenuItemClick(item: GridMenuItem | MenuCommandItem | 'divider', level = 0, e: DOMMouseOrTouchEvent) { if (item !== 'divider' && !item.disabled && !item.divider) { const command = item.command || ''; if (Utils.isDefined(command) && !item.commandItems && !(item as GridMenuItem).customItems) { const callbackArgs: GridMenuCommandItemCallbackArgs = { grid: this.grid, command, item, allColumns: this.columns, visibleColumns: this.getVisibleColumns() }; this.onCommand.notify(callbackArgs, e, this); // execute action callback when defined if (typeof item.action === 'function') { (item as MenuCommandItem).action!.call(this, e, callbackArgs); } // does the user want to leave open the Grid Menu after executing a command? const leaveOpen = !!(this._gridMenuOptions?.leaveOpen); if (!leaveOpen && !e.defaultPrevented) { this.hideMenu(e); } // Stop propagation so that it doesn't register as a header click event. e.preventDefault(); e.stopPropagation(); } else if (item.commandItems || (item as GridMenuItem).customItems) { this.repositionSubMenu(item, level, e); } else { this.destroySubMenus(); } } } hideMenu(e: DOMMouseOrTouchEvent) { if (this._menuElm) { const callbackArgs = { grid: this.grid, menu: this._menuElm, allColumns: this.columns, visibleColumns: this.getVisibleColumns() }; if (this._isMenuOpen && this.onMenuClose.notify(callbackArgs, e, this).getReturnValue() === false) { return; } this._isMenuOpen = false; Utils.hide(this._menuElm); } this.destroySubMenus(); } /** Update the Titles of each sections (command, commandTitle, ...) */ updateAllTitles(gridMenuOptions: GridMenuOption) { if (this._commandTitleElm) { this.grid.applyHtmlCode(this._commandTitleElm, this.grid.sanitizeHtmlString(gridMenuOptions.commandTitle || gridMenuOptions.customTitle || '')); } if (this._columnTitleElm) { this.grid.applyHtmlCode(this._columnTitleElm, this.grid.sanitizeHtmlString(gridMenuOptions.columnTitle || '')); } } protected addSubMenuTitleWhenExists(item: GridMenuItem | MenuCommandItem | 'divider', commandOrOptionMenu: HTMLDivElement) { if (item !== 'divider' && item?.subMenuTitle) { const subMenuTitleElm = document.createElement('div'); subMenuTitleElm.className = 'slick-menu-title'; subMenuTitleElm.textContent = item.subMenuTitle as string; const subMenuTitleClass = item.subMenuTitleCssClass as string; if (subMenuTitleClass) { subMenuTitleElm.classList.add(...Utils.classNameToList(subMenuTitleClass)); } commandOrOptionMenu.appendChild(subMenuTitleElm); } } protected repositionSubMenu(item: GridMenuItem | MenuCommandItem | 'divider', level: number, e: DOMMouseOrTouchEvent) { // when we're clicking a grid cell OR our last menu type (command/option) differs then we know that we need to start fresh and close any sub-menus that might still be open if (e.target.classList.contains('slick-cell')) { this.destroySubMenus(); } // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show const subMenuElm = this.createMenu(level + 1, item); subMenuElm.style.display = 'block'; document.body.appendChild(subMenuElm); this.repositionMenu(e, subMenuElm); } /** * Reposition the menu drop (up/down) and the side (left/right) * @param {*} event */ protected repositionMenu(e: DOMMouseOrTouchEvent, menuElm: HTMLElement, buttonElm?: HTMLButtonElement) { const targetEvent = e.touches ? e.touches[0] : e; const isSubMenu = menuElm.classList.contains('slick-submenu'); const parentElm = isSubMenu ? e.target.closest('.slick-gridmenu-item') as HTMLDivElement : targetEvent.target as HTMLElement; const menuIconOffset = Utils.offset(buttonElm || this._buttonElm); // get button offset position const menuWidth = menuElm.offsetWidth; const useClickToRepositionMenu = (this._gridMenuOptions?.useClickToRepositionMenu !== undefined) ? this._gridMenuOptions.useClickToRepositionMenu : this._defaults.useClickToRepositionMenu; const contentMinWidth = (this._gridMenuOptions?.contentMinWidth) ? this._gridMenuOptions.contentMinWidth : this._defaults.contentMinWidth as number; const currentMenuWidth = (contentMinWidth > menuWidth) ? contentMinWidth : menuWidth + 5; let menuOffsetTop = (useClickToRepositionMenu && targetEvent.pageY > 0) ? targetEvent.pageY : menuIconOffset!.top + 10; let menuOffsetLeft = (useClickToRepositionMenu && targetEvent.pageX > 0) ? targetEvent.pageX : menuIconOffset!.left + 10; if (isSubMenu && parentElm) { const parentOffset = Utils.offset(parentElm); menuOffsetLeft = parentOffset?.left ?? 0; menuOffsetTop = parentOffset?.top ?? 0; const gridPos = this.grid.getGridPosition(); let subMenuPosCalc = menuOffsetLeft + Number(menuWidth); // calculate coordinate at caller element far right if (isSubMenu) { subMenuPosCalc += parentElm.clientWidth; } const browserWidth = document.documentElement.clientWidth; const dropSide = (subMenuPosCalc >= gridPos.width || subMenuPosCalc >= browserWidth) ? 'left' : 'right'; if (dropSide === 'left') { menuElm.classList.remove('dropright'); menuElm.classList.add('dropleft'); menuOffsetLeft -= menuWidth; } else { menuElm.classList.remove('dropleft'); menuElm.classList.add('dropright'); if (isSubMenu) { menuOffsetLeft += parentElm.offsetWidth; } } } else { menuOffsetTop += 10; menuOffsetLeft = menuOffsetLeft - currentMenuWidth + 10; } menuElm.style.top = `${menuOffsetTop}px`; menuElm.style.left = `${menuOffsetLeft}px`; if (contentMinWidth > 0) { this._menuElm.style.minWidth = `${contentMinWidth}px`; } } protected updateColumnOrder() { // Because columns can be reordered, we have to update the `columns` // to reflect the new order, however we can't just take `grid.getColumns()`, // as it does not include columns currently hidden by the picker. // We create a new `columns` structure by leaving currently-hidden // columns in their original ordinal position and interleaving the results // of the current column sort. const current = this.grid.getColumns().slice(0); const ordered = new Array(this.columns.length); for (let i = 0; i < ordered.length; i++) { if (this.grid.getColumnIndex(this.columns[i].id) === undefined) { // If the column doesn't return a value from getColumnIndex, // it is hidden. Leave it in this position. ordered[i] = this.columns[i]; } else { // Otherwise, grab the next visible column. ordered[i] = current.shift(); } } this.columns = ordered; } protected updateColumn(e: DOMMouseOrTouchEvent) { if (e.target.dataset.option === 'autoresize') { // when calling setOptions, it will resize with ALL Columns (even the hidden ones) // we can avoid this problem by keeping a reference to the visibleColumns before setOptions and then setColumns after const previousVisibleColumns = this.getVisibleColumns(); const isChecked = e.target.checked; this.grid.setOptions({ forceFitColumns: isChecked }); this.grid.setColumns(previousVisibleColumns); return; } if (e.target.dataset.option === 'syncresize') { this.grid.setOptions({ syncColumnCellResize: !!(e.target.checked) }); return; } if (e.target.type === 'checkbox') { const isChecked = e.target.checked; const columnId = e.target.dataset.columnid || ''; const visibleColumns: Column[] = []; this._columnCheckboxes.forEach((columnCheckbox, idx) => { if (columnCheckbox.checked) { if (this.columns[idx].hidden) { this.columns[idx].hidden = false; } visibleColumns.push(this.columns[idx]); } }); if (!visibleColumns.length) { e.target.checked = true; return; } const callbackArgs = { columnId, showing: isChecked, grid: this.grid, allColumns: this.columns, columns: visibleColumns, visibleColumns: this.getVisibleColumns() }; this.grid.setColumns(visibleColumns); this.onColumnsChanged.notify(callbackArgs, e, this); } } getAllColumns() { return this.columns; } /** visible columns, we can simply get them directly from the grid */ getVisibleColumns() { return this.grid.getColumns(); } /** * Method that user can pass to override the default behavior. * In order word, user can choose or an item is (usable/visible/enable) by providing his own logic. * @param overrideFn: override function callback * @param args: multiple arguments provided to the override (cell, row, columnDef, dataContext, grid) */ protected runOverrideFunctionWhenExists(overrideFn: ((args: any) => boolean) | undefined, args: T): boolean { if (typeof overrideFn === 'function') { return overrideFn.call(this, args); } return true; } } // extend Slick namespace on window object when building as iife if (IIFE_ONLY && window.Slick) { window.Slick.Controls = window.Slick.Controls || {}; window.Slick.Controls.GridMenu = SlickGridMenu; }