From 6e41e8e9fa098e3731f3d2c3b52f2f571d76b9a8 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 17 Oct 2023 20:34:17 -0400 Subject: [PATCH 01/13] feat: add sub-menus to CellMenu/ContextMenu plugins - also merge command/option code since we had a lot of duplicate code to do roughly the same thing but with different list (command/options) --- cypress/e2e/example-plugin-contextmenu.cy.ts | 182 ++++++- examples/example-plugin-contextmenu.html | 40 +- src/models/cellMenuOption.interface.ts | 3 + src/models/menuCommandItem.interface.ts | 3 + src/models/menuOptionItem.interface.ts | 3 + src/plugins/slick.cellmenu.ts | 512 +++++++++---------- src/styles/slick.cellmenu.scss | 4 + 7 files changed, 462 insertions(+), 285 deletions(-) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index a2cced80b..ae2662e4a 100644 --- a/cypress/e2e/example-plugin-contextmenu.cy.ts +++ b/cypress/e2e/example-plugin-contextmenu.cy.ts @@ -39,7 +39,7 @@ describe('Example - Context Menu & Cell Menu', () => { .contains('Action'); cy.get('.slick-cell-menu') - .should('not.exist') + .should('not.exist'); }); it('should open the Context Menu and expect onBeforeMenuShow then onAfterMenuShow to show in the console log', () => { @@ -147,11 +147,11 @@ describe('Example - Context Menu & Cell Menu', () => { .should('exist'); }); - it('should change the Effort Driven to "False" in that same Action and then expect the "Command 2" to enabled and clickable', () => { + it('should change the Effort Driven to "False" in that same Action and then expect the "Command 2" to be enabled and clickable', () => { const stub = cy.stub(); cy.on('window:alert', stub); - cy.get('.slick-cell-menu .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') .find('.slick-cell-menu-item') .contains('False') .click(); @@ -167,6 +167,72 @@ describe('Example - Context Menu & Cell Menu', () => { .then(() => expect(stub.getCall(0)).to.be.calledWith('Command 2')); }); + it('should change the Effort Driven to "True" by using sub-options in that same Action and then expect the "Command 2" to be disabled and not clickable and "Delete Row" to not be shown', () => { + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('Sub-Options') + .click(); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('True') + .click(); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu .slick-cell-menu-item.slick-cell-menu-item-disabled') + .contains('Command 2'); + + cy.get('.slick-cell-menu .slick-cell-menu-item') + .contains('Delete Row') + .should('not.exist'); + }); + + it('should change the Effort Driven back to "False" by using sub-options in that same Action and then expect the "Command 2" to enabled and clickable and also show "Delete Row" command', () => { + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('Sub-Options') + .click(); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('False') + .click(); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu .slick-cell-menu-item') + .contains('Delete Row') + .should('exist'); + + cy.get('.slick-cell-menu .slick-cell-menu-item') + .contains('Command 2') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Command 2')); + }); + it('should expect the Context Menu now have the "Help" menu when Effort Driven is set to False', () => { const commands = ['Copy Cell Value', 'Delete Row', '', 'Help', 'Command (always disabled)']; @@ -213,6 +279,112 @@ describe('Example - Context Menu & Cell Menu', () => { .should('not.exist'); }); + it('should be able to open Cell Menu and click on Export->PDF sub-commands to see 1 cell menu + 1 sub-menu then clicking on PDF should call alert action', () => { + const subCommands = ['PDF', 'Excel']; + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu.level-0 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands[index])); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('PDF') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as PDF')); + }); + + it('should be able to open Cell Menu and click on Export->Excel-> sub-commands to see 1 cell menu + 1 sub-menu then clicking on PDF should call alert action', () => { + const subCommands1 = ['PDF', 'Excel']; + const subCommands2 = ['Excel (csv)', 'Excel (xls)']; + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu.level-0 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands1[index])); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Excel') + .click(); + + cy.get('.slick-cell-menu.level-2 .slick-cell-menu-command-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands2[index])); + + cy.get('.slick-cell-menu.level-2 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Excel (xls)') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Excel (xls)')); + }); + + it('should open Export->Excel sub-menu & open again Sub-Options on top and expect sub-menu to be recreated with that Sub-Options list instead of the Export->Excel list', () => { + const subCommands1 = ['PDF', 'Excel']; + const subCommands2 = ['Excel (csv)', 'Excel (xls)']; + const subOptions = ['True', 'False']; + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu.level-0 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands1[index])); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Excel') + .click(); + + cy.get('.slick-cell-menu.level-2 .slick-cell-menu-command-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands2[index])); + + cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('Sub-Options') + .click(); + + cy.get('.slick-cell-menu.level-1 .slick-cell-menu-option-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($option, index) => expect($option.text()).to.eq(subOptions[index])); + }); + it('should click on the "Show Commands & Priority Options" button and see both list when opening Context Menu', () => { cy.get('button') .contains('Show Commands & Priority Options') @@ -320,8 +492,8 @@ describe('Example - Context Menu & Cell Menu', () => { .click(); }); - it('should click on the "Show Action Commands Only" button and see both list when opening Context Menu', () => { - const commands = ['Command 1', 'Command 2', 'Delete Row', '', 'Help', 'Disabled Command']; + it('should click on the "Show Action Commands Only" button and see both list when opening Cell Menu', () => { + const commands = ['Command 1', 'Command 2', 'Delete Row', '', 'Help', 'Disabled Command', '', 'Export']; cy.get('button') .contains('Show Action Commands Only') diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index 9a2a9c8b0..34680831b 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -84,10 +84,10 @@ - -
+
+

@@ -285,10 +285,13 @@

View Source:

switch (command) { case "command1": - alert('Command 1'); - break; case "command2": - alert('Command 2'); + alert(args.item.title); + break; + case "export-csv": + case "export-pdf": + case "export-xls": + alert("Exporting as " + args.item.title); break; case "copy-text": copyCellValue(args.value); @@ -352,7 +355,22 @@

View Source:

"divider", // { divider: true }, { command: "help", title: "Help", iconCssClass: "sgi sgi-help-circle-outline" }, - { command: "something", title: "Disabled Command", disabled: true } + { command: "something", title: "Disabled Command", disabled: true }, + "divider", + { + // we can also have multiple sub-items + command: 'export', title: 'Export', + commandItems: [ + { command: "export-pdf", title: "PDF" }, + { + command: 'sub-menu', title: 'Excel', cssClass: "green", + commandItems: [ + { command: "export-csv", title: "Excel (csv)" }, + { command: "export-xls", title: "Excel (xls)" }, + ] + } + ] + } ], optionTitle: "Change Effort Driven", optionItems: [ @@ -373,6 +391,13 @@

View Source:

return (!args.dataContext.effortDriven); } }, + { + // we can also have multiple sub-items + option: null, title: "Sub-Options (demo)", optionItems: [ + { option: true, title: "True", iconCssClass: 'sgi sgi-checkbox-marked-outline green' }, + { option: false, title: "False", iconCssClass: 'sgi sgi-checkbox-blank-outline pink' }, + ] + } ] } } @@ -450,7 +475,7 @@

View Source:

document.addEventListener("DOMContentLoaded", function() { dataView = new Slick.Data.DataView(); grid = new Slick.Grid("#myGrid", dataView, columns, gridOptions); - cellMenuPlugin = new Slick.Plugins.CellMenu({ hideMenuOnScroll: true }); + cellMenuPlugin = new Slick.Plugins.CellMenu({ hideMenuOnScroll: true, subItemChevronClass: 'sgi sgi-chevron-right' }); contextMenuPlugin = new Slick.Plugins.ContextMenu(contextMenuOptions); var columnpicker = new Slick.Controls.ColumnPicker(columns, grid, gridOptions); @@ -542,6 +567,7 @@

View Source:

// subscribe to Cell Menu onOptionSelected event (or use the action callback on each option) cellMenuPlugin.onOptionSelected.subscribe(function (e, args) { + console.log('onOptionSelected', args) // e.preventDefault(); // you could do if you wish to keep the menu open var dataContext = args && args.dataContext; diff --git a/src/models/cellMenuOption.interface.ts b/src/models/cellMenuOption.interface.ts index 92024886b..c364ab17a 100644 --- a/src/models/cellMenuOption.interface.ts +++ b/src/models/cellMenuOption.interface.ts @@ -59,6 +59,9 @@ export interface CellMenuOption { /** Optional Title of the Option section, it will be hidden when nothing is provided */ optionTitle?: string; + /** CSS class that can be added on the right side of a sub-item parent (typically a chevron-right icon) */ + subItemChevronClass?: string; + // -- // action/override callbacks diff --git a/src/models/menuCommandItem.interface.ts b/src/models/menuCommandItem.interface.ts index 807ed1390..a737b897c 100644 --- a/src/models/menuCommandItem.interface.ts +++ b/src/models/menuCommandItem.interface.ts @@ -7,6 +7,9 @@ export interface MenuCommandItem; + // -- // action/override callbacks diff --git a/src/models/menuOptionItem.interface.ts b/src/models/menuOptionItem.interface.ts index 2eb3ccbe2..dd4f8c07d 100644 --- a/src/models/menuOptionItem.interface.ts +++ b/src/models/menuOptionItem.interface.ts @@ -5,6 +5,9 @@ export interface MenuOptionItem extends MenuItem { /** An option returned by the onOptionSelected (or action) event callback handler. */ option: any; + /** Array of Option Items (title, command, disabled, ...) */ + optionItems?: Array; + // -- // action/override callbacks diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index 1ad9791a7..d4a1640ea 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -25,6 +25,8 @@ const SlickEventData = IIFE_ONLY ? Slick.EventData : SlickEventData_; const EventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; +export type CellMenuType = 'command' | 'option'; + /** * A plugin to add Menu on a Cell click (click on the cell that has the cellMenu object defined) * The "cellMenu" is defined in a Column Definition object @@ -81,6 +83,7 @@ const Utils = IIFE_ONLY ? Slick.Utils : Utils_; * 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) * * * Available menu Command/Option item properties: @@ -167,6 +170,7 @@ export class SlickCellMenu implements SlickPlugin { protected _commandTitleElm?: HTMLSpanElement; protected _optionTitleElm?: HTMLSpanElement; protected _menuElm?: HTMLDivElement | null; + protected _subMenuElms: HTMLDivElement[] = []; protected _bindingEventService = new BindingEventService(); protected _defaults: CellMenuOption = { autoAdjustDrop: true, // dropup/dropdown @@ -177,6 +181,7 @@ export class SlickCellMenu implements SlickPlugin { maxHeight: 'none', width: 'auto', }; + protected _lastCellMenuTypeClicked = ''; constructor(optionProperties: Partial) { this._cellMenuProperties = Utils.extend({}, this._defaults, optionProperties); @@ -188,7 +193,7 @@ export class SlickCellMenu implements SlickPlugin { this._gridUid = grid?.getUID() || ''; this._handler.subscribe(this._grid.onClick, this.handleCellClick.bind(this)); if (this._cellMenuProperties.hideMenuOnScroll) { - this._handler.subscribe(this._grid.onScroll, this.destroyMenu.bind(this)); + this._handler.subscribe(this._grid.onScroll, this.closeMenu.bind(this)); } } @@ -215,7 +220,6 @@ export class SlickCellMenu implements SlickPlugin { this._currentCell = cell?.cell ?? 0; this._currentRow = cell?.row ?? 0; const columnDef = this._grid.getColumns()[this._currentCell]; - const dataContext = this._grid.getDataItem(this._currentRow); const commandItems = this._cellMenuProperties.commandItems || []; const optionItems = this._cellMenuProperties.optionItems || []; @@ -226,7 +230,7 @@ export class SlickCellMenu implements SlickPlugin { } // delete any prior Cell Menu - this.destroyMenu(); + this.closeMenu(); // Let the user modify the menu or cancel altogether, // or provide alternative menu implementation. @@ -238,34 +242,70 @@ export class SlickCellMenu implements SlickPlugin { return; } + // create 1st parent menu container & reposition it + this._menuElm = this.createCellMenu(commandItems, optionItems); + this._menuElm.style.top = `${e.pageY + 5}px`; + this._menuElm.style.left = `${e.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 createCellMenu(commandItems: Array, optionItems: Array, level = 0) { + const columnDef = this._grid.getColumns()[this._currentCell]; + const dataContext = this._grid.getDataItem(this._currentRow); + // create a new cell menu const maxHeight = isNaN(this._cellMenuProperties.maxHeight as number) ? this._cellMenuProperties.maxHeight : `${this._cellMenuProperties.maxHeight ?? 0}px`; const width = isNaN(this._cellMenuProperties.width as number) ? this._cellMenuProperties.width : `${this._cellMenuProperties.maxWidth ?? 0}px`; - this._menuElm = document.createElement('div'); - this._menuElm.className = `slick-cell-menu ${this._gridUid}`; - this._menuElm.role = 'menu'; + const menuClasses = `slick-cell-menu ${this._gridUid} level-${level}`; + const bodyMenuElm = document.body.querySelector(`.slick-cell-menu.${this._gridUid}.level-${level}`); + + // if menu/sub-menu already exist, then no need to recreate, just return it + if (bodyMenuElm) { + return bodyMenuElm; + } + + const menuElm = document.createElement('div'); + menuElm.className = menuClasses; + if (level > 0) { + menuElm.classList.add('sub-menu'); + } + menuElm.role = 'menu'; if (width) { - this._menuElm.style.width = width as string; + menuElm.style.width = width as string; } if (maxHeight) { - this._menuElm.style.maxHeight = maxHeight as string; + menuElm.style.maxHeight = maxHeight as string; } - this._menuElm.style.top = `${e.pageY + 5}px`; - this._menuElm.style.left = `${e.pageX}px`; - this._menuElm.style.display = 'none'; - const closeButtonElm = document.createElement('button'); - closeButtonElm.type = 'button'; - closeButtonElm.className = 'close'; - closeButtonElm.dataset.dismiss = 'slick-cell-menu'; - closeButtonElm.ariaLabel = 'Close'; - - const spanCloseElm = document.createElement('span'); - spanCloseElm.className = 'close'; - spanCloseElm.ariaHidden = 'true'; - spanCloseElm.innerHTML = '×'; - closeButtonElm.appendChild(spanCloseElm); + 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-cell-menu'; + closeButtonElm.ariaLabel = 'Close'; + + const spanCloseElm = document.createElement('span'); + spanCloseElm.className = 'close'; + spanCloseElm.ariaHidden = 'true'; + spanCloseElm.innerHTML = '×'; + closeButtonElm.appendChild(spanCloseElm); + } // -- Option List section if (!this._cellMenuProperties.hideOptionSection && optionItems.length > 0) { @@ -273,17 +313,18 @@ export class SlickCellMenu implements SlickPlugin { optionMenuElm.className = 'slick-cell-menu-option-list'; optionMenuElm.role = 'menu'; - if (!this._cellMenuProperties.hideCloseButton) { + if (closeButtonElm && !this._cellMenuProperties.hideCloseButton) { this._bindingEventService.bind(closeButtonElm, 'click', this.handleCloseButtonClicked.bind(this) as EventListener); - this._menuElm.appendChild(closeButtonElm); + menuElm.appendChild(closeButtonElm); } - this._menuElm.appendChild(optionMenuElm); + menuElm.appendChild(optionMenuElm); - this.populateOptionItems( + this.populateCommandOrOptionItems( + 'option', this._cellMenuProperties, optionMenuElm, optionItems, - { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid } + { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid, level } ); } @@ -293,44 +334,36 @@ export class SlickCellMenu implements SlickPlugin { commandMenuElm.className = 'slick-cell-menu-command-list'; commandMenuElm.role = 'menu'; - if (!this._cellMenuProperties.hideCloseButton && (optionItems.length === 0 || this._cellMenuProperties.hideOptionSection)) { + if (closeButtonElm && !this._cellMenuProperties.hideCloseButton && (optionItems.length === 0 || this._cellMenuProperties.hideOptionSection)) { this._bindingEventService.bind(closeButtonElm, 'click', this.handleCloseButtonClicked.bind(this) as EventListener); - this._menuElm.appendChild(closeButtonElm); + menuElm.appendChild(closeButtonElm); } + menuElm.appendChild(commandMenuElm); - this._menuElm.appendChild(commandMenuElm); - this.populateCommandItems( + this.populateCommandOrOptionItems( + 'command', this._cellMenuProperties, commandMenuElm, commandItems, - { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid } + { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid, level } ); } - 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; - } + // increment level for possible next sub-menus if exists + level++; - return this._menuElm; + return menuElm; } protected handleCloseButtonClicked(e: DOMMouseOrTouchEvent) { if (!e.defaultPrevented) { - this.destroyMenu(e); + this.closeMenu(e); } } - destroyMenu(e?: Event, args?: { cell: number; row: number; }) { - this._menuElm = this._menuElm || document.querySelector(`.slick-cell-menu.${this._gridUid}`); - - if (this._menuElm?.remove) { + /** Close and destroy Cell Menu */ + closeMenu(e?: DOMMouseOrTouchEvent, args?: MenuFromCellCallbackArgs) { + if (this._menuElm) { if (this.onBeforeMenuClose.notify({ cell: args?.cell ?? 0, row: args?.row ?? 0, @@ -338,8 +371,20 @@ export class SlickCellMenu implements SlickPlugin { }, e, this).getReturnValue() === false) { return; } - this._menuElm.remove(); - this._menuElm = null as any; + this._menuElm?.remove(); + this._menuElm = null; + } + this.destroySubMenus(); + } + + /** Close and destroy all previously opened sub-menus */ + destroySubMenus() { + if (this._subMenuElms.length) { + let subElm = this._subMenuElms.pop(); + while (subElm) { + subElm.remove(); + subElm = this._subMenuElms.pop(); + } } } @@ -347,15 +392,19 @@ export class SlickCellMenu implements SlickPlugin { * Reposition the menu drop (up/down) and the side (left/right) * @param {*} event */ - repositionMenu(e: DOMMouseOrTouchEvent) { - if (this._menuElm && e.target) { - const parentElm = e.target.closest('.slick-cell') as HTMLDivElement; - const parentOffset = (parentElm && Utils.offset(parentElm)); - let menuOffsetLeft = parentElm ? parentOffset?.left ?? 0 : e.pageX; - let menuOffsetTop = parentElm ? parentOffset?.top ?? 0 : e.pageY; - const parentCellWidth = parentElm.offsetWidth || 0; - const menuHeight = this._menuElm?.offsetHeight ?? 0; - const menuWidth = this._menuElm?.offsetWidth ?? this._cellMenuProperties.width ?? 0; + repositionMenu(menuElm: HTMLElement, e: DOMMouseOrTouchEvent) { + const isFromSubMenu = menuElm.classList.contains('sub-menu'); + const parentElm = isFromSubMenu + ? e.target.closest('.slick-cell-menu-item') as HTMLDivElement + : e.target.closest('.slick-cell') as HTMLDivElement; + + if (menuElm && parentElm) { + const parentOffset = Utils.offset(parentElm); + let menuOffsetLeft = parentElm ? parentOffset?.left ?? 0 : e?.pageX ?? 0; + let menuOffsetTop = parentElm ? parentOffset?.top ?? 0 : e?.pageY ?? 0; + const parentCellWidth = parentElm?.offsetWidth || 0; + const menuHeight = menuElm?.offsetHeight ?? 0; + const menuWidth = menuElm?.offsetWidth ?? this._cellMenuProperties.width ?? 0; const rowHeight = this._gridOptions.rowHeight; const dropOffset = +(this._cellMenuProperties.autoAdjustDropOffset || 0); const sideOffset = +(this._cellMenuProperties.autoAlignSideOffset || 0); @@ -370,13 +419,21 @@ export class SlickCellMenu implements SlickPlugin { const spaceTopRemaining = spaceTop - dropOffset + rowHeight!; const dropPosition = (spaceBottomRemaining < menuHeight && spaceTopRemaining > spaceBottomRemaining) ? 'top' : 'bottom'; if (dropPosition === 'top') { - this._menuElm.classList.remove('dropdown'); - this._menuElm.classList.add('dropup'); - menuOffsetTop = menuOffsetTop - menuHeight - dropOffset; + menuElm.classList.remove('dropdown'); + menuElm.classList.add('dropup'); + if (isFromSubMenu) { + menuOffsetTop -= (menuHeight - dropOffset + parentElm.clientHeight); + } else { + menuOffsetTop -= menuHeight - dropOffset; + } } else { - this._menuElm.classList.remove('dropup'); - this._menuElm.classList.add('dropdown'); - menuOffsetTop = menuOffsetTop + rowHeight! + dropOffset; + menuElm.classList.remove('dropup'); + menuElm.classList.add('dropdown'); + if (isFromSubMenu) { + menuOffsetTop += dropOffset; + } else { + menuOffsetTop += rowHeight! + dropOffset; + } } } @@ -387,19 +444,27 @@ export class SlickCellMenu implements SlickPlugin { const gridPos = this._grid.getGridPosition(); const dropSide = ((menuOffsetLeft + (+menuWidth)) >= gridPos.width) ? 'left' : 'right'; if (dropSide === 'left') { - this._menuElm.classList.remove('dropright'); - this._menuElm.classList.add('dropleft'); - menuOffsetLeft = (menuOffsetLeft - (+menuWidth - parentCellWidth) - sideOffset); + menuElm.classList.remove('dropright'); + menuElm.classList.add('dropleft'); + if (isFromSubMenu) { + menuOffsetLeft -= menuWidth - sideOffset; + } else { + menuOffsetLeft -= (+menuWidth - parentCellWidth) - sideOffset; + } } else { - this._menuElm.classList.remove('dropleft'); - this._menuElm.classList.add('dropright'); - menuOffsetLeft = menuOffsetLeft + sideOffset; + menuElm.classList.remove('dropleft'); + menuElm.classList.add('dropright'); + if (isFromSubMenu) { + menuOffsetLeft += sideOffset + parentElm.offsetWidth; + } else { + menuOffsetLeft += sideOffset; + } } } // ready to reposition the menu - this._menuElm.style.top = `${menuOffsetTop}px`; - this._menuElm.style.left = `${menuOffsetLeft}px`; + menuElm.style.top = `${menuOffsetTop}px`; + menuElm.style.left = `${menuOffsetLeft}px`; } } @@ -433,7 +498,7 @@ export class SlickCellMenu implements SlickPlugin { // reposition the menu to where the user clicked if (this._menuElm) { - this.repositionMenu(e); + this.repositionMenu(this._menuElm, e); this._menuElm.setAttribute('aria-expanded', 'true'); this._menuElm.style.display = 'block'; } @@ -443,49 +508,45 @@ export class SlickCellMenu implements SlickPlugin { } } + /** When users click outside the Cell Menu, we will typically close the Cell Menu (and any sub-menus) */ protected handleBodyMouseDown(e: DOMMouseOrTouchEvent) { - if (this._menuElm !== e.target && !(this._menuElm?.contains(e.target))) { - if (!e.defaultPrevented) { - this.closeMenu(e, { cell: this._currentCell, row: this._currentRow, grid: this._grid }); - } + let isMenuClicked = false; + this._subMenuElms.forEach(subElm => { + if (subElm.contains(e.target)) { + isMenuClicked = true; + } + }); + if (this._menuElm?.contains(e.target)) { + isMenuClicked = true; } - } - closeMenu(e: DOMMouseOrTouchEvent, args: MenuFromCellCallbackArgs) { - if (this._menuElm) { - if (this.onBeforeMenuClose.notify({ - cell: args?.cell, - row: args?.row, - grid: this._grid, - }, e, this).getReturnValue() === false) { - return; - } - this._menuElm?.remove(); - this._menuElm = null; + if (this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented) { + this.closeMenu(e, { cell: this._currentCell, row: this._currentRow, grid: this._grid }); } } - /** Construct the Option Items section. */ - protected populateOptionItems(cellMenu: CellMenuOption, optionMenuElm: HTMLElement, optionItems: Array, args: any) { - if (!args || !optionItems || !cellMenu) { + /** Build the Command Items section. */ + protected populateCommandOrOptionItems(itemType: CellMenuType, cellMenu: CellMenuOption, commandOrOptionMenuElm: HTMLElement, commandOrOptionItems: Array | Array, args: any) { + if (!args || !commandOrOptionItems || !cellMenu) { return; } - // user could pass a title on top of the Options section - if (cellMenu?.optionTitle) { - this._optionTitleElm = document.createElement('div'); - this._optionTitleElm.className = 'title'; - this._optionTitleElm.textContent = cellMenu.optionTitle; - optionMenuElm.appendChild(this._optionTitleElm); + // user could pass a title on top of the Commands section + const isSubMenu = args.level > 0; + if (cellMenu?.[`${itemType}Title`] && !isSubMenu) { + this[`_${itemType}TitleElm`] = document.createElement('div'); + this[`_${itemType}TitleElm`]!.className = 'title'; + this[`_${itemType}TitleElm`]!.textContent = cellMenu[`${itemType}Title`] as string; + commandOrOptionMenuElm.appendChild(this[`_${itemType}TitleElm`]!); } - for (let i = 0, ln = optionItems.length; i < ln; i++) { + for (let i = 0, ln = commandOrOptionItems.length; i < ln; i++) { let addClickListener = true; - const item = optionItems[i]; + const item = commandOrOptionItems[i]; // run each override functions to know if the item is visible and usable - const isItemVisible = this.runOverrideFunctionWhenExists((item as MenuOptionItem).itemVisibilityOverride, args); - const isItemUsable = this.runOverrideFunctionWhenExists((item as MenuOptionItem).itemUsabilityOverride, args); + 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) { @@ -493,36 +554,36 @@ export class SlickCellMenu implements SlickPlugin { } // when the override is defined, we need to use its result to update the disabled property - // so that "handleMenuItemOptionClick" has the correct flag and won't trigger an option clicked event + // so that "handleMenuItemCommandClick" has the correct flag and won't trigger a command clicked event if (Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { - (item as MenuOptionItem).disabled = isItemUsable ? false : true; + (item as MenuCommandItem | MenuOptionItem).disabled = isItemUsable ? false : true; } const liElm = document.createElement('div'); liElm.className = 'slick-cell-menu-item'; liElm.role = 'menuitem'; - if ((item as MenuOptionItem).divider || item === 'divider') { + if ((item as MenuCommandItem | MenuOptionItem).divider || item === 'divider') { liElm.classList.add('slick-cell-menu-item-divider'); addClickListener = false; } // if the item is disabled then add the disabled css class - if ((item as MenuOptionItem).disabled || !isItemUsable) { + if ((item as MenuCommandItem | MenuOptionItem).disabled || !isItemUsable) { liElm.classList.add('slick-cell-menu-item-disabled'); } // if the item is hidden then add the hidden css class - if ((item as MenuOptionItem).hidden) { + if ((item as MenuCommandItem | MenuOptionItem).hidden) { liElm.classList.add('slick-cell-menu-item-hidden'); } - if ((item as MenuOptionItem).cssClass) { - liElm.classList.add(...(item as MenuOptionItem).cssClass!.split(' ')); + if ((item as MenuCommandItem | MenuOptionItem).cssClass) { + liElm.classList.add(...(item as MenuCommandItem | MenuOptionItem).cssClass!.split(' ')); } - if ((item as MenuOptionItem).tooltip) { - liElm.title = (item as MenuOptionItem).tooltip || ''; + if ((item as MenuCommandItem | MenuOptionItem).tooltip) { + liElm.title = (item as MenuCommandItem | MenuOptionItem).tooltip || ''; } const iconElm = document.createElement('div'); @@ -530,195 +591,100 @@ export class SlickCellMenu implements SlickPlugin { liElm.appendChild(iconElm); - if ((item as MenuOptionItem).iconCssClass) { - iconElm.classList.add(...(item as MenuOptionItem).iconCssClass!.split(' ')); + if ((item as MenuCommandItem | MenuOptionItem).iconCssClass) { + iconElm.classList.add(...(item as MenuCommandItem | MenuOptionItem).iconCssClass!.split(' ')); } - if ((item as MenuOptionItem).iconImage) { - iconElm.style.backgroundImage = `url(${(item as MenuOptionItem).iconImage})`; + if ((item as MenuCommandItem | MenuOptionItem).iconImage) { + iconElm.style.backgroundImage = `url(${(item as MenuCommandItem | MenuOptionItem).iconImage})`; } const textElm = document.createElement('span'); textElm.className = 'slick-cell-menu-content'; - textElm.textContent = (item as MenuOptionItem).title || ''; + textElm.textContent = (item as MenuCommandItem | MenuOptionItem).title || ''; liElm.appendChild(textElm); - if ((item as MenuOptionItem).textCssClass) { - textElm.classList.add(...(item as MenuOptionItem).textCssClass!.split(' ')); + if ((item as MenuCommandItem | MenuOptionItem).textCssClass) { + textElm.classList.add(...(item as MenuCommandItem | MenuOptionItem).textCssClass!.split(' ')); } - optionMenuElm.appendChild(liElm); + commandOrOptionMenuElm.appendChild(liElm); if (addClickListener) { - this._bindingEventService.bind(liElm, 'click', this.handleMenuItemOptionClick.bind(this, item) as EventListener); + this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item, itemType, args.level) as EventListener); } - } - } - - /** Construct the Command Items section. */ - protected populateCommandItems(cellMenu: CellMenuOption, commandMenuElm: HTMLElement, commandItems: Array, args: any) { - if (!args || !commandItems || !cellMenu) { - return; - } - - // user could pass a title on top of the Commands section - if (cellMenu?.commandTitle) { - this._commandTitleElm = document.createElement('div'); - this._commandTitleElm.className = 'title'; - this._commandTitleElm.textContent = cellMenu.commandTitle; - commandMenuElm.appendChild(this._commandTitleElm); - } - - for (let i = 0, ln = commandItems.length; i < ln; 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 MenuCommandItem).itemVisibilityOverride, args); - const isItemUsable = this.runOverrideFunctionWhenExists((item as MenuCommandItem).itemUsabilityOverride, args); + // the option/command item could be a sub-menu if it has another list of options/commands + if ((item as MenuCommandItem).commandItems || (item as MenuOptionItem).optionItems) { + const chevronElm = document.createElement('span'); + chevronElm.className = 'sub-item-chevron'; + if (this._cellMenuProperties.subItemChevronClass) { + chevronElm.classList.add(...this._cellMenuProperties.subItemChevronClass.split(' ')); + } else { + chevronElm.textContent = '⮞'; // ⮞ or ▸ + } - // if the result is not visible then there's no need to go further - if (!isItemVisible) { + liElm.classList.add('submenu-item'); + liElm.appendChild(chevronElm); 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 MenuCommandItem).disabled = isItemUsable ? false : true; - } - - const liElm = document.createElement('div'); - liElm.className = 'slick-cell-menu-item'; - liElm.role = 'menuitem'; - - if ((item as MenuCommandItem).divider || item === 'divider') { - liElm.classList.add('slick-cell-menu-item-divider'); - addClickListener = false; - } - - // if the item is disabled then add the disabled css class - if ((item as MenuCommandItem).disabled || !isItemUsable) { - liElm.classList.add('slick-cell-menu-item-disabled'); - } - - // if the item is hidden then add the hidden css class - if ((item as MenuCommandItem).hidden) { - liElm.classList.add('slick-cell-menu-item-hidden'); - } - - if ((item as MenuCommandItem).cssClass) { - liElm.classList.add(...(item as MenuCommandItem).cssClass!.split(' ')); - } - - if ((item as MenuCommandItem).tooltip) { - liElm.title = (item as MenuCommandItem).tooltip || ''; - } - - const iconElm = document.createElement('div'); - iconElm.className = 'slick-cell-menu-icon'; - - liElm.appendChild(iconElm); - - if ((item as MenuCommandItem).iconCssClass) { - iconElm.classList.add(...(item as MenuCommandItem).iconCssClass!.split(' ')); - } - - if ((item as MenuCommandItem).iconImage) { - iconElm.style.backgroundImage = `url(${(item as MenuCommandItem).iconImage})`; - } - - const textElm = document.createElement('span'); - textElm.className = 'slick-cell-menu-content'; - textElm.textContent = (item as MenuCommandItem).title || ''; - - liElm.appendChild(textElm); - - if ((item as MenuCommandItem).textCssClass) { - textElm.classList.add(...(item as MenuCommandItem).textCssClass!.split(' ')); - } - - commandMenuElm.appendChild(liElm); - - if (addClickListener) { - this._bindingEventService.bind(liElm, 'click', this.handleMenuItemCommandClick.bind(this, item) as EventListener); - } } } - protected handleMenuItemCommandClick(item: MenuCommandItem | 'divider', e: DOMMouseOrTouchEvent) { - if (!item || (item as MenuCommandItem).disabled || (item as MenuCommandItem).divider || item === 'divider') { - return; + protected repositionSubMenu(item: MenuCommandItem | MenuOptionItem | 'divider', type: CellMenuType, 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._lastCellMenuTypeClicked !== type) { + this.destroySubMenus(); } - const command = item.command || ''; - const row = this._currentRow; - const cell = this._currentCell; - const columnDef = this._grid.getColumns()[cell]; - const dataContext = this._grid.getDataItem(row); - - if (command !== null && command !== '') { - // user could execute a callback through 2 ways - // via the onCommand event and/or an action callback - const callbackArgs = { - cell, - row, - grid: this._grid, - command, - item, - column: columnDef, - dataContext, - }; - this.onCommand.notify(callbackArgs, e, this); - - // execute action callback when defined - if (typeof item.action === 'function') { - item.action.call(this, e, callbackArgs); - } - - if (!e.defaultPrevented) { - this.closeMenu(e, { cell, row, grid: this._grid }); - } - } + const subMenuElm = this.createCellMenu((item as MenuCommandItem)?.commandItems || [], (item as MenuOptionItem)?.optionItems || [], level + 1); + this._subMenuElms.push(subMenuElm); + subMenuElm.style.display = 'block'; + document.body.appendChild(subMenuElm); + this.repositionMenu(subMenuElm, e); } - protected handleMenuItemOptionClick(item: MenuOptionItem | 'divider', e: DOMMouseOrTouchEvent) { - if (!item || (item as MenuOptionItem).disabled || (item as MenuOptionItem).divider || item === 'divider') { - return; - } - if (!this._grid.getEditorLock().commitCurrentEdit()) { - return; - } - - const option = item.option !== undefined ? item.option : ''; - const row = this._currentRow; - const cell = this._currentCell; - const columnDef = this._grid.getColumns()[cell]; - const dataContext = this._grid.getDataItem(row); - - if (option !== undefined) { - // user could execute a callback through 2 ways - // via the onOptionSelected event and/or an action callback - const callbackArgs = { - cell, - row, - grid: this._grid, - option, - item, - column: columnDef, - dataContext - }; - this.onOptionSelected.notify(callbackArgs, e, this); - - // execute action callback when defined - if (typeof item.action === 'function') { - item.action.call(this, e, callbackArgs); + protected handleMenuItemClick(item: T | 'divider', type: CellMenuType, 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); + + 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, + }; + const eventType = type === 'command' ? 'onCommand' : 'onOptionSelected'; + this[eventType].notify(callbackArgs as any, e, this); + + // execute action callback when defined + if (typeof item.action === 'function') { + (item as any).action.call(this, e, callbackArgs); + } - if (!e.defaultPrevented) { - this.closeMenu(e, { cell, row, grid: this._grid }); + if (!e.defaultPrevented) { + this.closeMenu(e, { cell, row, grid: this._grid }); + } + } else if ((item as MenuCommandItem).commandItems || (item as MenuOptionItem).optionItems) { + this.repositionSubMenu(item, type, level, e); + } else { + this.destroySubMenus(); } + this._lastCellMenuTypeClicked = type; } } diff --git a/src/styles/slick.cellmenu.scss b/src/styles/slick.cellmenu.scss index 09a6c5982..007353ba7 100644 --- a/src/styles/slick.cellmenu.scss +++ b/src/styles/slick.cellmenu.scss @@ -80,6 +80,10 @@ border: 1px solid transparent; border-radius: 3px; display: block; + + .sub-item-chevron { + float: right; + } } .slick-cell-menu-item:hover { border-color: silver; From 50ea0e49522cfc4748a83fb3c8d5f5133f228a90 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 18 Oct 2023 10:57:28 -0400 Subject: [PATCH 02/13] chore: fix PR comments & add `subMenuTitle` option --- cypress/e2e/example-plugin-contextmenu.cy.ts | 50 ++++++++++------- examples/example-plugin-contextmenu.html | 5 +- src/models/menuItem.interface.ts | 3 + src/plugins/slick.cellmenu.ts | 59 ++++++++++++++++---- 4 files changed, 83 insertions(+), 34 deletions(-) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index ae2662e4a..1d470818a 100644 --- a/cypress/e2e/example-plugin-contextmenu.cy.ts +++ b/cypress/e2e/example-plugin-contextmenu.cy.ts @@ -151,7 +151,7 @@ describe('Example - Context Menu & Cell Menu', () => { const stub = cy.stub(); cy.on('window:alert', stub); - cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-option-list') .find('.slick-cell-menu-item') .contains('False') .click(); @@ -176,12 +176,18 @@ describe('Example - Context Menu & Cell Menu', () => { .contains('Action') .click({ force: true }); - cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-option-list') .find('.slick-cell-menu-item') .contains('Sub-Options') .click(); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list').as('subMenuList'); + + cy.get('@subMenuList') + .find('.title') + .contains('Set Effort Driven'); + + cy.get('@subMenuList') .find('.slick-cell-menu-item') .contains('True') .click(); @@ -208,12 +214,12 @@ describe('Example - Context Menu & Cell Menu', () => { .contains('Action') .click({ force: true }); - cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-option-list') .find('.slick-cell-menu-item') .contains('Sub-Options') .click(); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list') .find('.slick-cell-menu-item') .contains('False') .click(); @@ -289,17 +295,17 @@ describe('Example - Context Menu & Cell Menu', () => { .contains('Action') .click({ force: true }); - cy.get('.slick-cell-menu.level-0 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') .contains('Export') .click(); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') .should('exist') .find('.slick-cell-menu-item') .each(($command, index) => expect($command.text()).to.eq(subCommands[index])); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') .contains('PDF') .click() @@ -317,27 +323,33 @@ describe('Example - Context Menu & Cell Menu', () => { .contains('Action') .click({ force: true }); - cy.get('.slick-cell-menu.level-0 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') .contains('Export') .click(); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') .should('exist') .find('.slick-cell-menu-item') .each(($command, index) => expect($command.text()).to.eq(subCommands1[index])); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') .contains('Excel') .click(); - cy.get('.slick-cell-menu.level-2 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-2 .slick-cell-menu-command-list').as('subMenuList2'); + + cy.get('@subMenuList2') + .find('.title') + .contains('available formats'); + + cy.get('@subMenuList2') .should('exist') .find('.slick-cell-menu-item') .each(($command, index) => expect($command.text()).to.eq(subCommands2[index])); - cy.get('.slick-cell-menu.level-2 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-2 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') .contains('Excel (xls)') .click() @@ -354,32 +366,32 @@ describe('Example - Context Menu & Cell Menu', () => { .contains('Action') .click({ force: true }); - cy.get('.slick-cell-menu.level-0 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') .contains('Export') .click(); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') .should('exist') .find('.slick-cell-menu-item') .each(($command, index) => expect($command.text()).to.eq(subCommands1[index])); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') .contains('Excel') .click(); - cy.get('.slick-cell-menu.level-2 .slick-cell-menu-command-list') + cy.get('.slick-cell-menu.slick-menu-level-2 .slick-cell-menu-command-list') .should('exist') .find('.slick-cell-menu-item') .each(($command, index) => expect($command.text()).to.eq(subCommands2[index])); - cy.get('.slick-cell-menu.level-0 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-option-list') .find('.slick-cell-menu-item') .contains('Sub-Options') .click(); - cy.get('.slick-cell-menu.level-1 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list') .should('exist') .find('.slick-cell-menu-item') .each(($option, index) => expect($option.text()).to.eq(subOptions[index])); diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index 34680831b..fdf7de9f9 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -363,7 +363,7 @@

View Source:

commandItems: [ { command: "export-pdf", title: "PDF" }, { - command: 'sub-menu', title: 'Excel', cssClass: "green", + command: 'sub-menu', title: 'Excel', cssClass: "green", subMenuTitle: "available formats", commandItems: [ { command: "export-csv", title: "Excel (csv)" }, { command: "export-xls", title: "Excel (xls)" }, @@ -393,7 +393,7 @@

View Source:

}, { // we can also have multiple sub-items - option: null, title: "Sub-Options (demo)", optionItems: [ + option: null, title: "Sub-Options (demo)", subMenuTitle: "Set Effort Driven", optionItems: [ { option: true, title: "True", iconCssClass: 'sgi sgi-checkbox-marked-outline green' }, { option: false, title: "False", iconCssClass: 'sgi sgi-checkbox-blank-outline pink' }, ] @@ -567,7 +567,6 @@

View Source:

// subscribe to Cell Menu onOptionSelected event (or use the action callback on each option) cellMenuPlugin.onOptionSelected.subscribe(function (e, args) { - console.log('onOptionSelected', args) // e.preventDefault(); // you could do if you wish to keep the menu open var dataContext = args && args.dataContext; diff --git a/src/models/menuItem.interface.ts b/src/models/menuItem.interface.ts index aead35319..ea8797dba 100644 --- a/src/models/menuItem.interface.ts +++ b/src/models/menuItem.interface.ts @@ -22,6 +22,9 @@ export interface MenuItem { /** position order in the list, a lower number will make it on top of the list. Internal commands starts at 50. */ positionOrder?: number; + /** Optional sub-menu title that will shows up when sub-menu commmands/options list is opened */ + subMenuTitle?: string; + /** CSS class to be added to the menu item text. */ textCssClass?: string; diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index d4a1640ea..ad8e6287e 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -7,6 +7,7 @@ import { } from '../slick.core'; import type { CellMenuOption, + Column, DOMMouseOrTouchEvent, GridOption, MenuCommandItem, @@ -261,7 +262,15 @@ export class SlickCellMenu implements SlickPlugin { return this._menuElm; } - protected createCellMenu(commandItems: Array, optionItems: Array, level = 0) { + /** + * Create parent menu or sub-menu(s), a parent menu will start at level 0 while sub-menu(s) will be incremented + * @param commandItems - array of optional commands or dividers + * @param optionItems - array of optional options or dividers + * @param level - menu level + * @param item - command, option or divider + * @returns menu DOM element + */ + protected createCellMenu(commandItems: Array, optionItems: Array, level = 0, item?: MenuCommandItem | MenuOptionItem | 'divider') { const columnDef = this._grid.getColumns()[this._currentCell]; const dataContext = this._grid.getDataItem(this._currentRow); @@ -269,8 +278,8 @@ export class SlickCellMenu implements SlickPlugin { const maxHeight = isNaN(this._cellMenuProperties.maxHeight as number) ? this._cellMenuProperties.maxHeight : `${this._cellMenuProperties.maxHeight ?? 0}px`; const width = isNaN(this._cellMenuProperties.width as number) ? this._cellMenuProperties.width : `${this._cellMenuProperties.maxWidth ?? 0}px`; - const menuClasses = `slick-cell-menu ${this._gridUid} level-${level}`; - const bodyMenuElm = document.body.querySelector(`.slick-cell-menu.${this._gridUid}.level-${level}`); + const menuClasses = `slick-cell-menu ${this._gridUid} slick-menu-level-${level}`; + const bodyMenuElm = document.body.querySelector(`.slick-cell-menu.${this._gridUid}.slick-menu-level-${level}`); // if menu/sub-menu already exist, then no need to recreate, just return it if (bodyMenuElm) { @@ -280,7 +289,7 @@ export class SlickCellMenu implements SlickPlugin { const menuElm = document.createElement('div'); menuElm.className = menuClasses; if (level > 0) { - menuElm.classList.add('sub-menu'); + menuElm.classList.add('slick-submenu'); } menuElm.role = 'menu'; if (width) { @@ -313,6 +322,11 @@ export class SlickCellMenu implements SlickPlugin { optionMenuElm.className = 'slick-cell-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._cellMenuProperties.hideCloseButton) { this._bindingEventService.bind(closeButtonElm, 'click', this.handleCloseButtonClicked.bind(this) as EventListener); menuElm.appendChild(closeButtonElm); @@ -334,6 +348,11 @@ export class SlickCellMenu implements SlickPlugin { commandMenuElm.className = 'slick-cell-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._cellMenuProperties.hideCloseButton && (optionItems.length === 0 || this._cellMenuProperties.hideOptionSection)) { this._bindingEventService.bind(closeButtonElm, 'click', this.handleCloseButtonClicked.bind(this) as EventListener); menuElm.appendChild(closeButtonElm); @@ -355,6 +374,15 @@ export class SlickCellMenu implements SlickPlugin { return menuElm; } + protected addSubMenuTitleWhenExists(item: MenuCommandItem | MenuOptionItem | 'divider', commandOrOptionMenu: HTMLDivElement) { + if (item !== 'divider' && item?.subMenuTitle) { + const subMenuTitleElm = document.createElement('div'); + subMenuTitleElm.className = 'title'; + subMenuTitleElm.textContent = (item as MenuCommandItem | MenuOptionItem).subMenuTitle as string; + commandOrOptionMenu.appendChild(subMenuTitleElm); + } + } + protected handleCloseButtonClicked(e: DOMMouseOrTouchEvent) { if (!e.defaultPrevented) { this.closeMenu(e); @@ -371,7 +399,7 @@ export class SlickCellMenu implements SlickPlugin { }, e, this).getReturnValue() === false) { return; } - this._menuElm?.remove(); + this._menuElm.remove(); this._menuElm = null; } this.destroySubMenus(); @@ -393,7 +421,7 @@ export class SlickCellMenu implements SlickPlugin { * @param {*} event */ repositionMenu(menuElm: HTMLElement, e: DOMMouseOrTouchEvent) { - const isFromSubMenu = menuElm.classList.contains('sub-menu'); + const isFromSubMenu = menuElm.classList.contains('slick-submenu'); const parentElm = isFromSubMenu ? e.target.closest('.slick-cell-menu-item') as HTMLDivElement : e.target.closest('.slick-cell') as HTMLDivElement; @@ -526,7 +554,13 @@ export class SlickCellMenu implements SlickPlugin { } /** Build the Command Items section. */ - protected populateCommandOrOptionItems(itemType: CellMenuType, cellMenu: CellMenuOption, commandOrOptionMenuElm: HTMLElement, commandOrOptionItems: Array | Array, args: any) { + protected populateCommandOrOptionItems( + itemType: CellMenuType, + cellMenu: CellMenuOption, + commandOrOptionMenuElm: HTMLElement, + commandOrOptionItems: Array | Array, + args: { cell: number, row: number, column: Column, dataContext: any, grid: SlickGrid, level: number } + ) { if (!args || !commandOrOptionItems || !cellMenu) { return; } @@ -554,7 +588,7 @@ export class SlickCellMenu implements SlickPlugin { } // 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 + // so that "handleMenuItemCommandClick" has the correct flag and won't trigger a command/option clicked event if (Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { (item as MenuCommandItem | MenuOptionItem).disabled = isItemUsable ? false : true; } @@ -615,7 +649,7 @@ export class SlickCellMenu implements SlickPlugin { this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item, itemType, args.level) as EventListener); } - // the option/command item could be a sub-menu if it has another list of options/commands + // 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'; @@ -625,7 +659,7 @@ export class SlickCellMenu implements SlickPlugin { chevronElm.textContent = '⮞'; // ⮞ or ▸ } - liElm.classList.add('submenu-item'); + liElm.classList.add('slick-submenu-item'); liElm.appendChild(chevronElm); continue; } @@ -638,7 +672,8 @@ export class SlickCellMenu implements SlickPlugin { this.destroySubMenus(); } - const subMenuElm = this.createCellMenu((item as MenuCommandItem)?.commandItems || [], (item as MenuOptionItem)?.optionItems || [], level + 1); + // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show + const subMenuElm = this.createCellMenu((item as MenuCommandItem)?.commandItems || [], (item as MenuOptionItem)?.optionItems || [], level + 1, item); this._subMenuElms.push(subMenuElm); subMenuElm.style.display = 'block'; document.body.appendChild(subMenuElm); @@ -658,7 +693,7 @@ export class SlickCellMenu implements SlickPlugin { 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 + // via the onCommand/onOptionSelected event and/or an action callback const callbackArgs = { cell, row, From 7959a491bba157e0599412b1a9def575fccb5ba7 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 18 Oct 2023 13:56:27 -0400 Subject: [PATCH 03/13] chore: improve auto-align (left/right) detection by using body width --- examples/example-plugin-contextmenu.html | 5 +++++ src/plugins/slick.cellmenu.ts | 24 +++++++++++++----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index fdf7de9f9..1c1ea68f7 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -78,6 +78,11 @@ border: 1px solid #718BB7; box-shadow: 2px 2px 2px silver; } + .slick-submenu { + background-color: #fbfbfb; + /* border-width: 2px; */ + box-shadow: 0 2px 4px 2px rgba(146, 152, 163, 0.4); + } diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index ad8e6287e..3185b4f49 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -421,8 +421,8 @@ export class SlickCellMenu implements SlickPlugin { * @param {*} event */ repositionMenu(menuElm: HTMLElement, e: DOMMouseOrTouchEvent) { - const isFromSubMenu = menuElm.classList.contains('slick-submenu'); - const parentElm = isFromSubMenu + const isSubMenu = menuElm.classList.contains('slick-submenu'); + const parentElm = isSubMenu ? e.target.closest('.slick-cell-menu-item') as HTMLDivElement : e.target.closest('.slick-cell') as HTMLDivElement; @@ -434,8 +434,8 @@ export class SlickCellMenu implements SlickPlugin { const menuHeight = menuElm?.offsetHeight ?? 0; const menuWidth = menuElm?.offsetWidth ?? this._cellMenuProperties.width ?? 0; const rowHeight = this._gridOptions.rowHeight; - const dropOffset = +(this._cellMenuProperties.autoAdjustDropOffset || 0); - const sideOffset = +(this._cellMenuProperties.autoAlignSideOffset || 0); + const dropOffset = Number(this._cellMenuProperties.autoAdjustDropOffset || 0); + const sideOffset = Number(this._cellMenuProperties.autoAlignSideOffset || 0); // if autoAdjustDrop is enable, we first need to see what position the drop will be located (defaults to bottom) // without necessary toggling it's position just yet, we just want to know the future position for calculation @@ -449,15 +449,15 @@ export class SlickCellMenu implements SlickPlugin { if (dropPosition === 'top') { menuElm.classList.remove('dropdown'); menuElm.classList.add('dropup'); - if (isFromSubMenu) { - menuOffsetTop -= (menuHeight - dropOffset + parentElm.clientHeight); + if (isSubMenu) { + menuOffsetTop -= (menuHeight - dropOffset - parentElm.clientHeight); } else { menuOffsetTop -= menuHeight - dropOffset; } } else { menuElm.classList.remove('dropup'); menuElm.classList.add('dropdown'); - if (isFromSubMenu) { + if (isSubMenu) { menuOffsetTop += dropOffset; } else { menuOffsetTop += rowHeight! + dropOffset; @@ -470,19 +470,21 @@ export class SlickCellMenu implements SlickPlugin { // to simulate an align left, we actually need to know the width of the drop menu if (this._cellMenuProperties.autoAlignSide) { const gridPos = this._grid.getGridPosition(); - const dropSide = ((menuOffsetLeft + (+menuWidth)) >= gridPos.width) ? 'left' : 'right'; + const subMenuPosCalc = menuOffsetLeft + parentElm.clientWidth + Number(menuWidth); // calculate coordinate at caller element far right + 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'); - if (isFromSubMenu) { + if (isSubMenu) { menuOffsetLeft -= menuWidth - sideOffset; } else { - menuOffsetLeft -= (+menuWidth - parentCellWidth) - sideOffset; + menuOffsetLeft -= Number(menuWidth) - parentCellWidth - sideOffset; } } else { menuElm.classList.remove('dropleft'); menuElm.classList.add('dropright'); - if (isFromSubMenu) { + if (isSubMenu) { menuOffsetLeft += sideOffset + parentElm.offsetWidth; } else { menuOffsetLeft += sideOffset; From 667f95cc0feb484fb6324cbf0ed0229c7ac9b28c Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 18 Oct 2023 14:00:50 -0400 Subject: [PATCH 04/13] chore: rename create menu methods to be more explicit --- src/plugins/slick.cellmenu.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index 3185b4f49..b397fc287 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -216,7 +216,7 @@ export class SlickCellMenu implements SlickPlugin { this._menuElm = null as any; } - protected createMenu(e: DOMMouseOrTouchEvent) { + protected createParentMenu(e: DOMMouseOrTouchEvent) { const cell = this._grid.getCellFromEvent(e); this._currentCell = cell?.cell ?? 0; this._currentRow = cell?.row ?? 0; @@ -244,7 +244,7 @@ export class SlickCellMenu implements SlickPlugin { } // create 1st parent menu container & reposition it - this._menuElm = this.createCellMenu(commandItems, optionItems); + this._menuElm = this.createMenu(commandItems, optionItems); this._menuElm.style.top = `${e.pageY + 5}px`; this._menuElm.style.left = `${e.pageX}px`; @@ -270,7 +270,7 @@ export class SlickCellMenu implements SlickPlugin { * @param item - command, option or divider * @returns menu DOM element */ - protected createCellMenu(commandItems: Array, optionItems: Array, level = 0, item?: MenuCommandItem | MenuOptionItem | 'divider') { + 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); @@ -524,7 +524,7 @@ export class SlickCellMenu implements SlickPlugin { } // create the DOM element - this._menuElm = this.createMenu(e); + this._menuElm = this.createParentMenu(e); // reposition the menu to where the user clicked if (this._menuElm) { @@ -675,7 +675,7 @@ export class SlickCellMenu implements SlickPlugin { } // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show - const subMenuElm = this.createCellMenu((item as MenuCommandItem)?.commandItems || [], (item as MenuOptionItem)?.optionItems || [], level + 1, item); + const subMenuElm = this.createMenu((item as MenuCommandItem)?.commandItems || [], (item as MenuOptionItem)?.optionItems || [], level + 1, item); this._subMenuElms.push(subMenuElm); subMenuElm.style.display = 'block'; document.body.appendChild(subMenuElm); From 209e950745c4c7ec1a76607921b2e07891f5df01 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 18 Oct 2023 14:14:08 -0400 Subject: [PATCH 05/13] chore: change "title" css class to "slick-menu-title" to avoid conflict like w/Bulma --- src/plugins/slick.cellmenu.ts | 4 ++-- src/styles/slick.cellmenu.scss | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index b397fc287..cfdef7af5 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -377,7 +377,7 @@ export class SlickCellMenu implements SlickPlugin { protected addSubMenuTitleWhenExists(item: MenuCommandItem | MenuOptionItem | 'divider', commandOrOptionMenu: HTMLDivElement) { if (item !== 'divider' && item?.subMenuTitle) { const subMenuTitleElm = document.createElement('div'); - subMenuTitleElm.className = 'title'; + subMenuTitleElm.className = 'slick-menu-title'; subMenuTitleElm.textContent = (item as MenuCommandItem | MenuOptionItem).subMenuTitle as string; commandOrOptionMenu.appendChild(subMenuTitleElm); } @@ -571,7 +571,7 @@ export class SlickCellMenu implements SlickPlugin { const isSubMenu = args.level > 0; if (cellMenu?.[`${itemType}Title`] && !isSubMenu) { this[`_${itemType}TitleElm`] = document.createElement('div'); - this[`_${itemType}TitleElm`]!.className = 'title'; + this[`_${itemType}TitleElm`]!.className = 'slick-menu-title'; this[`_${itemType}TitleElm`]!.textContent = cellMenu[`${itemType}Title`] as string; commandOrOptionMenuElm.appendChild(this[`_${itemType}TitleElm`]!); } diff --git a/src/styles/slick.cellmenu.scss b/src/styles/slick.cellmenu.scss index 007353ba7..c7c417b36 100644 --- a/src/styles/slick.cellmenu.scss +++ b/src/styles/slick.cellmenu.scss @@ -30,7 +30,7 @@ float: right; } -.slick-cell-menu .title { +.slick-cell-menu .slick-menu-title { font-size: 16px; width: calc(100% - 30px); border-bottom: solid 1px #d6d6d6; From 404d2a35416f97b44f00ad45213ce4fdc6a8ed4d Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 18 Oct 2023 14:21:11 -0400 Subject: [PATCH 06/13] chore: fix failing Cypress E2E test --- cypress/e2e/example-plugin-contextmenu.cy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index 1d470818a..df7dc30af 100644 --- a/cypress/e2e/example-plugin-contextmenu.cy.ts +++ b/cypress/e2e/example-plugin-contextmenu.cy.ts @@ -184,7 +184,7 @@ describe('Example - Context Menu & Cell Menu', () => { cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list').as('subMenuList'); cy.get('@subMenuList') - .find('.title') + .find('.slick-menu-title') .contains('Set Effort Driven'); cy.get('@subMenuList') @@ -341,7 +341,7 @@ describe('Example - Context Menu & Cell Menu', () => { cy.get('.slick-cell-menu.slick-menu-level-2 .slick-cell-menu-command-list').as('subMenuList2'); cy.get('@subMenuList2') - .find('.title') + .find('.slick-menu-title') .contains('available formats'); cy.get('@subMenuList2') From a952d6c7e4a4b958ed349ff44f6dc29851f86d7a Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 18 Oct 2023 14:34:05 -0400 Subject: [PATCH 07/13] chore: add `subMenuTitleCssClass` to customize submenu titles --- examples/example-plugin-contextmenu.html | 2 +- src/models/menuItem.interface.ts | 3 +++ src/plugins/slick.cellmenu.ts | 7 ++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index 1c1ea68f7..17dc16a3e 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -368,7 +368,7 @@

View Source:

commandItems: [ { command: "export-pdf", title: "PDF" }, { - command: 'sub-menu', title: 'Excel', cssClass: "green", subMenuTitle: "available formats", + command: 'sub-menu', title: 'Excel', cssClass: "green", subMenuTitle: "available formats", subMenuTitleCssClass: "italic orange", commandItems: [ { command: "export-csv", title: "Excel (csv)" }, { command: "export-xls", title: "Excel (xls)" }, diff --git a/src/models/menuItem.interface.ts b/src/models/menuItem.interface.ts index ea8797dba..a1662e6f0 100644 --- a/src/models/menuItem.interface.ts +++ b/src/models/menuItem.interface.ts @@ -25,6 +25,9 @@ export interface MenuItem { /** Optional sub-menu title that will shows up when sub-menu commmands/options list is opened */ subMenuTitle?: string; + /** Optional sub-menu title CSS class to use with `subMenuTitle` */ + subMenuTitleCssClass?: string; + /** CSS class to be added to the menu item text. */ textCssClass?: string; diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index cfdef7af5..8ff4d83f2 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -378,7 +378,12 @@ export class SlickCellMenu implements SlickPlugin { if (item !== 'divider' && item?.subMenuTitle) { const subMenuTitleElm = document.createElement('div'); subMenuTitleElm.className = 'slick-menu-title'; - subMenuTitleElm.textContent = (item as MenuCommandItem | MenuOptionItem).subMenuTitle as string; + subMenuTitleElm.textContent = item.subMenuTitle as string; + const subMenuTitleClass = item.subMenuTitleCssClass as string; + if (subMenuTitleClass) { + subMenuTitleElm.classList.add(...subMenuTitleClass.split(' ')); + } + commandOrOptionMenu.appendChild(subMenuTitleElm); } } From e9ac5f717019fca31a28df7ec77af8b41ce5d7e1 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 18 Oct 2023 17:20:12 -0400 Subject: [PATCH 08/13] feat: add sub-menu(s) to ContextMenu plugin --- cypress/e2e/example-plugin-contextmenu.cy.ts | 170 +++++- examples/example-plugin-contextmenu.html | 26 + src/models/contextMenuOption.interface.ts | 3 + src/models/menuItem.interface.ts | 2 + src/plugins/slick.cellmenu.ts | 56 +- src/plugins/slick.contextmenu.ts | 537 ++++++++++--------- src/styles/slick.contextmenu.scss | 6 +- 7 files changed, 503 insertions(+), 297 deletions(-) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index df7dc30af..6d827a3ab 100644 --- a/cypress/e2e/example-plugin-contextmenu.cy.ts +++ b/cypress/e2e/example-plugin-contextmenu.cy.ts @@ -65,7 +65,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should expect the Context Menu to not have the "Help" menu when there is Effort Driven set to True', () => { - const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)']; + const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)', '', 'Export']; cy.get('#myGrid') .find('.slick-row .slick-cell:nth(1)') @@ -78,13 +78,13 @@ describe('Example - Context Menu & Cell Menu', () => { cy.get('.slick-context-menu.dropright .slick-context-menu-command-list') .find('.slick-context-menu-item') .each(($command, index) => { - expect($command.text()).to.eq(commands[index]); + expect($command.text()).to.contain(commands[index]); expect($command.text()).not.include('Help'); }); }); it('should expect the Context Menu to not have the "Help" menu when there is Effort Driven set to True', () => { - const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)']; + const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)', '', 'Export']; cy.get('#myGrid') .find('.slick-row .slick-cell:nth(1)') @@ -97,7 +97,7 @@ describe('Example - Context Menu & Cell Menu', () => { cy.get('.slick-context-menu.dropright .slick-context-menu-command-list') .find('.slick-context-menu-item') .each(($command, index) => { - expect($command.text()).to.eq(commands[index]); + expect($command.text()).to.contain(commands[index]); expect($command.text()).not.include('Help'); }); }); @@ -240,7 +240,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should expect the Context Menu now have the "Help" menu when Effort Driven is set to False', () => { - const commands = ['Copy Cell Value', 'Delete Row', '', 'Help', 'Command (always disabled)']; + const commands = ['Copy Cell Value', 'Delete Row', '', 'Help', 'Command (always disabled)', '', 'Export']; cy.get('#myGrid') .find('.slick-row .slick-cell:nth(1)') @@ -252,7 +252,7 @@ describe('Example - Context Menu & Cell Menu', () => { cy.get('.slick-context-menu.dropleft .slick-context-menu-command-list') .find('.slick-context-menu-item') - .each(($command, index) => expect($command.text()).to.eq(commands[index])); + .each(($command, index) => expect($command.text()).to.contain(commands[index])); cy.get('.slick-context-menu button.close') .click(); @@ -391,7 +391,13 @@ describe('Example - Context Menu & Cell Menu', () => { .contains('Sub-Options') .click(); - cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list') + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list').as('optionSubList2'); + + cy.get('@optionSubList2') + .find('.slick-menu-title') + .contains('Set Effort Driven'); + + cy.get('@optionSubList2') .should('exist') .find('.slick-cell-menu-item') .each(($option, index) => expect($option.text()).to.eq(subOptions[index])); @@ -420,7 +426,7 @@ describe('Example - Context Menu & Cell Menu', () => { .click(); }); - it('should click on the "Show Priority Options Only" button and see both list when opening Context Menu', () => { + it('should click on the "Show Priority Options Only" button and see both list when opening Context Menu & selecting "Medium" option should be reflected in the grid cell and expect "Action" cell menu to be disabled', () => { cy.get('button') .contains('Show Priority Options Only') .click(); @@ -429,15 +435,62 @@ describe('Example - Context Menu & Cell Menu', () => { .find('.slick-row .slick-cell:nth(5)') .rightclick(); + cy.get('.slick-context-menu-command-list') + .should('not.exist'); + cy.get('.slick-context-menu .slick-context-menu-option-list') .should('exist') - .contains('High'); + .contains('Medium') + .click(); - cy.get('.slick-context-menu-command-list') - .should('not.exist'); + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(5)') + .contains('Medium'); - cy.get('.slick-context-menu button.close') + cy.get('.slick-row .slick-cell:nth(7)') + .find('.cell-menu-dropdown.disabled') + .should('exist'); + }); + + it('should reopen Context Menu then select "High" option from sub-menu and expect "Action" cell menu to be reenabled', () => { + const subOptions = ['Low', 'Medium', 'High']; + + cy.get('button') + .contains('Show Priority Options Only') + .click(); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(5)') + .rightclick(); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-context-menu-option-list') + .find('.slick-context-menu-item') + .contains('Sub-Options (demo)') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-option-list').as('subMenuList'); + + cy.get('@subMenuList') + .find('.slick-menu-title') + .contains('Set Priority'); + + cy.get('@subMenuList') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subOptions[index])); + + cy.get('@subMenuList') + .find('.slick-context-menu-item') + .contains('High') .click(); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(5)') + .contains('High'); + + cy.get('.slick-row .slick-cell:nth(7)') + .find('.cell-menu-dropdown.disabled') + .should('not.exist'); }); it('should click on the "Show Actions Commands & Effort Options" button and see both list when opening Action Cell Menu', () => { @@ -599,4 +652,97 @@ describe('Example - Context Menu & Cell Menu', () => { cy.get('.slick-context-menu button.close') .click(); }); + + it('should be able to open Context Menu and click on Export->Excel-> sub-commands to see 1 context menu + 1 sub-menu then clicking on PDF should call alert action', () => { + const subCommands1 = ['PDF', 'Excel']; + const subCommands2 = ['Excel (csv)', 'Excel (xls)']; + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(1)') + .rightclick(); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-command-list') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Excel') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-2 .slick-context-menu-command-list').as('subMenuList2'); + + cy.get('@subMenuList2') + .find('.slick-menu-title') + .contains('available formats'); + + cy.get('@subMenuList2') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2[index])); + + cy.get('.slick-context-menu.slick-menu-level-2 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Excel (xls)') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Excel (xls)')); + }); + + it('should open Export->Excel sub-menu & open again Sub-Options on top and expect sub-menu to be recreated with that Sub-Options list instead of the Export->Excel list', () => { + const subCommands1 = ['PDF', 'Excel']; + const subCommands2 = ['Excel (csv)', 'Excel (xls)']; + const subOptions = ['Low', 'Medium', 'High']; + + cy.get('button') + .contains('Show Commands & Priority Options') + .click(); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(5)') + .rightclick(); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-command-list') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Excel') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-2 .slick-context-menu-command-list') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2[index])); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-context-menu-option-list') + .find('.slick-context-menu-item') + .contains('Sub-Options') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-option-list').as('optionSubList2'); + + cy.get('@optionSubList2') + .find('.slick-menu-title') + .contains('Set Priority'); + + cy.get('@optionSubList2') + .should('exist') + .find('.slick-context-menu-item') + .each(($option, index) => expect($option.text()).to.contain(subOptions[index])); + }); }); diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index 17dc16a3e..2c11cbc75 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -418,6 +418,8 @@

View Source:

}; var contextMenuOptions = { + // subItemChevronClass: 'sgi sgi-chevron-right', + // optionally and conditionally define when the the menu is usable, // this should be used with a custom formatter to show/hide/disable the menu menuUsabilityOverride: function (args) { @@ -442,6 +444,21 @@

View Source:

} }, { command: "something", title: "Command (always disabled)", disabled: true }, + "divider", + { + // we can also have multiple sub-items + command: 'export', title: 'Export', + commandItems: [ + { command: "export-pdf", title: "PDF" }, + { + command: 'sub-menu', title: 'Excel', cssClass: "green", subMenuTitle: "available formats", subMenuTitleCssClass: "italic orange", + commandItems: [ + { command: "export-csv", title: "Excel (csv)" }, + { command: "export-xls", title: "Excel (xls)" }, + ] + } + ] + } ], // Options allows you to edit a column from an option chose a list @@ -474,6 +491,15 @@

View Source:

return (!args.dataContext.effortDriven); } }, + "divider", + { + // we can also have multiple sub-items + option: null, title: "Sub-Options (demo)", subMenuTitle: "Set Priority", optionItems: [ + { option: 1, iconCssClass: "sgi sgi-star-outline", title: "Low" }, + { option: 2, iconCssClass: "sgi sgi-star orange", title: "Medium" }, + { option: 3, iconCssClass: "sgi sgi-star red", title: "High" }, + ] + } ] }; diff --git a/src/models/contextMenuOption.interface.ts b/src/models/contextMenuOption.interface.ts index 5ba16db74..40569fa7b 100644 --- a/src/models/contextMenuOption.interface.ts +++ b/src/models/contextMenuOption.interface.ts @@ -65,6 +65,9 @@ export interface ContextMenuOption { /** Optional Title of the Option section, it will be hidden when nothing is provided */ optionTitle?: string; + /** CSS class that can be added on the right side of a sub-item parent (typically a chevron-right icon) */ + subItemChevronClass?: string; + // -- // action/override callbacks diff --git a/src/models/menuItem.interface.ts b/src/models/menuItem.interface.ts index a1662e6f0..8cc399769 100644 --- a/src/models/menuItem.interface.ts +++ b/src/models/menuItem.interface.ts @@ -1,5 +1,7 @@ import type { MenuCallbackArgs } from './menuCallbackArgs.interface'; +export type MenuType = 'command' | 'option'; + export interface MenuItem { /** A CSS class to be added to the menu item container. */ cssClass?: string; diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index 8ff4d83f2..f348cc605 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -15,6 +15,7 @@ import type { MenuFromCellCallbackArgs, MenuOptionItem, MenuOptionItemCallbackArgs, + MenuType, SlickPlugin } from '../models/index'; import type { SlickGrid } from '../slick.grid'; @@ -26,8 +27,6 @@ const SlickEventData = IIFE_ONLY ? Slick.EventData : SlickEventData_; const EventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; -export type CellMenuType = 'command' | 'option'; - /** * A plugin to add Menu on a Cell click (click on the cell that has the cellMenu object defined) * The "cellMenu" is defined in a Column Definition object @@ -95,6 +94,8 @@ export type CellMenuType = 'command' | 'option'; * divider: Boolean which tells 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. @@ -170,6 +171,7 @@ export class SlickCellMenu implements SlickPlugin { protected _handler = new EventHandler(); protected _commandTitleElm?: HTMLSpanElement; protected _optionTitleElm?: HTMLSpanElement; + protected _lastMenuTypeClicked = ''; protected _menuElm?: HTMLDivElement | null; protected _subMenuElms: HTMLDivElement[] = []; protected _bindingEventService = new BindingEventService(); @@ -182,7 +184,6 @@ export class SlickCellMenu implements SlickPlugin { maxHeight: 'none', width: 'auto', }; - protected _lastCellMenuTypeClicked = ''; constructor(optionProperties: Partial) { this._cellMenuProperties = Utils.extend({}, this._defaults, optionProperties); @@ -247,7 +248,6 @@ export class SlickCellMenu implements SlickPlugin { this._menuElm = this.createMenu(commandItems, optionItems); this._menuElm.style.top = `${e.pageY + 5}px`; this._menuElm.style.left = `${e.pageX}px`; - this._menuElm.style.display = 'block'; document.body.appendChild(this._menuElm); @@ -421,11 +421,25 @@ export class SlickCellMenu implements SlickPlugin { } } + 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); + this._subMenuElms.push(subMenuElm); + 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 */ - repositionMenu(menuElm: HTMLElement, e: DOMMouseOrTouchEvent) { + repositionMenu(e: DOMMouseOrTouchEvent, menuElm: HTMLElement) { const isSubMenu = menuElm.classList.contains('slick-submenu'); const parentElm = isSubMenu ? e.target.closest('.slick-cell-menu-item') as HTMLDivElement @@ -437,7 +451,7 @@ export class SlickCellMenu implements SlickPlugin { let menuOffsetTop = parentElm ? parentOffset?.top ?? 0 : e?.pageY ?? 0; const parentCellWidth = parentElm?.offsetWidth || 0; const menuHeight = menuElm?.offsetHeight ?? 0; - const menuWidth = menuElm?.offsetWidth ?? this._cellMenuProperties.width ?? 0; + const menuWidth = Number(menuElm?.offsetWidth ?? this._cellMenuProperties.width ?? 0); const rowHeight = this._gridOptions.rowHeight; const dropOffset = Number(this._cellMenuProperties.autoAdjustDropOffset || 0); const sideOffset = Number(this._cellMenuProperties.autoAlignSideOffset || 0); @@ -475,7 +489,7 @@ export class SlickCellMenu implements SlickPlugin { // to simulate an align left, we actually need to know the width of the drop menu if (this._cellMenuProperties.autoAlignSide) { const gridPos = this._grid.getGridPosition(); - const subMenuPosCalc = menuOffsetLeft + parentElm.clientWidth + Number(menuWidth); // calculate coordinate at caller element far right + const subMenuPosCalc = menuOffsetLeft + parentElm.clientWidth + menuWidth; // calculate coordinate at caller element far right const browserWidth = document.documentElement.clientWidth; const dropSide = (subMenuPosCalc >= gridPos.width || subMenuPosCalc >= browserWidth) ? 'left' : 'right'; if (dropSide === 'left') { @@ -484,7 +498,7 @@ export class SlickCellMenu implements SlickPlugin { if (isSubMenu) { menuOffsetLeft -= menuWidth - sideOffset; } else { - menuOffsetLeft -= Number(menuWidth) - parentCellWidth - sideOffset; + menuOffsetLeft -= menuWidth - parentCellWidth - sideOffset; } } else { menuElm.classList.remove('dropleft'); @@ -533,7 +547,7 @@ export class SlickCellMenu implements SlickPlugin { // reposition the menu to where the user clicked if (this._menuElm) { - this.repositionMenu(this._menuElm, e); + this.repositionMenu(e, this._menuElm); this._menuElm.setAttribute('aria-expanded', 'true'); this._menuElm.style.display = 'block'; } @@ -562,7 +576,7 @@ export class SlickCellMenu implements SlickPlugin { /** Build the Command Items section. */ protected populateCommandOrOptionItems( - itemType: CellMenuType, + itemType: MenuType, cellMenu: CellMenuOption, commandOrOptionMenuElm: HTMLElement, commandOrOptionItems: Array | Array, @@ -572,7 +586,7 @@ export class SlickCellMenu implements SlickPlugin { return; } - // user could pass a title on top of the Commands section + // user could pass a title on top of the Commands/Options section const isSubMenu = args.level > 0; if (cellMenu?.[`${itemType}Title`] && !isSubMenu) { this[`_${itemType}TitleElm`] = document.createElement('div'); @@ -595,7 +609,7 @@ export class SlickCellMenu implements SlickPlugin { } // 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/option clicked event + // so that "handleMenuItemClick" has the correct flag and won't trigger a command/option clicked event if (Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { (item as MenuCommandItem | MenuOptionItem).disabled = isItemUsable ? false : true; } @@ -673,21 +687,7 @@ export class SlickCellMenu implements SlickPlugin { } } - protected repositionSubMenu(item: MenuCommandItem | MenuOptionItem | 'divider', type: CellMenuType, 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._lastCellMenuTypeClicked !== 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); - this._subMenuElms.push(subMenuElm); - subMenuElm.style.display = 'block'; - document.body.appendChild(subMenuElm); - this.repositionMenu(subMenuElm, e); - } - - protected handleMenuItemClick(item: T | 'divider', type: CellMenuType, level = 0, e: DOMMouseOrTouchEvent) { + 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; @@ -726,7 +726,7 @@ export class SlickCellMenu implements SlickPlugin { } else { this.destroySubMenus(); } - this._lastCellMenuTypeClicked = type; + this._lastMenuTypeClicked = type; } } diff --git a/src/plugins/slick.contextmenu.ts b/src/plugins/slick.contextmenu.ts index 2cbc013c4..9ad1020b9 100644 --- a/src/plugins/slick.contextmenu.ts +++ b/src/plugins/slick.contextmenu.ts @@ -6,6 +6,7 @@ import { Utils as Utils_ } from '../slick.core'; import type { + Column, ContextMenuOption, DOMMouseOrTouchEvent, GridOption, @@ -14,6 +15,7 @@ import type { MenuFromCellCallbackArgs, MenuOptionItem, MenuOptionItemCallbackArgs, + MenuType, SlickPlugin } from '../models/index'; import type { SlickGrid } from '../slick.grid'; @@ -88,6 +90,7 @@ const Utils = IIFE_ONLY ? Slick.Utils : Utils_; * 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) * * * Available menu Command/Option item properties: @@ -98,6 +101,8 @@ const Utils = IIFE_ONLY ? Slick.Utils : Utils_; * 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. @@ -173,7 +178,9 @@ export class SlickContextMenu implements SlickPlugin { protected _handler = new EventHandler(); protected _commandTitleElm?: HTMLSpanElement; protected _optionTitleElm?: HTMLSpanElement; + protected _lastMenuTypeClicked = ''; protected _menuElm?: HTMLDivElement | null; + protected _subMenuElms: HTMLDivElement[] = []; protected _bindingEventService = new BindingEventService(); protected _defaults: ContextMenuOption = { autoAdjustDrop: true, // dropup/dropdown @@ -227,14 +234,13 @@ export class SlickContextMenu implements SlickPlugin { this._menuElm = null as any; } - protected createMenu(evt: SlickEventData_ | MouseEvent) { + 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 dataContext = this._grid.getDataItem(this._currentRow); const isColumnOptionAllowed = this.checkIsColumnAllowed(this._contextMenuProperties.optionShownOverColumnIds ?? [], columnDef.id); const isColumnCommandAllowed = this.checkIsColumnAllowed(this._contextMenuProperties.commandShownOverColumnIds ?? [], columnDef.id); @@ -259,34 +265,71 @@ export class SlickContextMenu implements SlickPlugin { 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`; - this._menuElm = document.createElement('div'); - this._menuElm.className = `slick-context-menu ${this._gridUid}`; - this._menuElm.role = 'menu'; + const menuClasses = `slick-context-menu ${this._gridUid} slick-menu-level-${level}`; + const bodyMenuElm = document.body.querySelector(`.slick-context-menu.${this._gridUid}.slick-menu-level-${level}`); + + // if menu/sub-menu already exist, then no need to recreate, just return it + if (bodyMenuElm) { + return bodyMenuElm; + } + + const menuElm = document.createElement('div'); + menuElm.className = menuClasses; + if (level > 0) { + menuElm.classList.add('slick-submenu'); + } + menuElm.role = 'menu'; if (width) { - this._menuElm.style.width = width as string; + menuElm.style.width = width as string; } if (maxHeight) { - this._menuElm.style.maxHeight = maxHeight as string; + menuElm.style.maxHeight = maxHeight as string; } - this._menuElm.style.top = `${targetEvent.pageY}px`; - this._menuElm.style.left = `${targetEvent.pageX}px`; - this._menuElm.style.display = 'none'; - const 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.innerHTML = '×'; - closeButtonElm.appendChild(spanCloseElm); + 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.innerHTML = '×'; + closeButtonElm.appendChild(spanCloseElm); + } // -- Option List section if (!this._contextMenuProperties.hideOptionSection && isColumnOptionAllowed && optionItems.length > 0) { @@ -294,17 +337,23 @@ export class SlickContextMenu implements SlickPlugin { optionMenuElm.className = 'slick-context-menu-option-list'; optionMenuElm.role = 'menu'; - if (!this._contextMenuProperties.hideCloseButton) { + // 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); - this._menuElm.appendChild(closeButtonElm); + menuElm.appendChild(closeButtonElm); } - this._menuElm.appendChild(optionMenuElm); + menuElm.appendChild(optionMenuElm); - this.populateOptionItems( + this.populateCommandOrOptionItems( + 'option', this._contextMenuProperties, optionMenuElm, optionItems, - { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid } + { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid, level } ); } @@ -314,32 +363,44 @@ export class SlickContextMenu implements SlickPlugin { commandMenuElm.className = 'slick-context-menu-command-list'; commandMenuElm.role = 'menu'; - if (!this._contextMenuProperties.hideCloseButton && (!isColumnOptionAllowed || optionItems.length === 0 || this._contextMenuProperties.hideOptionSection)) { + // 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); - this._menuElm.appendChild(closeButtonElm); + menuElm.appendChild(closeButtonElm); } - this._menuElm.appendChild(commandMenuElm); - this.populateCommandItems( + menuElm.appendChild(commandMenuElm); + this.populateCommandOrOptionItems( + 'command', this._contextMenuProperties, commandMenuElm, commandItems, - { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid } + { cell: this._currentCell, row: this._currentRow, column: columnDef, dataContext, grid: this._grid, level } ); } - this._menuElm.style.display = 'block'; - document.body.appendChild(this._menuElm); + // increment level for possible next sub-menus if exists + level++; - if (this.onAfterMenuShow.notify({ - cell: this._currentCell, - row: this._currentRow, - grid: this._grid - }, e, this).getReturnValue() === false) { - return; - } + return menuElm; + } - return this._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(...subMenuTitleClass.split(' ')); + } + + commandOrOptionMenu.appendChild(subMenuTitleElm); + } } protected handleCloseButtonClicked(e: MouseEvent | TouchEvent) { @@ -362,6 +423,18 @@ export class SlickContextMenu implements SlickPlugin { this._menuElm.remove(); this._menuElm = null; } + this.destroySubMenus(); + } + + /** Close and destroy all previously opened sub-menus */ + destroySubMenus() { + if (this._subMenuElms.length) { + let subElm = this._subMenuElms.pop(); + while (subElm) { + subElm.remove(); + subElm = this._subMenuElms.pop(); + } + } } protected checkIsColumnAllowed(columnIds: Array, columnId: number | string) { @@ -402,135 +475,64 @@ export class SlickContextMenu implements SlickPlugin { } // create the DOM element - this._menuElm = this.createMenu(e as MouseEvent); + this._menuElm = this.createParentMenu(e as MouseEvent); // reposition the menu to where the user clicked if (this._menuElm) { - this.repositionMenu(e); + this.repositionMenu(e, this._menuElm); this._menuElm.style.display = 'block'; } - this._bindingEventService.bind(document.body, 'click', (e) => { - if (!e.defaultPrevented) { - this.destroyMenu(e, { cell: this._currentCell, row: this._currentRow }); - } - }); + // Hide the menu on outside click. + this._bindingEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener); } } - /** Construct the Option Items section. */ - protected populateOptionItems(contextMenu: ContextMenuOption, optionMenuElm: HTMLElement, optionItems: Array, args: any) { - if (!args || !optionItems || !contextMenu) { - return; - } - - // user could pass a title on top of the Options section - if (contextMenu?.optionTitle) { - this._optionTitleElm = document.createElement('div'); - this._optionTitleElm.className = 'title'; - this._optionTitleElm.textContent = contextMenu.optionTitle; - optionMenuElm.appendChild(this._optionTitleElm); - } - - for (let i = 0, ln = optionItems.length; i < ln; i++) { - let addClickListener = true; - const item = optionItems[i]; - - // run each override functions to know if the item is visible and usable - const isItemVisible = this.runOverrideFunctionWhenExists((item as MenuOptionItem).itemVisibilityOverride, args); - const isItemUsable = this.runOverrideFunctionWhenExists((item as MenuOptionItem).itemUsabilityOverride, args); - - // if the result is not visible then there's no need to go further - if (!isItemVisible) { - continue; + /** When users click outside the Cell Menu, we will typically close the Cell Menu (and any sub-menus) */ + protected handleBodyMouseDown(e: DOMMouseOrTouchEvent) { + let isMenuClicked = false; + this._subMenuElms.forEach(subElm => { + if (subElm.contains(e.target)) { + isMenuClicked = true; } + }); + if (this._menuElm?.contains(e.target)) { + isMenuClicked = true; + } - // when the override is defined, we need to use its result to update the disabled property - // so that "handleMenuItemOptionClick" has the correct flag and won't trigger an option clicked event - if (Object.prototype.hasOwnProperty.call(item, 'itemUsabilityOverride')) { - (item as MenuOptionItem).disabled = isItemUsable ? false : true; - } - - const liElm = document.createElement('div'); - liElm.className = 'slick-context-menu-item'; - liElm.role = 'menuitem'; - - if ((item as 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 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 MenuOptionItem).hidden) { - liElm.classList.add('slick-context-menu-item-hidden'); - } - - if ((item as MenuOptionItem).cssClass) { - liElm.classList.add(...(item as MenuOptionItem).cssClass!.split(' ')); - } - - if ((item as MenuOptionItem).tooltip) { - liElm.title = (item as MenuOptionItem).tooltip || ''; - } - - const iconElm = document.createElement('div'); - iconElm.role = 'button'; - iconElm.className = 'slick-context-menu-icon'; - - liElm.appendChild(iconElm); - - if ((item as MenuOptionItem).iconCssClass) { - iconElm.classList.add(...(item as MenuOptionItem).iconCssClass!.split(' ')); - } - - if ((item as MenuOptionItem).iconImage) { - iconElm.style.backgroundImage = `url(${(item as MenuOptionItem).iconImage})`; - } - - const textElm = document.createElement('span'); - textElm.className = 'slick-context-menu-content'; - textElm.textContent = (item as MenuOptionItem).title || ''; - - liElm.appendChild(textElm); - - if ((item as MenuOptionItem).textCssClass) { - textElm.classList.add(...(item as MenuOptionItem).textCssClass!.split(' ')); - } - - optionMenuElm.appendChild(liElm); - - if (addClickListener) { - this._bindingEventService.bind(liElm, 'click', this.handleMenuItemOptionClick.bind(this, item) as EventListener); - } + if (this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented) { + this.destroyMenu(e, { cell: this._currentCell, row: this._currentRow }); } } /** Construct the Command Items section. */ - protected populateCommandItems(contextMenu: ContextMenuOption, commandMenuElm: HTMLElement, commandItems: Array, args: any) { - if (!args || !commandItems || !contextMenu) { + 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 section - if (contextMenu?.commandTitle) { - this._commandTitleElm = document.createElement('div'); - this._commandTitleElm.className = 'title'; - this._commandTitleElm.textContent = contextMenu.commandTitle; - commandMenuElm.appendChild(this._commandTitleElm); + // user could pass a title on top of the Commands/Options section + const isSubMenu = args.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 = commandItems.length; i < ln; i++) { + for (let i = 0, ln = commandOrOptionItems.length; i < ln; i++) { let addClickListener = true; - const item = commandItems[i]; + const item = commandOrOptionItems[i]; // run each override functions to know if the item is visible and usable - const isItemVisible = this.runOverrideFunctionWhenExists((item as MenuCommandItem).itemVisibilityOverride, args); - const isItemUsable = this.runOverrideFunctionWhenExists((item as MenuCommandItem).itemUsabilityOverride, args); + 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) { @@ -538,36 +540,36 @@ export class SlickContextMenu implements SlickPlugin { } // 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 + // 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).disabled = isItemUsable ? false : true; + (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).divider || item === 'divider') { + 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).disabled || !isItemUsable) { + 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).hidden) { + if ((item as MenuCommandItem | MenuOptionItem).hidden) { liElm.classList.add('slick-context-menu-item-hidden'); } - if ((item as MenuCommandItem).cssClass) { - liElm.classList.add(...(item as MenuCommandItem).cssClass!.split(' ')); + if ((item as MenuCommandItem | MenuOptionItem).cssClass) { + liElm.classList.add(...(item as MenuCommandItem | MenuOptionItem).cssClass!.split(' ')); } - if ((item as MenuCommandItem).tooltip) { - liElm.title = (item as MenuCommandItem).tooltip || ''; + if ((item as MenuCommandItem | MenuOptionItem).tooltip) { + liElm.title = (item as MenuCommandItem | MenuOptionItem).tooltip || ''; } const iconElm = document.createElement('div'); @@ -575,121 +577,130 @@ export class SlickContextMenu implements SlickPlugin { liElm.appendChild(iconElm); - if ((item as MenuCommandItem).iconCssClass) { - iconElm.classList.add(...(item as MenuCommandItem).iconCssClass!.split(' ')); + if ((item as MenuCommandItem | MenuOptionItem).iconCssClass) { + iconElm.classList.add(...(item as MenuCommandItem | MenuOptionItem).iconCssClass!.split(' ')); } - if ((item as MenuCommandItem).iconImage) { - iconElm.style.backgroundImage = `url(${(item as MenuCommandItem).iconImage})`; + 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).title || ''; + textElm.textContent = (item as MenuCommandItem | MenuOptionItem).title || ''; liElm.appendChild(textElm); - if ((item as MenuCommandItem).textCssClass) { - textElm.classList.add(...(item as MenuCommandItem).textCssClass!.split(' ')); + if ((item as MenuCommandItem | MenuOptionItem).textCssClass) { + textElm.classList.add(...(item as MenuCommandItem | MenuOptionItem).textCssClass!.split(' ')); } - commandMenuElm.appendChild(liElm); + commandOrOptionMenuElm.appendChild(liElm); if (addClickListener) { - this._bindingEventService.bind(liElm, 'click', this.handleMenuItemCommandClick.bind(this, item) as EventListener); + this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item, itemType, args.level) as EventListener); } - } - } - - protected handleMenuItemCommandClick(item: MenuCommandItem | 'divider', e: DOMMouseOrTouchEvent) { - if (!item || (item as MenuCommandItem).disabled || (item as MenuCommandItem).divider) { - return; - } - const command = (item as MenuCommandItem).command || ''; - const row = this._currentRow; - const cell = this._currentCell; - const columnDef = this._grid.getColumns()[cell]; - const dataContext = this._grid.getDataItem(row); - let cellValue; + // 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(...this._contextMenuProperties.subItemChevronClass.split(' ')); + } else { + chevronElm.textContent = '⮞'; // ⮞ or ▸ + } - if (Object.prototype.hasOwnProperty.call(dataContext, columnDef?.field)) { - cellValue = dataContext[columnDef.field]; + liElm.classList.add('slick-submenu-item'); + liElm.appendChild(chevronElm); + continue; + } } + } - if (command !== null && command !== '') { - // user could execute a callback through 2 ways - // via the onCommand event and/or an action callback - const callbackArgs = { - cell, - row, - grid: this._grid, - command, - item: item as MenuCommandItem, - column: columnDef, - dataContext, - value: cellValue - }; - this.onCommand.notify(callbackArgs, e, this); - - // execute action callback when defined - if (typeof (item as MenuCommandItem).action === 'function') { - (item as any).action.call(this, e, callbackArgs); + 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 handleMenuItemOptionClick(item: MenuOptionItem | 'divider', e: DOMMouseOrTouchEvent) { - if ((item as MenuOptionItem).disabled || (item as MenuOptionItem).divider) { - return; - } - if (!this._grid.getEditorLock().commitCurrentEdit()) { - return; + 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(); } - const option = (item as MenuOptionItem).option !== undefined ? (item as MenuOptionItem).option : ''; - const row = this._currentRow; - const cell = this._currentCell; - const columnDef = this._grid.getColumns()[cell]; - const dataContext = this._grid.getDataItem(row); - - if (option !== undefined) { - // user could execute a callback through 2 ways - // via the onOptionSelected event and/or an action callback - const callbackArgs = { - cell, - row, - grid: this._grid, - option, - item: item as MenuOptionItem, - column: columnDef, - dataContext, - }; - this.onOptionSelected.notify(callbackArgs, e, this); - - // execute action callback when defined - if (typeof (item as MenuOptionItem).action === 'function') { - (item as any).action.call(this, e, callbackArgs); - } - } + // 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); + this._subMenuElms.push(subMenuElm); + 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) { - if (this._menuElm && e.target) { - const targetEvent = (e as TouchEvent).touches?.[0] ?? e; - const parentElm = e.target.closest('.slick-cell') as HTMLDivElement; - const parentOffset = (parentElm && Utils.offset(parentElm)); - let menuOffsetLeft = targetEvent.pageX; + 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 = this._menuElm?.offsetHeight || 0; - const menuWidth = this._menuElm?.offsetWidth || this._contextMenuProperties.width || 0; + const menuHeight = menuElm?.offsetHeight || 0; + const menuWidth = Number(menuElm?.offsetWidth || this._contextMenuProperties.width || 0); const rowHeight = this._gridOptions.rowHeight; - const dropOffset = this._contextMenuProperties.autoAdjustDropOffset; - const sideOffset = this._contextMenuProperties.autoAlignSideOffset; + 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 @@ -697,17 +708,25 @@ export class SlickContextMenu implements SlickPlugin { // 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 || 0) - rowHeight!; - const spaceTopRemaining = spaceTop - (dropOffset || 0) + rowHeight!; + const spaceBottomRemaining = spaceBottom + dropOffset - rowHeight!; + const spaceTopRemaining = spaceTop - dropOffset + rowHeight!; const dropPosition = (spaceBottomRemaining < menuHeight && spaceTopRemaining > spaceBottomRemaining) ? 'top' : 'bottom'; if (dropPosition === 'top') { - this._menuElm.classList.remove('dropdown'); - this._menuElm.classList.add('dropup'); - menuOffsetTop = menuOffsetTop - menuHeight - (dropOffset || 0); + menuElm.classList.remove('dropdown'); + menuElm.classList.add('dropup'); + if (isSubMenu) { + menuOffsetTop -= (menuHeight - dropOffset - parentElm.clientHeight); + } else { + menuOffsetTop -= menuHeight - dropOffset; + } } else { - this._menuElm.classList.remove('dropup'); - this._menuElm.classList.add('dropdown'); - menuOffsetTop = menuOffsetTop + rowHeight! + (dropOffset || 0); + menuElm.classList.remove('dropup'); + menuElm.classList.add('dropdown'); + if (isSubMenu) { + menuOffsetTop += dropOffset; + } else { + menuOffsetTop += rowHeight! + dropOffset; + } } } @@ -716,21 +735,27 @@ export class SlickContextMenu implements SlickPlugin { // 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(); - const dropSide = ((menuOffsetLeft + (+menuWidth)) >= gridPos.width) ? 'left' : 'right'; + const subMenuPosCalc = menuOffsetLeft + parentElm.clientWidth + menuWidth; // calculate coordinate at caller element far right + const browserWidth = document.documentElement.clientWidth; + const dropSide = (subMenuPosCalc >= gridPos.width || subMenuPosCalc >= browserWidth) ? 'left' : 'right'; if (dropSide === 'left') { - this._menuElm.classList.remove('dropright'); - this._menuElm.classList.add('dropleft'); - menuOffsetLeft = (menuOffsetLeft - (+menuWidth) - (sideOffset || 0)); + menuElm.classList.remove('dropright'); + menuElm.classList.add('dropleft'); + menuOffsetLeft -= menuWidth - sideOffset; } else { - this._menuElm.classList.remove('dropleft'); - this._menuElm.classList.add('dropright'); - menuOffsetLeft = menuOffsetLeft + (sideOffset || 0); + menuElm.classList.remove('dropleft'); + menuElm.classList.add('dropright'); + if (isSubMenu) { + menuOffsetLeft += sideOffset + parentElm.offsetWidth; + } else { + menuOffsetLeft += sideOffset; + } } } // ready to reposition the menu - this._menuElm.style.top = `${menuOffsetTop}px`; - this._menuElm.style.left = `${menuOffsetLeft}px`; + menuElm.style.top = `${menuOffsetTop}px`; + menuElm.style.left = `${menuOffsetLeft}px`; } } diff --git a/src/styles/slick.contextmenu.scss b/src/styles/slick.contextmenu.scss index 35422ed2c..4ea3f425a 100644 --- a/src/styles/slick.contextmenu.scss +++ b/src/styles/slick.contextmenu.scss @@ -30,7 +30,7 @@ float: right; } -.slick-context-menu .title { +.slick-context-menu .slick-menu-title { font-size: 16px; width: calc(100% - 30px); border-bottom: solid 1px #d6d6d6; @@ -85,6 +85,10 @@ padding: 2px 4px; border: 1px solid transparent; border-radius: 3px; + + .sub-item-chevron { + float: right; + } } .slick-context-menu-item:hover { border-color: silver; From 09a47ee9bbe0701231917c9d73c1e43d6a148fe6 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 19 Oct 2023 10:47:51 -0400 Subject: [PATCH 09/13] chore: fix auto-align to always prefer right align --- cypress/e2e/example-plugin-contextmenu.cy.ts | 20 ++++++++++---------- src/plugins/slick.cellmenu.ts | 5 ++++- src/plugins/slick.contextmenu.ts | 5 ++++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index 6d827a3ab..68b0c600c 100644 --- a/cypress/e2e/example-plugin-contextmenu.cy.ts +++ b/cypress/e2e/example-plugin-contextmenu.cy.ts @@ -285,8 +285,8 @@ describe('Example - Context Menu & Cell Menu', () => { .should('not.exist'); }); - it('should be able to open Cell Menu and click on Export->PDF sub-commands to see 1 cell menu + 1 sub-menu then clicking on PDF should call alert action', () => { - const subCommands = ['PDF', 'Excel']; + it('should be able to open Cell Menu and click on Export->Text sub-commands to see 1 cell menu + 1 sub-menu then clicking on Text should call alert action', () => { + const subCommands = ['Text', 'Excel']; const stub = cy.stub(); cy.on('window:alert', stub); @@ -307,13 +307,13 @@ describe('Example - Context Menu & Cell Menu', () => { cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') .find('.slick-cell-menu-item') - .contains('PDF') + .contains('Text') .click() - .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as PDF')); + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Text')); }); - it('should be able to open Cell Menu and click on Export->Excel-> sub-commands to see 1 cell menu + 1 sub-menu then clicking on PDF should call alert action', () => { - const subCommands1 = ['PDF', 'Excel']; + it('should be able to open Cell Menu and click on Export->Excel-> sub-commands to see 1 cell menu + 1 sub-menu then clicking on Text should call alert action', () => { + const subCommands1 = ['Text', 'Excel']; const subCommands2 = ['Excel (csv)', 'Excel (xls)']; const stub = cy.stub(); cy.on('window:alert', stub); @@ -357,7 +357,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should open Export->Excel sub-menu & open again Sub-Options on top and expect sub-menu to be recreated with that Sub-Options list instead of the Export->Excel list', () => { - const subCommands1 = ['PDF', 'Excel']; + const subCommands1 = ['Text', 'Excel']; const subCommands2 = ['Excel (csv)', 'Excel (xls)']; const subOptions = ['True', 'False']; @@ -653,8 +653,8 @@ describe('Example - Context Menu & Cell Menu', () => { .click(); }); - it('should be able to open Context Menu and click on Export->Excel-> sub-commands to see 1 context menu + 1 sub-menu then clicking on PDF should call alert action', () => { - const subCommands1 = ['PDF', 'Excel']; + it('should be able to open Context Menu and click on Export->Excel-> sub-commands to see 1 context menu + 1 sub-menu then clicking on Text should call alert action', () => { + const subCommands1 = ['Text', 'Excel']; const subCommands2 = ['Excel (csv)', 'Excel (xls)']; const stub = cy.stub(); cy.on('window:alert', stub); @@ -697,7 +697,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should open Export->Excel sub-menu & open again Sub-Options on top and expect sub-menu to be recreated with that Sub-Options list instead of the Export->Excel list', () => { - const subCommands1 = ['PDF', 'Excel']; + const subCommands1 = ['Text', 'Excel']; const subCommands2 = ['Excel (csv)', 'Excel (xls)']; const subOptions = ['Low', 'Medium', 'High']; diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index f348cc605..2f1dcefda 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -489,7 +489,10 @@ export class SlickCellMenu implements SlickPlugin { // to simulate an align left, we actually need to know the width of the drop menu if (this._cellMenuProperties.autoAlignSide) { const gridPos = this._grid.getGridPosition(); - const subMenuPosCalc = menuOffsetLeft + parentElm.clientWidth + menuWidth; // calculate coordinate at caller element far right + 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') { diff --git a/src/plugins/slick.contextmenu.ts b/src/plugins/slick.contextmenu.ts index 9ad1020b9..b8577e6dc 100644 --- a/src/plugins/slick.contextmenu.ts +++ b/src/plugins/slick.contextmenu.ts @@ -735,7 +735,10 @@ export class SlickContextMenu implements SlickPlugin { // 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(); - const subMenuPosCalc = menuOffsetLeft + parentElm.clientWidth + menuWidth; // calculate coordinate at caller element far right + 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') { From 0d94ac1647f65e7a8f99c839accae360601297fb Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 19 Oct 2023 10:55:02 -0400 Subject: [PATCH 10/13] chore: fix auto-align to always prefer right align --- examples/example-plugin-contextmenu.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index 2c11cbc75..701d51e1e 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -294,7 +294,7 @@

View Source:

alert(args.item.title); break; case "export-csv": - case "export-pdf": + case "export-txt": case "export-xls": alert("Exporting as " + args.item.title); break; @@ -366,7 +366,7 @@

View Source:

// we can also have multiple sub-items command: 'export', title: 'Export', commandItems: [ - { command: "export-pdf", title: "PDF" }, + { command: "export-txt", title: "Text" }, { command: 'sub-menu', title: 'Excel', cssClass: "green", subMenuTitle: "available formats", subMenuTitleCssClass: "italic orange", commandItems: [ @@ -449,7 +449,7 @@

View Source:

// we can also have multiple sub-items command: 'export', title: 'Export', commandItems: [ - { command: "export-pdf", title: "PDF" }, + { command: "export-txt", title: "Text" }, { command: 'sub-menu', title: 'Excel', cssClass: "green", subMenuTitle: "available formats", subMenuTitleCssClass: "italic orange", commandItems: [ From 60f0201c7054e764d11d93404921184b6a80319f Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 19 Oct 2023 11:24:30 -0400 Subject: [PATCH 11/13] chore: lower-min-width on sub-menu elements --- examples/example-plugin-contextmenu.html | 5 +++-- src/styles/slick.cellmenu.scss | 3 +++ src/styles/slick.contextmenu.scss | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index 701d51e1e..635b98a07 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -76,12 +76,13 @@ .slick-context-menu { border: 1px solid #718BB7; - box-shadow: 2px 2px 2px silver; } - .slick-submenu { + .slick-cell-menu.slick-submenu, + .slick-context-menu.slick-submenu { background-color: #fbfbfb; /* border-width: 2px; */ box-shadow: 0 2px 4px 2px rgba(146, 152, 163, 0.4); + min-width: 150px; } diff --git a/src/styles/slick.cellmenu.scss b/src/styles/slick.cellmenu.scss index c7c417b36..0108320f2 100644 --- a/src/styles/slick.cellmenu.scss +++ b/src/styles/slick.cellmenu.scss @@ -11,6 +11,9 @@ z-index: 2000; overflow:auto; resize: both; + &.slick-submenu { + min-width: 100px; + } } .slick-cell-menu-button { diff --git a/src/styles/slick.contextmenu.scss b/src/styles/slick.contextmenu.scss index 4ea3f425a..2a0ef68cf 100644 --- a/src/styles/slick.contextmenu.scss +++ b/src/styles/slick.contextmenu.scss @@ -11,6 +11,9 @@ z-index: 2000; overflow:auto; resize: both; + &.slick-submenu { + min-width: 100px; + } } .slick-context-menu-button { From 04fe071d9c880b5141fc6d7fa9c4923335bd6445 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 19 Oct 2023 13:50:51 -0400 Subject: [PATCH 12/13] chore: add more Cypress E2E tests --- cypress/e2e/example-plugin-contextmenu.cy.ts | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index 68b0c600c..29bf3eaf2 100644 --- a/cypress/e2e/example-plugin-contextmenu.cy.ts +++ b/cypress/e2e/example-plugin-contextmenu.cy.ts @@ -312,6 +312,33 @@ describe('Example - Context Menu & Cell Menu', () => { .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Text')); }); + it('should be able to open Cell Menu and click on Export->Text and expect alert triggered with Text Export', () => { + const subCommands1 = ['Text', 'Excel']; + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Text') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Text')); + }); + it('should be able to open Cell Menu and click on Export->Excel-> sub-commands to see 1 cell menu + 1 sub-menu then clicking on Text should call alert action', () => { const subCommands1 = ['Text', 'Excel']; const subCommands2 = ['Excel (csv)', 'Excel (xls)']; @@ -653,6 +680,32 @@ describe('Example - Context Menu & Cell Menu', () => { .click(); }); + it('should be able to open Context Menu and click on Export->Text and expect alert triggered with Text Export', () => { + const subCommands1 = ['Text', 'Excel']; + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(1)') + .rightclick(); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-command-list') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Text') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Text')); + }); + it('should be able to open Context Menu and click on Export->Excel-> sub-commands to see 1 context menu + 1 sub-menu then clicking on Text should call alert action', () => { const subCommands1 = ['Text', 'Excel']; const subCommands2 = ['Excel (csv)', 'Excel (xls)']; From 5bdda806a03e36774fc3cdc622d020218bc14f42 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 20 Oct 2023 16:02:33 -0400 Subject: [PATCH 13/13] chore: fix open different sub-menu tree left previous sub-menu tree open --- cypress/e2e/example-plugin-contextmenu.cy.ts | 127 ++++++++++++++++++- examples/example-plugin-contextmenu.html | 41 +++++- src/plugins/slick.cellmenu.ts | 78 ++++++++---- src/plugins/slick.contextmenu.ts | 76 +++++++---- 4 files changed, 267 insertions(+), 55 deletions(-) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index 29bf3eaf2..35828f931 100644 --- a/cypress/e2e/example-plugin-contextmenu.cy.ts +++ b/cypress/e2e/example-plugin-contextmenu.cy.ts @@ -65,7 +65,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should expect the Context Menu to not have the "Help" menu when there is Effort Driven set to True', () => { - const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)', '', 'Export']; + const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)', '', 'Export', 'Feedback']; cy.get('#myGrid') .find('.slick-row .slick-cell:nth(1)') @@ -84,7 +84,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should expect the Context Menu to not have the "Help" menu when there is Effort Driven set to True', () => { - const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)', '', 'Export']; + const commands = ['Copy Cell Value', 'Delete Row', '', 'Command (always disabled)', '', 'Export', 'Feedback']; cy.get('#myGrid') .find('.slick-row .slick-cell:nth(1)') @@ -240,7 +240,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should expect the Context Menu now have the "Help" menu when Effort Driven is set to False', () => { - const commands = ['Copy Cell Value', 'Delete Row', '', 'Help', 'Command (always disabled)', '', 'Export']; + const commands = ['Copy Cell Value', 'Delete Row', '', 'Help', 'Command (always disabled)', '', 'Export', 'Feedback']; cy.get('#myGrid') .find('.slick-row .slick-cell:nth(1)') @@ -430,6 +430,64 @@ describe('Example - Context Menu & Cell Menu', () => { .each(($option, index) => expect($option.text()).to.eq(subOptions[index])); }); + it('should open Export->Excel sub-menu then open Feedback->ContactUs sub-menus and expect previous Export menu to no longer exists', () => { + const subCommands1 = ['Text', 'Excel']; + const subCommands2 = ['Request update from shipping team', '', 'Contact Us']; + const subCommands2_1 = ['Email us', 'Chat with us', 'Book an appointment']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(7)') + .contains('Action') + .click({ force: true }); + + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-command-list') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-cell-menu.slick-menu-level-0') + .find('.slick-cell-menu-item') + .contains('Feedback') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 1); + cy.get('.slick-cell-menu.slick-menu-level-1') + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2[index])); + + // click on Feedback->ContactUs + cy.get('.slick-cell-menu.slick-menu-level-1.dropleft') // left align + .find('.slick-cell-menu-item') + .contains('Contact Us') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 2); + cy.get('.slick-cell-menu.slick-menu-level-2.dropright') // right align + .should('exist') + .find('.slick-cell-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands2_1[index])); + + cy.get('.slick-cell-menu.slick-menu-level-2'); + + cy.get('.slick-cell-menu.slick-menu-level-2 .slick-cell-menu-command-list') + .find('.slick-cell-menu-item') + .contains('Chat with us') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Command: contact-chat')); + }); + it('should click on the "Show Commands & Priority Options" button and see both list when opening Context Menu', () => { cy.get('button') .contains('Show Commands & Priority Options') @@ -585,7 +643,7 @@ describe('Example - Context Menu & Cell Menu', () => { }); it('should click on the "Show Action Commands Only" button and see both list when opening Cell Menu', () => { - const commands = ['Command 1', 'Command 2', 'Delete Row', '', 'Help', 'Disabled Command', '', 'Export']; + const commands = ['Command 1', 'Command 2', 'Delete Row', '', 'Help', 'Disabled Command', '', 'Export', 'Feedback']; cy.get('button') .contains('Show Action Commands Only') @@ -798,4 +856,63 @@ describe('Example - Context Menu & Cell Menu', () => { .find('.slick-context-menu-item') .each(($option, index) => expect($option.text()).to.contain(subOptions[index])); }); -}); + + it('should open Export->Excel context sub-menu then open Feedback->ContactUs sub-menus and expect previous Export menu to no longer exists', () => { + const subCommands1 = ['Text', 'Excel']; + const subCommands2 = ['Request update from shipping team', '', 'Contact Us']; + const subCommands2_1 = ['Email us', 'Chat with us', 'Book an appointment']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('.slick-row .slick-cell:nth(2)') + .rightclick(); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Export') + .click(); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-context-menu-command-list') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-context-menu.slick-menu-level-0') + .find('.slick-context-menu-item') + .contains('Feedback') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 1); + cy.get('.slick-context-menu.slick-menu-level-1') + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2[index])); + + // click on Feedback->ContactUs + cy.get('.slick-context-menu.slick-menu-level-1.dropright') // right align + .find('.slick-context-menu-item') + .contains('Contact Us') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 2); + cy.get('.slick-context-menu.slick-menu-level-2.dropleft') // left align + .should('exist') + .find('.slick-context-menu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands2_1[index])); + + cy.get('.slick-context-menu.slick-menu-level-2'); + + cy.get('.slick-context-menu.slick-menu-level-2 .slick-context-menu-command-list') + .find('.slick-context-menu-item') + .contains('Chat with us') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Command: contact-chat')); + + cy.get('.slick-submenu').should('have.length', 0); + }); +}); \ No newline at end of file diff --git a/examples/example-plugin-contextmenu.html b/examples/example-plugin-contextmenu.html index 635b98a07..2ddbb1c4d 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -310,6 +310,9 @@

View Source:

dataView.deleteItem(dataContext.id); } break; + default: + alert("Command: " + args.command); + break; } } @@ -364,7 +367,7 @@

View Source:

{ command: "something", title: "Disabled Command", disabled: true }, "divider", { - // we can also have multiple sub-items + // we can also have multiple nested sub-menus command: 'export', title: 'Export', commandItems: [ { command: "export-txt", title: "Text" }, @@ -376,6 +379,21 @@

View Source:

] } ] + }, + { + command: 'feedback', title: 'Feedback', + commandItems: [ + { command: "request-update", title: "Request update from shipping team", iconCssClass: "sgi sgi-star", tooltip: "this will automatically send an alert to the shipping team to contact the user for an update" }, + "divider", + { + command: 'sub-menu', title: 'Contact Us', iconCssClass: "sgi sgi-user", subMenuTitle: "contact us...", subMenuTitleCssClass: "italic", + commandItems: [ + { command: "contact-email", title: "Email us", iconCssClass: "sgi sgi-pencil-outline" }, + { command: "contact-chat", title: "Chat with us", iconCssClass: "sgi sgi-message-outline" }, + { command: "contact-meeting", title: "Book an appointment", iconCssClass: "sgi sgi-coffee-outline" }, + ] + } + ] } ], optionTitle: "Change Effort Driven", @@ -398,7 +416,7 @@

View Source:

} }, { - // we can also have multiple sub-items + // we can also have multiple nested sub-menus option: null, title: "Sub-Options (demo)", subMenuTitle: "Set Effort Driven", optionItems: [ { option: true, title: "True", iconCssClass: 'sgi sgi-checkbox-marked-outline green' }, { option: false, title: "False", iconCssClass: 'sgi sgi-checkbox-blank-outline pink' }, @@ -447,7 +465,7 @@

View Source:

{ command: "something", title: "Command (always disabled)", disabled: true }, "divider", { - // we can also have multiple sub-items + // we can also have multiple nested sub-menus command: 'export', title: 'Export', commandItems: [ { command: "export-txt", title: "Text" }, @@ -459,6 +477,21 @@

View Source:

] } ] + }, + { + command: 'feedback', title: 'Feedback', + commandItems: [ + { command: "column-love", title: "Request update from shipping team", iconCssClass: "sgi sgi-tag-outline", tooltip: "this will automatically send an alert to the shipping team to contact the user for an update" }, + "divider", + { + command: 'sub-menu', title: 'Contact Us', iconCssClass: "sgi sgi-user", subMenuTitle: "contact us...", subMenuTitleCssClass: "italic", + commandItems: [ + { command: "contact-email", title: "Email us", iconCssClass: "sgi sgi-pencil-outline" }, + { command: "contact-chat", title: "Chat with us", iconCssClass: "sgi sgi-message-outline" }, + { command: "contact-meeting", title: "Book an appointment", iconCssClass: "sgi sgi-coffee-outline" }, + ] + } + ] } ], @@ -494,7 +527,7 @@

View Source:

}, "divider", { - // we can also have multiple sub-items + // we can also have multiple nested sub-menus option: null, title: "Sub-Options (demo)", subMenuTitle: "Set Priority", optionItems: [ { option: 1, iconCssClass: "sgi sgi-star-outline", title: "Low" }, { option: 2, iconCssClass: "sgi sgi-star orange", title: "Medium" }, diff --git a/src/plugins/slick.cellmenu.ts b/src/plugins/slick.cellmenu.ts index 2f1dcefda..97c360197 100644 --- a/src/plugins/slick.cellmenu.ts +++ b/src/plugins/slick.cellmenu.ts @@ -162,6 +162,7 @@ export class SlickCellMenu implements SlickPlugin { // -- // protected props + protected _bindingEventService = new BindingEventService(); protected _cellMenuProperties: CellMenuOption; protected _currentCell = -1; protected _currentRow = -1; @@ -173,8 +174,7 @@ export class SlickCellMenu implements SlickPlugin { protected _optionTitleElm?: HTMLSpanElement; protected _lastMenuTypeClicked = ''; protected _menuElm?: HTMLDivElement | null; - protected _subMenuElms: HTMLDivElement[] = []; - protected _bindingEventService = new BindingEventService(); + protected _subMenuParentId = ''; protected _defaults: CellMenuOption = { autoAdjustDrop: true, // dropup/dropdown autoAlignSide: true, // left/right @@ -270,7 +270,7 @@ export class SlickCellMenu implements SlickPlugin { * @param item - command, option or divider * @returns menu DOM element */ - protected createMenu(commandItems: Array, optionItems: Array, level = 0, item?: MenuCommandItem | MenuOptionItem | 'divider') { + 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); @@ -278,19 +278,38 @@ export class SlickCellMenu implements SlickPlugin { const maxHeight = isNaN(this._cellMenuProperties.maxHeight as number) ? this._cellMenuProperties.maxHeight : `${this._cellMenuProperties.maxHeight ?? 0}px`; const width = isNaN(this._cellMenuProperties.width as number) ? this._cellMenuProperties.width : `${this._cellMenuProperties.maxWidth ?? 0}px`; - const menuClasses = `slick-cell-menu ${this._gridUid} slick-menu-level-${level}`; - const bodyMenuElm = document.body.querySelector(`.slick-cell-menu.${this._gridUid}.slick-menu-level-${level}`); + // 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-cell-menu slick-menu-level-${level} ${this._gridUid}`; + const bodyMenuElm = document.body.querySelector(`.slick-cell-menu.slick-menu-level-${level}${this.getGridUidSelector()}`); - // if menu/sub-menu already exist, then no need to recreate, just return it + // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all if (bodyMenuElm) { - return 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' : 'Cell Menu'; menuElm.role = 'menu'; if (width) { menuElm.style.width = width as string; @@ -374,7 +393,7 @@ export class SlickCellMenu implements SlickPlugin { return menuElm; } - protected addSubMenuTitleWhenExists(item: MenuCommandItem | MenuOptionItem | 'divider', commandOrOptionMenu: HTMLDivElement) { + protected addSubMenuTitleWhenExists(item: MenuCommandItem | MenuOptionItem | 'divider', commandOrOptionMenu: HTMLDivElement) { if (item !== 'divider' && item?.subMenuTitle) { const subMenuTitleElm = document.createElement('div'); subMenuTitleElm.className = 'slick-menu-title'; @@ -410,18 +429,20 @@ export class SlickCellMenu implements SlickPlugin { this.destroySubMenus(); } + /** Destroy all parent menus and any sub-menus */ + destroyAllMenus() { + this.destroySubMenus(); + document.querySelectorAll(`.slick-cell-menu${this.getGridUidSelector()}`) + .forEach(subElm => subElm.remove()); + } + /** Close and destroy all previously opened sub-menus */ destroySubMenus() { - if (this._subMenuElms.length) { - let subElm = this._subMenuElms.pop(); - while (subElm) { - subElm.remove(); - subElm = this._subMenuElms.pop(); - } - } + document.querySelectorAll(`.slick-cell-menu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => subElm.remove()); } - protected repositionSubMenu(item: MenuCommandItem | MenuOptionItem | 'divider', type: MenuType, level: number, e: DOMMouseOrTouchEvent) { + 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(); @@ -429,7 +450,6 @@ export class SlickCellMenu implements SlickPlugin { // 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); - this._subMenuElms.push(subMenuElm); subMenuElm.style.display = 'block'; document.body.appendChild(subMenuElm); this.repositionMenu(e, subMenuElm); @@ -520,10 +540,16 @@ export class SlickCellMenu implements SlickPlugin { } } + protected getGridUidSelector() { + const gridUid = this._grid.getUID() || ''; + return gridUid ? `.${gridUid}` : ''; + } + protected handleCellClick(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; - const cell = this._grid.getCellFromEvent(e); + if (cell) { const dataContext = this._grid.getDataItem(cell.row); const columnDef = this._grid.getColumns()[cell.cell]; @@ -562,15 +588,20 @@ export class SlickCellMenu implements SlickPlugin { /** 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; - this._subMenuElms.forEach(subElm => { - if (subElm.contains(e.target)) { - isMenuClicked = true; - } - }); if (this._menuElm?.contains(e.target)) { isMenuClicked = true; } + if (!isMenuClicked) { + document + .querySelectorAll(`.slick-cell-menu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => { + if (subElm.contains(e.target)) { + isMenuClicked = true; + } + }); + } if (this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented) { this.closeMenu(e, { cell: this._currentCell, row: this._currentRow, grid: this._grid }); @@ -721,6 +752,7 @@ export class SlickCellMenu implements SlickPlugin { (item as any).action.call(this, e, callbackArgs); } + // unless prevented, close the menu if (!e.defaultPrevented) { this.closeMenu(e, { cell, row, grid: this._grid }); } diff --git a/src/plugins/slick.contextmenu.ts b/src/plugins/slick.contextmenu.ts index b8577e6dc..182f312a9 100644 --- a/src/plugins/slick.contextmenu.ts +++ b/src/plugins/slick.contextmenu.ts @@ -169,6 +169,7 @@ export class SlickContextMenu implements SlickPlugin { // -- // protected props + protected _bindingEventService = new BindingEventService(); protected _contextMenuProperties: ContextMenuOption; protected _currentCell = -1; protected _currentRow = -1; @@ -180,8 +181,7 @@ export class SlickContextMenu implements SlickPlugin { protected _optionTitleElm?: HTMLSpanElement; protected _lastMenuTypeClicked = ''; protected _menuElm?: HTMLDivElement | null; - protected _subMenuElms: HTMLDivElement[] = []; - protected _bindingEventService = new BindingEventService(); + protected _subMenuParentId = ''; protected _defaults: ContextMenuOption = { autoAdjustDrop: true, // dropup/dropdown autoAlignSide: true, // left/right @@ -293,19 +293,38 @@ export class SlickContextMenu implements SlickPlugin { 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`; - const menuClasses = `slick-context-menu ${this._gridUid} slick-menu-level-${level}`; - const bodyMenuElm = document.body.querySelector(`.slick-context-menu.${this._gridUid}.slick-menu-level-${level}`); + // 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()}`); - // if menu/sub-menu already exist, then no need to recreate, just return it + // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all if (bodyMenuElm) { - return 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; @@ -389,7 +408,7 @@ export class SlickContextMenu implements SlickPlugin { return menuElm; } - protected addSubMenuTitleWhenExists(item: MenuCommandItem | MenuOptionItem | 'divider', commandOrOptionMenu: HTMLDivElement) { + protected addSubMenuTitleWhenExists(item: MenuCommandItem | MenuOptionItem | 'divider', commandOrOptionMenu: HTMLDivElement) { if (item !== 'divider' && item?.subMenuTitle) { const subMenuTitleElm = document.createElement('div'); subMenuTitleElm.className = 'slick-menu-title'; @@ -410,7 +429,7 @@ export class SlickContextMenu implements SlickPlugin { } destroyMenu(e?: Event, args?: { cell: number; row: number; }) { - this._menuElm = this._menuElm || document.querySelector(`.slick-context-menu.${this._gridUid}`); + this._menuElm = this._menuElm || document.querySelector(`.slick-context-menu${this.getGridUidSelector()}`); if (this._menuElm?.remove) { if (this.onBeforeMenuClose.notify({ @@ -426,15 +445,17 @@ export class SlickContextMenu implements SlickPlugin { this.destroySubMenus(); } + /** Destroy all parent menus and any sub-menus */ + destroyAllMenus() { + this.destroySubMenus(); + document.querySelectorAll(`.slick-context-menu${this.getGridUidSelector()}`) + .forEach(subElm => subElm.remove()); + } + /** Close and destroy all previously opened sub-menus */ destroySubMenus() { - if (this._subMenuElms.length) { - let subElm = this._subMenuElms.pop(); - while (subElm) { - subElm.remove(); - subElm = this._subMenuElms.pop(); - } - } + document.querySelectorAll(`.slick-context-menu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => subElm.remove()); } protected checkIsColumnAllowed(columnIds: Array, columnId: number | string) { @@ -452,13 +473,18 @@ export class SlickContextMenu implements SlickPlugin { 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); @@ -490,15 +516,20 @@ export class SlickContextMenu implements SlickPlugin { /** 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; - this._subMenuElms.forEach(subElm => { - if (subElm.contains(e.target)) { - isMenuClicked = true; - } - }); 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 }); @@ -667,7 +698,7 @@ export class SlickContextMenu implements SlickPlugin { } } - protected repositionSubMenu(item: MenuCommandItem | MenuOptionItem | 'divider', type: MenuType, level: number, e: DOMMouseOrTouchEvent) { + 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(); @@ -675,7 +706,6 @@ export class SlickContextMenu implements SlickPlugin { // 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); - this._subMenuElms.push(subMenuElm); subMenuElm.style.display = 'block'; document.body.appendChild(subMenuElm); this.repositionMenu(e, subMenuElm);