import { BindingEventService as BindingEventService_, Event as SlickEvent_, type SlickEventData, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core.js'; import type { Column, DOMEvent, DOMMouseOrTouchEvent, HeaderMenuCommandItemCallbackArgs, HeaderMenuItems, HeaderMenuOption, HeaderMenuCommandItem, MenuCommandItemCallbackArgs, SlickPlugin, OnHeaderCellRenderedEventArgs, } from '../models/index.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 SlickEventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; /** * A plugin to add drop-down menus to column headers. * * USAGE: * * Add the plugin .js & .css files and register it with the grid. * * To specify a menu in a column header, extend the column definition like so: * * let columns = [ * { * id: 'myColumn', * name: 'My column', * * // This is the relevant part * header: { * menu: { * items: [ * { * // menu item options * }, * { * // menu item options * } * ] * } * } * } * ]; * * * Available menu options: * autoAlign: Auto-align drop menu to the left when not enough viewport space to show on the right * autoAlignOffset: When drop menu is aligned to the left, it might not be perfectly aligned with the header menu icon, if that is the case you can add an offset (positive/negative number to move right/left) * buttonCssClass: an extra CSS class to add to the menu button (default 'caret') * buttonImage: a url to the menu button image * 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) * minWidth: Minimum width that the drop menu will have * 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 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. * 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. * 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. * 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. * * * @param options {Object} Options: * buttonCssClass: an extra CSS class to add to the menu button (default 'caret') * buttonImage: a url to the menu button image * @class Slick.Plugins.HeaderButtons */ export class SlickHeaderMenu implements SlickPlugin { // -- // public API pluginName = 'HeaderMenu' as const; onAfterMenuShow = new SlickEvent('onAfterMenuShow'); onBeforeMenuShow = new SlickEvent('onBeforeMenuShow'); onCommand = new SlickEvent('onCommand'); // -- // protected props protected _grid!: SlickGrid; protected _gridUid = ''; protected _handler = new SlickEventHandler(); protected _bindingEventService = new BindingEventService(); protected _defaults: HeaderMenuOption = { buttonCssClass: undefined, buttonImage: undefined, minWidth: 100, autoAlign: true, autoAlignOffset: 0, subMenuOpenByEvent: 'mouseover', }; protected _options: HeaderMenuOption; protected _activeHeaderColumnElm?: HTMLDivElement | null; protected _menuElm?: HTMLDivElement | null; protected _subMenuParentId = ''; constructor(options: Partial) { this._options = Utils.extend(true, {}, options, this._defaults); } init(grid: SlickGrid) { this._grid = grid; this._gridUid = grid?.getUID() || ''; Utils.addSlickEventPubSubWhenDefined(grid.getPubSubService(), this); this._handler .subscribe(this._grid.onHeaderCellRendered, this.handleHeaderCellRendered.bind(this)) .subscribe(this._grid.onBeforeHeaderCellDestroy, this.handleBeforeHeaderCellDestroy.bind(this)); // Force the grid to re-render the header now that the events are hooked up. this._grid.setColumns(this._grid.getColumns()); // Hide the menu on outside click. this._bindingEventService.bind(document.body, 'click', this.handleBodyMouseDown.bind(this) as EventListener); } setOptions(newOptions: Partial) { this._options = Utils.extend(true, {}, this._options, newOptions); } protected getGridUidSelector() { const gridUid = this._grid.getUID() || ''; return gridUid ? `.${gridUid}` : ''; } destroy() { this._handler.unsubscribeAll(); this._bindingEventService.unbindAll(); this._menuElm = this._menuElm || document.body.querySelector(`.slick-header-menu${this.getGridUidSelector()}`); this._menuElm?.remove(); this._activeHeaderColumnElm = undefined; } destroyAllMenus() { this.destroySubMenus(); // remove all parent menu listeners before removing them from the DOM this._bindingEventService.unbindAll('parent-menu'); document.querySelectorAll(`.slick-header-menu${this.getGridUidSelector()}`) .forEach(subElm => subElm.remove()); } /** Close and destroy all previously opened sub-menus */ destroySubMenus() { this._bindingEventService.unbindAll('sub-menu'); document.querySelectorAll(`.slick-header-menu.slick-submenu${this.getGridUidSelector()}`) .forEach(subElm => subElm.remove()); } protected handleBodyMouseDown(e: DOMEvent) { // 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-header-menu.slick-submenu${this.getGridUidSelector()}`) .forEach(subElm => { if (subElm.contains(e.target)) { isMenuClicked = true; } }); } if (this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented || e.target.className === 'close') { this.hideMenu(); } } hideMenu() { if (this._menuElm) { this._menuElm.remove(); this._menuElm = undefined; } this._activeHeaderColumnElm?.classList.remove('slick-header-column-active'); this.destroySubMenus(); } protected handleHeaderCellRendered(_e: SlickEventData, args: OnHeaderCellRenderedEventArgs) { const column = args.column; const menu = column?.header?.menu as HeaderMenuItems; if (menu?.items) { console.warn('[SlickGrid] Header Menu "items" property was deprecated in favor of "commandItems" to align with all other Menu plugins.'); } if (menu) { // run the override function (when defined), if the result is false it won't go further if (!this.runOverrideFunctionWhenExists(this._options.menuUsabilityOverride, args)) { return; } const elm = document.createElement('div'); elm.className = 'slick-header-menubutton'; elm.ariaLabel = 'Header Menu'; elm.role = 'button'; if (!this._options.buttonCssClass && !this._options.buttonImage) { this._options.buttonCssClass = 'caret'; // default when nothing is provided } if (this._options.buttonCssClass) { // sgi icon with mask requires inner span to work properly const icon = document.createElement('span'); icon.classList.add(...Utils.classNameToList(this._options.buttonCssClass)); elm.appendChild(icon); } if (this._options.buttonImage) { elm.style.backgroundImage = `url(${this._options.buttonImage})`; } if (this._options.tooltip) { elm.title = this._options.tooltip; } this._bindingEventService.bind(elm, 'click', ((e: DOMMouseOrTouchEvent) => { this.destroyAllMenus(); // make there's only 1 parent menu opened at a time this.createParentMenu(e, menu, args.column); }) as EventListener); args.node.appendChild(elm); } } protected handleBeforeHeaderCellDestroy(_e: SlickEventData, args: { column: Column; node: HTMLElement; }) { const column = args.column; if (column.header?.menu) { args.node.querySelectorAll('.slick-header-menubutton').forEach(elm => elm.remove()); } } protected addSubMenuTitleWhenExists(item: HeaderMenuCommandItem | 'divider', commandMenuElm: 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)); } commandMenuElm.appendChild(subMenuTitleElm); } } protected createParentMenu(event: DOMMouseOrTouchEvent, menu: HeaderMenuItems, columnDef: Column) { // Let the user modify the menu or cancel altogether, // or provide alternative menu implementation. const callbackArgs = { grid: this._grid, column: columnDef, menu } as unknown as HeaderMenuCommandItemCallbackArgs; if (this.onBeforeMenuShow.notify(callbackArgs, event, this).getReturnValue() === false) { return; } // create 1st parent menu container & reposition it this._menuElm = this.createMenu((menu.commandItems || menu.items) as Array, columnDef); const containerNode = this._grid.getContainerNode(); if (containerNode) { containerNode.appendChild(this._menuElm); } this.repositionMenu(event, this._menuElm); if (this.onAfterMenuShow.notify(callbackArgs, event, this).getReturnValue() === false) { return; } // Stop propagation so that it doesn't register as a header click event. event.preventDefault(); event.stopPropagation(); } protected createMenu(commandItems: Array, columnDef: Column, level = 0, item?: HeaderMenuCommandItem | 'divider') { // 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 isSubMenu = level > 0; const subMenuCommand = (item as HeaderMenuCommandItem)?.command; let subMenuId = (level === 1 && subMenuCommand) ? subMenuCommand.replaceAll(' ', '') : ''; if (subMenuId) { this._subMenuParentId = subMenuId; } if (isSubMenu) { subMenuId = this._subMenuParentId; } // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all const menuClasses = `slick-header-menu slick-menu-level-${level} ${this._gridUid}`; const bodyMenuElm = document.body.querySelector(`.slick-header-menu.slick-menu-level-${level}${this.getGridUidSelector()}`); if (bodyMenuElm) { if (bodyMenuElm.dataset.subMenuParent === subMenuId) { return bodyMenuElm; } this.destroySubMenus(); } const menuElm = document.createElement('div'); menuElm.className = menuClasses; if (level > 0) { menuElm.classList.add('slick-submenu'); if (subMenuId) { menuElm.dataset.subMenuParent = subMenuId; } } menuElm.classList.add(this._gridUid); menuElm.role = 'menu'; menuElm.ariaLabel = level > 1 ? 'SubMenu' : 'Header Menu'; menuElm.style.minWidth = `${this._options.minWidth}px`; menuElm.setAttribute('aria-expanded', 'true'); const callbackArgs = { grid: this._grid, column: columnDef, menu: { items: commandItems } } as unknown as HeaderMenuCommandItemCallbackArgs; // when creating sub-menu add its sub-menu title when exists if (item && level > 0) { this.addSubMenuTitleWhenExists(item, menuElm); // add sub-menu title when exists } // Construct the menu items. for (let i = 0; i < commandItems.length; i++) { let addClickListener = true; const item = commandItems[i]; // run each override functions to know if the item is visible and usable const isItemVisible = this.runOverrideFunctionWhenExists((item as HeaderMenuCommandItem).itemVisibilityOverride, callbackArgs); const isItemUsable = this.runOverrideFunctionWhenExists((item as HeaderMenuCommandItem).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 "handleMenuItemCommandClick" has the correct flag and won't trigger a command clicked event if (Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { (item as HeaderMenuCommandItem).disabled = isItemUsable ? false : true; } const menuItemElm = document.createElement('div'); menuItemElm.className = 'slick-header-menuitem'; menuItemElm.role = 'menuitem'; if ((item as HeaderMenuCommandItem).divider || item === 'divider') { menuItemElm.classList.add('slick-header-menuitem-divider'); addClickListener = false; } if ((item as HeaderMenuCommandItem).disabled) { menuItemElm.classList.add('slick-header-menuitem-disabled'); } if ((item as HeaderMenuCommandItem).hidden) { menuItemElm.classList.add('slick-header-menuitem-hidden'); } if ((item as HeaderMenuCommandItem).cssClass) { menuItemElm.classList.add(...Utils.classNameToList((item as HeaderMenuCommandItem).cssClass)); } if ((item as HeaderMenuCommandItem).tooltip) { menuItemElm.title = (item as HeaderMenuCommandItem).tooltip || ''; } const iconElm = document.createElement('div'); iconElm.className = 'slick-header-menuicon'; menuItemElm.appendChild(iconElm); if ((item as HeaderMenuCommandItem).iconCssClass) { iconElm.classList.add(...Utils.classNameToList((item as HeaderMenuCommandItem).iconCssClass)); } if ((item as HeaderMenuCommandItem).iconImage) { iconElm.style.backgroundImage = 'url(' + (item as HeaderMenuCommandItem).iconImage + ')'; } const textElm = document.createElement('span'); textElm.className = 'slick-header-menucontent'; textElm.textContent = (item as HeaderMenuCommandItem).title || ''; menuItemElm.appendChild(textElm); if ((item as HeaderMenuCommandItem).textCssClass) { textElm.classList.add(...Utils.classNameToList((item as HeaderMenuCommandItem).textCssClass)); } menuElm.appendChild(menuItemElm); if (addClickListener) { const eventGroup = isSubMenu ? 'sub-menu' : 'parent-menu'; this._bindingEventService.bind(menuItemElm, 'click', this.handleMenuItemClick.bind(this, item, columnDef, level) as EventListener, undefined, eventGroup); } // optionally open sub-menu(s) by mouseover if (this._options.subMenuOpenByEvent === 'mouseover') { this._bindingEventService.bind(menuItemElm, 'mouseover', ((e: DOMMouseOrTouchEvent) => { if ((item as HeaderMenuCommandItem).commandItems || (item as HeaderMenuCommandItem).items) { this.repositionSubMenu(item as HeaderMenuCommandItem, columnDef, 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 HeaderMenuCommandItem).commandItems || (item as HeaderMenuCommandItem).items) { const chevronElm = document.createElement('div'); chevronElm.className = 'sub-item-chevron'; if (this._options.subItemChevronClass) { chevronElm.classList.add(...Utils.classNameToList(this._options.subItemChevronClass)); } else { chevronElm.textContent = '⮞'; // ⮞ or ▸ } menuItemElm.classList.add('slick-submenu-item'); menuItemElm.appendChild(chevronElm); } } return menuElm; } protected handleMenuItemClick(item: HeaderMenuCommandItem | 'divider', columnDef: Column, level = 0, e: DOMMouseOrTouchEvent): boolean | void { if (item !== 'divider' && !item.disabled && !item.divider) { const command = (item as HeaderMenuCommandItem).command || ''; if (Utils.isDefined(command) && !item.commandItems && !(item as HeaderMenuCommandItem).items) { const callbackArgs = { grid: this._grid, column: columnDef, command, item, }; this.onCommand.notify(callbackArgs, e, this); // execute action callback when defined if (typeof item.action === 'function') { item.action.call(this, e, callbackArgs); } // unless prevented, close the menu if (!e.defaultPrevented) { this.hideMenu(); } } else if (item.commandItems || (item as HeaderMenuCommandItem).items) { this.repositionSubMenu(item as HeaderMenuCommandItem, columnDef, level, e); } else { this.destroySubMenus(); } } } protected repositionSubMenu(item: HeaderMenuCommandItem, columnDef: Column, 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-header-menubutton')) { this.destroySubMenus(); } // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show const subMenuElm = this.createMenu(item.commandItems || item.items || [], columnDef, level + 1, item); document.body.appendChild(subMenuElm); this.repositionMenu(e, subMenuElm); } protected repositionMenu(e: DOMMouseOrTouchEvent, menuElm: HTMLDivElement) { const buttonElm = e.target; const isSubMenu = menuElm.classList.contains('slick-submenu'); const parentElm = isSubMenu ? e.target.closest('.slick-header-menuitem') as HTMLDivElement : buttonElm as HTMLElement; const btnOffset = Utils.offset(buttonElm); const gridPos = this._grid.getGridPosition(); const menuWidth = menuElm.offsetWidth; const menuOffset = Utils.offset(this._menuElm!); const parentOffset = Utils.offset(parentElm); const menuOffsetTop = isSubMenu ? parentOffset?.top ?? 0 : buttonElm.clientHeight ?? btnOffset?.top ?? 0 + (this._options?.menuOffsetTop ?? 0); let menuOffsetLeft = isSubMenu ? parentOffset?.left ?? 0 : btnOffset?.left ?? 0; // when auto-align is set, it will calculate whether it has enough space in the viewport to show the drop menu on the right (default) // if there isn't enough space on the right, it will automatically align the drop menu to the left // to simulate an align left, we actually need to know the width of the drop menu if (isSubMenu && parentElm) { 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 { if (menuOffsetLeft + menuElm.offsetWidth >= gridPos.width) { menuOffsetLeft = menuOffsetLeft + buttonElm.clientWidth - menuElm.clientWidth + (this._options.autoAlignOffset || 0); } menuOffsetLeft -= menuOffset?.left ?? 0; } // ready to reposition the menu menuElm.style.top = `${menuOffsetTop}px`; menuElm.style.left = `${menuOffsetLeft}px`; // Mark the header as active to keep the highlighting. this._activeHeaderColumnElm = menuElm.closest('.slick-header-column'); if (this._activeHeaderColumnElm) { this._activeHeaderColumnElm.classList.add('slick-header-column-active'); } } /** * 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) { Utils.extend(true, window, { Slick: { Plugins: { HeaderMenu: SlickHeaderMenu } } }); }