import { BindingEventService as BindingEventService_, SlickEvent as SlickEvent_, SlickEventData as SlickEventData_, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core.js'; import type { Column, ContextMenuOption, DOMMouseOrTouchEvent, GridOption, MenuCommandItem, MenuCommandItemCallbackArgs, MenuFromCellCallbackArgs, MenuOptionItem, MenuOptionItemCallbackArgs, MenuType, SlickPlugin } 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 SlickEventData = IIFE_ONLY ? Slick.EventData : SlickEventData_; const EventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; /** * A plugin to add Context Menu (mouse right+click), it subscribes to the cell "onContextMenu" event. * The "contextMenu" is defined in the Grid Options object * You can use it to change a data property (only 1) through a list of Options AND/OR through a list of Commands. * A good example of a Command would be an Export to CSV, that can be run from anywhere in the grid by doing a mouse right+click * * Note: * There is only 1 list of Options, so typically that would be use for 1 column * if you plan to use different Options for different columns, then the CellMenu plugin might be better suited. * * USAGE: * * Add the slick.contextmenu.(js|css) files and register it with the grid. * * To specify a menu in a column header, extend the column definition like so: * var contextMenuPlugin = new Slick.Plugins.ContextMenu(columns, grid, options); * * Available grid options, by defining a contextMenu object: * * var options = { * enableCellNavigation: true, * contextMenu: { * optionTitle: 'Change Priority', * optionShownOverColumnIds: ["priority"], * optionItems: [ * { option: 0, title: 'none', cssClass: 'italic' }, * { divider: true }, * "divider" // just the string is also accepted * { option: 1, iconCssClass: 'fa fa-fire grey', title: 'Low' }, * { option: 3, iconCssClass: 'fa fa-fire red', title: 'High' }, * { option: 2, iconCssClass: 'fa fa-fire orange', title: 'Medium' }, * { option: 4, iconCssClass: 'fa fa-fire', title: 'Extreme', disabled: true }, * ], * commandTitle: 'Commands', * commandShownOverColumnIds: ["title", "complete", "start", "finish", "effortDriven"], * commandItems: [ * { command: 'export-excel', title: 'Export to CSV', iconCssClass: 'fa fa-file-excel-o', cssClass: '' }, * { command: 'delete-row', title: 'Delete Row', cssClass: 'bold', textCssClass: 'red' }, * { command: 'help', title: 'Help', iconCssClass: 'fa fa-question-circle',}, * { divider: true }, * ], * } * }; * * * Available contextMenu properties: * commandTitle: Title of the Command section (optional) * commandItems: Array of Command item objects (command/title pair) * commandShownOverColumnIds: Define which column to show the Commands list. If not defined (defaults), the menu will be shown over all columns * optionTitle: Title of the Option section (optional) * optionItems: Array of Options item objects (option/title pair) * optionShownOverColumnIds: Define which column to show the Options list. If not defined (defaults), the menu will be shown over all columns * hideCloseButton: Hide the Close button on top right (defaults to false) * hideCommandSection: Hide the Commands section even when the commandItems array is filled (defaults to false) * hideMenuOnScroll: Do we want to hide the Cell Menu when a scrolling event occurs (defaults to false)? * hideOptionSection: Hide the Options section even when the optionItems array is filled (defaults to false) * maxHeight: Maximum height that the drop menu will have, can be a number (250) or text ("none") * width: Width that the drop menu will have, can be a number (250) or text (defaults to "auto") * autoAdjustDrop: Auto-align dropup or dropdown menu to the left or right depending on grid viewport available space (defaults to true) * autoAdjustDropOffset: Optionally add an offset to the auto-align of the drop menu (defaults to -4) * autoAlignSide: Auto-align drop menu to the left or right depending on grid viewport available space (defaults to true) * autoAlignSideOffset: Optionally add an offset to the left/right side auto-align (defaults to 0) * 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) * 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 Command/Option item properties: * action: Optionally define a callback function that gets executed when item is chosen (and/or use the onCommand event) * command: A command identifier to be passed to the onCommand event handlers (when using "commandItems"). * option: An option to be passed to the onOptionSelected event handlers (when using "optionItems"). * title: Menu item text. * divider: Boolean which tell if the current item is a divider, not an actual command. You could also pass "divider" instead of an object * disabled: Whether the item/command is disabled. * hidden: Whether the item/command is hidden. * 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` * tooltip: Item tooltip. * cssClass: A CSS class to be added to the menu item container. * iconCssClass: A CSS class to be added to the menu item icon. * textCssClass: A CSS class to be added to the menu item text. * iconImage: A url to the icon image. * 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: * cell: Cell or column index * row: Row index * grid: Reference to the grid. * * onBeforeMenuShow: Fired before the menu is shown. You can customize the menu or dismiss it by returning false. * Event args: * cell: Cell or column index * row: Row index * grid: Reference to the grid. * * onBeforeMenuClose: Fired when the menu is closing. * Event args: * cell: Cell or column index * row: Row index * grid: Reference to the grid. * menu: Menu DOM element * * onCommand: Fired on menu option clicked from the Command items list * Event args: * cell: Cell or column index * row: Row index * grid: Reference to the grid. * command: Menu command identified. * item: Menu item selected * column: Cell Column definition * dataContext: Cell Data Context (data object) * value: Value of the cell we triggered the context menu from * * onOptionSelected: Fired on menu option clicked from the Option items list * Event args: * cell: Cell or column index * row: Row index * grid: Reference to the grid. * option: Menu option selected. * item: Menu item selected * column: Cell Column definition * dataContext: Cell Data Context (data object) * * * @param options {Object} Context Menu Options * @class Slick.Plugins.ContextMenu */ export class SlickContextMenu implements SlickPlugin { // -- // public API pluginName = 'ContextMenu' as const; onAfterMenuShow = new SlickEvent('onAfterMenuShow'); onBeforeMenuShow = new SlickEvent('onBeforeMenuShow'); onBeforeMenuClose = new SlickEvent('onBeforeMenuClose'); onCommand = new SlickEvent('onCommand'); onOptionSelected = new SlickEvent('onOptionSelected'); // -- // protected props protected _bindingEventService = new BindingEventService(); protected _contextMenuProperties: ContextMenuOption; protected _currentCell = -1; protected _currentRow = -1; protected _grid!: SlickGrid; protected _gridOptions!: GridOption; protected _gridUid = ''; protected _handler = new EventHandler(); protected _commandTitleElm?: HTMLSpanElement; protected _optionTitleElm?: HTMLSpanElement; protected _lastMenuTypeClicked = ''; protected _menuElm?: HTMLDivElement | null; protected _subMenuParentId = ''; protected _defaults: ContextMenuOption = { autoAdjustDrop: true, // dropup/dropdown autoAlignSide: true, // left/right autoAdjustDropOffset: -4, autoAlignSideOffset: 0, hideMenuOnScroll: false, maxHeight: 'none', width: 'auto', optionShownOverColumnIds: [], commandShownOverColumnIds: [], subMenuOpenByEvent: 'mouseover', }; constructor(optionProperties: Partial) { this._contextMenuProperties = Utils.extend({}, this._defaults, optionProperties); } init(grid: SlickGrid) { this._grid = grid; this._gridOptions = grid.getOptions(); this._gridUid = grid.getUID() || ''; Utils.addSlickEventPubSubWhenDefined(grid.getPubSubService(), this); this._handler.subscribe(this._grid.onContextMenu, this.handleOnContextMenu.bind(this)); if (this._contextMenuProperties.hideMenuOnScroll) { this._handler.subscribe(this._grid.onScroll, this.destroyMenu.bind(this)); } } setOptions(newOptions: Partial) { this._contextMenuProperties = Utils.extend({}, this._contextMenuProperties, newOptions); // on the array properties, we want to make sure to overwrite them and not just extending them if (newOptions.commandShownOverColumnIds) { this._contextMenuProperties.commandShownOverColumnIds = newOptions.commandShownOverColumnIds; } if (newOptions.optionShownOverColumnIds) { this._contextMenuProperties.optionShownOverColumnIds = newOptions.optionShownOverColumnIds; } } destroy() { this.onAfterMenuShow.unsubscribe(); this.onBeforeMenuShow.unsubscribe(); this.onBeforeMenuClose.unsubscribe(); this.onCommand.unsubscribe(); this.onOptionSelected.unsubscribe(); this._handler.unsubscribeAll(); this._bindingEventService.unbindAll(); this._menuElm?.remove(); this._commandTitleElm = null as any; this._optionTitleElm = null as any; this._menuElm = null as any; } protected createParentMenu(evt: SlickEventData_ | MouseEvent) { const e = evt instanceof SlickEventData ? evt.getNativeEvent() : evt; const targetEvent = (e as TouchEvent).touches?.[0] ?? e; const cell = this._grid.getCellFromEvent(e); this._currentCell = cell?.cell ?? 0; this._currentRow = cell?.row ?? 0; const columnDef = this._grid.getColumns()[this._currentCell]; const isColumnOptionAllowed = this.checkIsColumnAllowed(this._contextMenuProperties.optionShownOverColumnIds ?? [], columnDef.id); const isColumnCommandAllowed = this.checkIsColumnAllowed(this._contextMenuProperties.commandShownOverColumnIds ?? [], columnDef.id); const commandItems = this._contextMenuProperties.commandItems || []; const optionItems = this._contextMenuProperties.optionItems || []; // make sure there's at least something to show before creating the Context Menu if (!columnDef || (!isColumnCommandAllowed && !isColumnOptionAllowed) || (!commandItems.length && !optionItems.length)) { return; } // delete any prior context menu this.destroyMenu(e); // Let the user modify the menu or cancel altogether, // or provide alternative menu implementation. if (this.onBeforeMenuShow.notify({ cell: this._currentCell, row: this._currentRow, grid: this._grid }, e, this).getReturnValue() === false) { return; } // create 1st parent menu container & reposition it this._menuElm = this.createMenu(commandItems, optionItems); this._menuElm.style.top = `${targetEvent.pageY}px`; this._menuElm.style.left = `${targetEvent.pageX}px`; this._menuElm.style.display = 'block'; document.body.appendChild(this._menuElm); if (this.onAfterMenuShow.notify({ cell: this._currentCell, row: this._currentRow, grid: this._grid }, e, this).getReturnValue() === false) { return; } return this._menuElm; } protected createMenu(commandItems: Array, optionItems: Array, level = 0, item?: MenuCommandItem | MenuOptionItem | 'divider') { const columnDef = this._grid.getColumns()[this._currentCell]; const dataContext = this._grid.getDataItem(this._currentRow); const isColumnOptionAllowed = this.checkIsColumnAllowed(this._contextMenuProperties.optionShownOverColumnIds ?? [], columnDef.id); const isColumnCommandAllowed = this.checkIsColumnAllowed(this._contextMenuProperties.commandShownOverColumnIds ?? [], columnDef.id); // create a new context menu const maxHeight = isNaN(this._contextMenuProperties.maxHeight as number) ? this._contextMenuProperties.maxHeight : `${this._contextMenuProperties.maxHeight ?? 0}px`; const width = isNaN(this._contextMenuProperties.width as number) ? this._contextMenuProperties.width : `${this._contextMenuProperties.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 MenuCommandItem)?.command; let subMenuId = (level === 1 && subMenuCommand) ? subMenuCommand.replaceAll(' ', '') : ''; if (subMenuId) { this._subMenuParentId = subMenuId; } if (level > 1) { subMenuId = this._subMenuParentId; } const menuClasses = `slick-context-menu slick-menu-level-${level} ${this._gridUid}`; const bodyMenuElm = document.body.querySelector(`.slick-context-menu.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.className = menuClasses; if (level > 0) { menuElm.classList.add('slick-submenu'); if (subMenuId) { menuElm.dataset.subMenuParent = subMenuId; } } menuElm.ariaLabel = level > 1 ? 'SubMenu' : 'Context Menu'; menuElm.role = '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-context-menu'; closeButtonElm.ariaLabel = 'Close'; const spanCloseElm = document.createElement('span'); spanCloseElm.className = 'close'; spanCloseElm.ariaHidden = 'true'; spanCloseElm.textContent = '×'; closeButtonElm.appendChild(spanCloseElm); } // -- Option List section if (!this._contextMenuProperties.hideOptionSection && isColumnOptionAllowed && optionItems.length > 0) { const optionMenuElm = document.createElement('div'); optionMenuElm.className = 'slick-context-menu-option-list'; optionMenuElm.role = 'menu'; // when creating sub-menu add its sub-menu title when exists if (item && level > 0) { this.addSubMenuTitleWhenExists(item, optionMenuElm); // add sub-menu title when exists } if (closeButtonElm && !this._contextMenuProperties.hideCloseButton) { this._bindingEventService.bind(closeButtonElm, 'click', this.handleCloseButtonClicked.bind(this) as EventListener); menuElm.appendChild(closeButtonElm); } menuElm.appendChild(optionMenuElm); this.populateCommandOrOptionItems( 'option', this._contextMenuProperties, optionMenuElm, optionItems, { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid, level } ); } // -- Command List section if (!this._contextMenuProperties.hideCommandSection && isColumnCommandAllowed && commandItems.length > 0) { const commandMenuElm = document.createElement('div'); commandMenuElm.className = 'slick-context-menu-command-list'; commandMenuElm.role = 'menu'; // when creating sub-menu add its sub-menu title when exists if (item && level > 0) { this.addSubMenuTitleWhenExists(item, commandMenuElm); // add sub-menu title when exists } if (closeButtonElm && !this._contextMenuProperties.hideCloseButton && (!isColumnOptionAllowed || optionItems.length === 0 || this._contextMenuProperties.hideOptionSection)) { this._bindingEventService.bind(closeButtonElm, 'click', this.handleCloseButtonClicked.bind(this) as EventListener); menuElm.appendChild(closeButtonElm); } menuElm.appendChild(commandMenuElm); this.populateCommandOrOptionItems( 'command', this._contextMenuProperties, commandMenuElm, commandItems, { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid, level } ); } // increment level for possible next sub-menus if exists level++; return menuElm; } protected addSubMenuTitleWhenExists(item: MenuCommandItem | MenuOptionItem | '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 handleCloseButtonClicked(e: MouseEvent | TouchEvent) { if (!e.defaultPrevented) { this.destroyMenu(e); } } destroyMenu(e?: Event | SlickEventData_, args?: { cell: number; row: number; }) { this._menuElm = this._menuElm || document.querySelector(`.slick-context-menu${this.getGridUidSelector()}`); if (this._menuElm?.remove) { if (this.onBeforeMenuClose.notify({ cell: args?.cell ?? 0, row: args?.row ?? 0, grid: this._grid, }, e, this).getReturnValue() === false) { return; } this._menuElm.remove(); this._menuElm = null; } this.destroySubMenus(); } /** Destroy all parent menus and any sub-menus */ destroyAllMenus() { this.destroySubMenus(); // remove all parent menu listeners before removing them from the DOM this._bindingEventService.unbindAll('parent-menu'); document.querySelectorAll(`.slick-context-menu${this.getGridUidSelector()}`) .forEach(subElm => subElm.remove()); } /** Close and destroy all previously opened sub-menus */ destroySubMenus() { this._bindingEventService.unbindAll('sub-menu'); document.querySelectorAll(`.slick-context-menu.slick-submenu${this.getGridUidSelector()}`) .forEach(subElm => subElm.remove()); } protected checkIsColumnAllowed(columnIds: Array, columnId: number | string) { let isAllowedColumn = false; if (columnIds?.length > 0) { for (let o = 0, ln = columnIds.length; o < ln; o++) { if (columnIds[o] === columnId) { isAllowedColumn = true; } } } else { isAllowedColumn = true; } return isAllowedColumn; } protected getGridUidSelector() { const gridUid = this._grid.getUID() || ''; return gridUid ? `.${gridUid}` : ''; } protected handleOnContextMenu(evt: SlickEventData_ | DOMMouseOrTouchEvent, args: MenuCommandItemCallbackArgs) { this.destroyAllMenus(); // make there's only 1 parent menu opened at a time const e = evt instanceof SlickEventData ? evt.getNativeEvent>() : evt; e.preventDefault(); const cell = this._grid.getCellFromEvent(e); if (cell) { const columnDef = this._grid.getColumns()[cell.cell]; const dataContext = this._grid.getDataItem(cell.row); // run the override function (when defined), if the result is false it won't go further args = args || {}; args.cell = cell.cell; args.row = cell.row; args.column = columnDef; args.dataContext = dataContext; args.grid = this._grid; if (!this.runOverrideFunctionWhenExists(this._contextMenuProperties.menuUsabilityOverride, args)) { return; } // create the DOM element this._menuElm = this.createParentMenu(e as MouseEvent); // reposition the menu to where the user clicked if (this._menuElm) { this.repositionMenu(e, this._menuElm); this._menuElm.style.display = 'block'; } // Hide the menu on outside click. this._bindingEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener); } } /** When users click outside the Cell Menu, we will typically close the Cell Menu (and any sub-menus) */ 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-context-menu.slick-submenu${this.getGridUidSelector()}`) .forEach(subElm => { if (subElm.contains(e.target)) { isMenuClicked = true; } }); } if (this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented) { this.destroyMenu(e, { cell: this._currentCell, row: this._currentRow }); } } /** Construct the Command Items section. */ protected populateCommandOrOptionItems( itemType: MenuType, contextMenu: ContextMenuOption, commandOrOptionMenuElm: HTMLElement, commandOrOptionItems: Array | Array, args: { cell: number, row: number, column: Column, dataContext: any, grid: SlickGrid, level: number } ) { if (!args || !commandOrOptionItems || !contextMenu) { return; } // user could pass a title on top of the Commands/Options section const level = args?.level || 0; const isSubMenu = level > 0; if (contextMenu?.[`${itemType}Title`] && !isSubMenu) { this[`_${itemType}TitleElm`] = document.createElement('div'); this[`_${itemType}TitleElm`]!.className = 'slick-menu-title'; this[`_${itemType}TitleElm`]!.textContent = contextMenu[`${itemType}Title`] as string; commandOrOptionMenuElm.appendChild(this[`_${itemType}TitleElm`]!); } for (let i = 0, ln = commandOrOptionItems.length; i < ln; i++) { let addClickListener = true; const item = commandOrOptionItems[i]; // run each override functions to know if the item is visible and usable const isItemVisible = this.runOverrideFunctionWhenExists((item as MenuCommandItem | MenuOptionItem).itemVisibilityOverride, args); const isItemUsable = this.runOverrideFunctionWhenExists((item as MenuCommandItem | MenuOptionItem).itemUsabilityOverride, args); // 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 MenuCommandItem | MenuOptionItem).disabled = isItemUsable ? false : true; } const liElm = document.createElement('div'); liElm.className = 'slick-context-menu-item'; liElm.role = 'menuitem'; if ((item as MenuCommandItem | MenuOptionItem).divider || item === 'divider') { liElm.classList.add('slick-context-menu-item-divider'); addClickListener = false; } // if the item is disabled then add the disabled css class if ((item as MenuCommandItem | MenuOptionItem).disabled || !isItemUsable) { liElm.classList.add('slick-context-menu-item-disabled'); } // if the item is hidden then add the hidden css class if ((item as MenuCommandItem | MenuOptionItem).hidden) { liElm.classList.add('slick-context-menu-item-hidden'); } if ((item as MenuCommandItem | MenuOptionItem).cssClass) { liElm.classList.add(...Utils.classNameToList((item as MenuCommandItem | MenuOptionItem).cssClass)); } if ((item as MenuCommandItem | MenuOptionItem).tooltip) { liElm.title = (item as MenuCommandItem | MenuOptionItem).tooltip || ''; } const iconElm = document.createElement('div'); iconElm.className = 'slick-context-menu-icon'; liElm.appendChild(iconElm); if ((item as MenuCommandItem | MenuOptionItem).iconCssClass) { iconElm.classList.add(...Utils.classNameToList((item as MenuCommandItem | MenuOptionItem).iconCssClass)); } if ((item as MenuCommandItem | MenuOptionItem).iconImage) { iconElm.style.backgroundImage = `url(${(item as MenuCommandItem | MenuOptionItem).iconImage})`; } const textElm = document.createElement('span'); textElm.className = 'slick-context-menu-content'; textElm.textContent = (item as MenuCommandItem | MenuOptionItem).title || ''; liElm.appendChild(textElm); if ((item as MenuCommandItem | MenuOptionItem).textCssClass) { textElm.classList.add(...Utils.classNameToList((item as MenuCommandItem | MenuOptionItem).textCssClass)); } commandOrOptionMenuElm.appendChild(liElm); if (addClickListener) { const eventGroup = isSubMenu ? 'sub-menu' : 'parent-menu'; this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item, itemType, level) as EventListener, undefined, eventGroup); } // optionally open sub-menu(s) by mouseover if (this._contextMenuProperties.subMenuOpenByEvent === 'mouseover') { this._bindingEventService.bind(liElm, 'mouseover', ((e: DOMMouseOrTouchEvent) => { if ((item as MenuCommandItem).commandItems || (item as MenuOptionItem).optionItems) { this.repositionSubMenu(item, itemType, level, e); this._lastMenuTypeClicked = itemType; } 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 MenuCommandItem).commandItems || (item as MenuOptionItem).optionItems) { const chevronElm = document.createElement('span'); chevronElm.className = 'sub-item-chevron'; if (this._contextMenuProperties.subItemChevronClass) { chevronElm.classList.add(...Utils.classNameToList(this._contextMenuProperties.subItemChevronClass)); } else { chevronElm.textContent = '⮞'; // ⮞ or ▸ } liElm.classList.add('slick-submenu-item'); liElm.appendChild(chevronElm); continue; } } } protected handleMenuItemClick(item: MenuCommandItem | MenuOptionItem | 'divider', type: MenuType, level = 0, e: DOMMouseOrTouchEvent) { if ((item as never)?.[type] !== undefined && item !== 'divider' && !item.disabled && !(item as MenuCommandItem | MenuOptionItem).divider && this._currentCell !== undefined && this._currentRow !== undefined) { if (type === 'option' && !this._grid.getEditorLock().commitCurrentEdit()) { return; } const optionOrCommand = (item as any)[type] !== undefined ? (item as any)[type] : ''; const row = this._currentRow; const cell = this._currentCell; const columnDef = this._grid.getColumns()[cell]; const dataContext = this._grid.getDataItem(row); let cellValue; if (Object.prototype.hasOwnProperty.call(dataContext, columnDef?.field)) { cellValue = dataContext[columnDef.field]; } if (optionOrCommand !== undefined && !(item as any)[`${type}Items`]) { // user could execute a callback through 2 ways // via the onCommand event and/or an action callback const callbackArgs = { cell, row, grid: this._grid, [type]: optionOrCommand, item, column: columnDef, dataContext, value: cellValue }; const eventType = type === 'command' ? 'onCommand' : 'onOptionSelected'; this[eventType].notify(callbackArgs as any, e, this); // execute action callback when defined if (typeof (item as MenuCommandItem).action === 'function') { (item as any).action.call(this, e, callbackArgs); } if (!e.defaultPrevented) { this.destroyMenu(e, { cell, row }); } } else if ((item as MenuCommandItem).commandItems || (item as MenuOptionItem).optionItems) { this.repositionSubMenu(item, type, level, e); } else { this.destroySubMenus(); } this._lastMenuTypeClicked = type; } } protected repositionSubMenu(item: MenuCommandItem | MenuOptionItem | 'divider', type: MenuType, 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._lastMenuTypeClicked !== type) { 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 as MenuCommandItem)?.commandItems || [], (item as MenuOptionItem)?.optionItems || [], 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) { const isSubMenu = menuElm.classList.contains('slick-submenu'); const targetEvent = (e as TouchEvent).touches?.[0] ?? e; const parentElm = isSubMenu ? e.target.closest('.slick-context-menu-item') as HTMLDivElement : e.target.closest('.slick-cell') as HTMLDivElement; if (menuElm && parentElm) { const parentOffset = Utils.offset(parentElm); let menuOffsetLeft = (isSubMenu && parentElm) ? parentOffset?.left ?? 0 : targetEvent.pageX; let menuOffsetTop = parentElm ? parentOffset?.top ?? 0 : targetEvent.pageY; const menuHeight = menuElm?.offsetHeight || 0; const menuWidth = Number(menuElm?.offsetWidth || this._contextMenuProperties.width || 0); const rowHeight = this._gridOptions.rowHeight; const dropOffset = Number(this._contextMenuProperties.autoAdjustDropOffset || 0); const sideOffset = Number(this._contextMenuProperties.autoAlignSideOffset || 0); // if autoAdjustDrop is enable, we first need to see what position the drop will be located // without necessary toggling it's position just yet, we just want to know the future position for calculation if (this._contextMenuProperties.autoAdjustDrop) { // since we reposition menu below slick cell, we need to take it in consideration and do our calculation from that element const spaceBottom = Utils.calculateAvailableSpace(parentElm).bottom; const spaceTop = Utils.calculateAvailableSpace(parentElm).top; const spaceBottomRemaining = spaceBottom + dropOffset - rowHeight!; const spaceTopRemaining = spaceTop - dropOffset + rowHeight!; const dropPosition = (spaceBottomRemaining < menuHeight && spaceTopRemaining > spaceBottomRemaining) ? 'top' : 'bottom'; if (dropPosition === 'top') { menuElm.classList.remove('dropdown'); menuElm.classList.add('dropup'); if (isSubMenu) { menuOffsetTop -= (menuHeight - dropOffset - parentElm.clientHeight); } else { menuOffsetTop -= menuHeight - dropOffset; } } else { menuElm.classList.remove('dropup'); menuElm.classList.add('dropdown'); if (isSubMenu) { menuOffsetTop += dropOffset; } else { menuOffsetTop += rowHeight! + dropOffset; } } } // 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 (this._contextMenuProperties.autoAlignSide) { 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 - sideOffset; } else { menuElm.classList.remove('dropleft'); menuElm.classList.add('dropright'); if (isSubMenu) { menuOffsetLeft += sideOffset + parentElm.offsetWidth; } else { menuOffsetLeft += sideOffset; } } } // ready to reposition the menu menuElm.style.top = `${menuOffsetTop}px`; menuElm.style.left = `${menuOffsetLeft}px`; } } /** * 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: { ContextMenu: SlickContextMenu } } }); }