From 0309ec47470ba61b47eb22bf82f253d0c733171f Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Fri, 20 Oct 2023 16:14:55 -0400 Subject: [PATCH] feat: add sub-menu(s) to CellMenu & ContextMenu plugins (#867) * 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 | 534 +++++++++++++++- examples/example-plugin-contextmenu.html | 106 +++- src/models/cellMenuOption.interface.ts | 3 + src/models/contextMenuOption.interface.ts | 3 + src/models/menuCommandItem.interface.ts | 3 + src/models/menuItem.interface.ts | 8 + src/models/menuOptionItem.interface.ts | 3 + src/plugins/slick.cellmenu.ts | 607 ++++++++++--------- src/plugins/slick.contextmenu.ts | 574 ++++++++++-------- src/styles/slick.cellmenu.scss | 9 +- src/styles/slick.contextmenu.scss | 9 +- 11 files changed, 1292 insertions(+), 567 deletions(-) diff --git a/cypress/e2e/example-plugin-contextmenu.cy.ts b/cypress/e2e/example-plugin-contextmenu.cy.ts index a2cced80b..35828f931 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', () => { @@ -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', 'Feedback']; 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', 'Feedback']; 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'); }); }); @@ -147,11 +147,79 @@ 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.slick-menu-level-0 .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('Command 2') + .click() + .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.slick-menu-level-0 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('Sub-Options') + .click(); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list').as('subMenuList'); + + cy.get('@subMenuList') + .find('.slick-menu-title') + .contains('Set Effort Driven'); + + cy.get('@subMenuList') + .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.slick-menu-level-0 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('Sub-Options') + .click(); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-cell-menu-option-list') .find('.slick-cell-menu-item') .contains('False') .click(); @@ -161,6 +229,10 @@ describe('Example - Context Menu & Cell Menu', () => { .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() @@ -168,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', 'Feedback']; cy.get('#myGrid') .find('.slick-row .slick-cell:nth(1)') @@ -180,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(); @@ -213,6 +285,209 @@ describe('Example - Context Menu & Cell Menu', () => { .should('not.exist'); }); + 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); + + 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.eq(subCommands[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->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)']; + 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.eq(subCommands1[index])); + + 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.slick-menu-level-2 .slick-cell-menu-command-list').as('subMenuList2'); + + cy.get('@subMenuList2') + .find('.slick-menu-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.slick-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 = ['Text', '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.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.eq(subCommands1[index])); + + 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.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.slick-menu-level-0 .slick-cell-menu-option-list') + .find('.slick-cell-menu-item') + .contains('Sub-Options') + .click(); + + 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])); + }); + + 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') @@ -236,7 +511,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(); @@ -245,15 +520,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', () => { @@ -320,8 +642,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', 'Feedback']; cy.get('button') .contains('Show Action Commands Only') @@ -415,4 +737,182 @@ 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->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)']; + 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 = ['Text', '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])); + }); + + 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 9a2a9c8b0..2ddbb1c4d 100644 --- a/examples/example-plugin-contextmenu.html +++ b/examples/example-plugin-contextmenu.html @@ -76,7 +76,13 @@ .slick-context-menu { border: 1px solid #718BB7; - box-shadow: 2px 2px 2px silver; + } + .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; } @@ -84,10 +90,10 @@ - -
+
+

@@ -285,10 +291,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-txt": + case "export-xls": + alert("Exporting as " + args.item.title); break; case "copy-text": copyCellValue(args.value); @@ -301,6 +310,9 @@

View Source:

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

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 nested sub-menus + command: 'export', title: 'Export', + commandItems: [ + { command: "export-txt", title: "Text" }, + { + 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)" }, + ] + } + ] + }, + { + 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", optionItems: [ @@ -373,6 +415,13 @@

View Source:

return (!args.dataContext.effortDriven); } }, + { + // 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' }, + ] + } ] } } @@ -388,6 +437,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) { @@ -412,6 +463,36 @@

View Source:

} }, { command: "something", title: "Command (always disabled)", disabled: true }, + "divider", + { + // we can also have multiple nested sub-menus + command: 'export', title: 'Export', + commandItems: [ + { command: "export-txt", title: "Text" }, + { + 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)" }, + ] + } + ] + }, + { + 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" }, + ] + } + ] + } ], // Options allows you to edit a column from an option chose a list @@ -444,13 +525,22 @@

View Source:

return (!args.dataContext.effortDriven); } }, + "divider", + { + // 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" }, + { option: 3, iconCssClass: "sgi sgi-star red", title: "High" }, + ] + } ] }; 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); 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/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/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/menuItem.interface.ts b/src/models/menuItem.interface.ts index aead35319..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; @@ -22,6 +24,12 @@ 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; + + /** 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/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..97c360197 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, @@ -14,6 +15,7 @@ import type { MenuFromCellCallbackArgs, MenuOptionItem, MenuOptionItemCallbackArgs, + MenuType, SlickPlugin } from '../models/index'; import type { SlickGrid } from '../slick.grid'; @@ -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: @@ -91,6 +94,8 @@ const Utils = IIFE_ONLY ? Slick.Utils : Utils_; * 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. @@ -157,6 +162,7 @@ export class SlickCellMenu implements SlickPlugin { // -- // protected props + protected _bindingEventService = new BindingEventService(); protected _cellMenuProperties: CellMenuOption; protected _currentCell = -1; protected _currentRow = -1; @@ -166,8 +172,9 @@ export class SlickCellMenu implements SlickPlugin { protected _handler = new EventHandler(); protected _commandTitleElm?: HTMLSpanElement; protected _optionTitleElm?: HTMLSpanElement; + protected _lastMenuTypeClicked = ''; protected _menuElm?: HTMLDivElement | null; - protected _bindingEventService = new BindingEventService(); + protected _subMenuParentId = ''; protected _defaults: CellMenuOption = { autoAdjustDrop: true, // dropup/dropdown autoAlignSide: true, // left/right @@ -188,7 +195,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)); } } @@ -210,12 +217,11 @@ 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; 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 +232,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 +244,96 @@ export class SlickCellMenu implements SlickPlugin { return; } + // create 1st parent menu container & reposition it + 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); + + if (this.onAfterMenuShow.notify({ + cell: this._currentCell, + row: this._currentRow, + grid: this._grid + }, e, this).getReturnValue() === false) { + return; + } + + return this._menuElm; + } + + /** + * 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 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); + // 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'; + // 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()}`); + + // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all + if (bodyMenuElm) { + if (bodyMenuElm.dataset.subMenuParent === subMenuId) { + return bodyMenuElm; + } + this.destroySubMenus(); + } + + const menuElm = document.createElement('div'); + menuElm.className = menuClasses; + if (level > 0) { + menuElm.classList.add('slick-submenu'); + if (subMenuId) { + menuElm.dataset.subMenuParent = subMenuId; + } + } + menuElm.ariaLabel = level > 1 ? 'SubMenu' : 'Cell 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 +341,23 @@ export class SlickCellMenu implements SlickPlugin { optionMenuElm.className = 'slick-cell-menu-option-list'; optionMenuElm.role = 'menu'; - if (!this._cellMenuProperties.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._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 +367,55 @@ 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)) { + // 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); - 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); + // 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: 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, @@ -339,26 +424,57 @@ export class SlickCellMenu implements SlickPlugin { return; } this._menuElm.remove(); - this._menuElm = null as any; + this._menuElm = null; } + 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() { + 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) { + // when we're clicking a grid cell OR our last menu type (command/option) differs then we know that we need to start fresh and close any sub-menus that might still be open + if (e.target.classList.contains('slick-cell') || this._lastMenuTypeClicked !== type) { + this.destroySubMenus(); + } + + // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show + const subMenuElm = this.createMenu((item as MenuCommandItem)?.commandItems || [], (item as MenuOptionItem)?.optionItems || [], level + 1, item); + subMenuElm.style.display = 'block'; + document.body.appendChild(subMenuElm); + this.repositionMenu(e, subMenuElm); } /** * Reposition the menu drop (up/down) and the side (left/right) * @param {*} event */ - 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(e: DOMMouseOrTouchEvent, menuElm: HTMLElement) { + 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; + + 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 = Number(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 @@ -370,13 +486,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 (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; + menuElm.classList.remove('dropup'); + menuElm.classList.add('dropdown'); + if (isSubMenu) { + menuOffsetTop += dropOffset; + } else { + menuOffsetTop += rowHeight! + dropOffset; + } } } @@ -385,28 +509,47 @@ 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'; + 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') { - this._menuElm.classList.remove('dropright'); - this._menuElm.classList.add('dropleft'); - menuOffsetLeft = (menuOffsetLeft - (+menuWidth - parentCellWidth) - sideOffset); + menuElm.classList.remove('dropright'); + menuElm.classList.add('dropleft'); + if (isSubMenu) { + 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 (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`; } } + 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]; @@ -429,11 +572,11 @@ 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) { - this.repositionMenu(e); + this.repositionMenu(e, this._menuElm); this._menuElm.setAttribute('aria-expanded', 'true'); this._menuElm.style.display = 'block'; } @@ -443,140 +586,56 @@ 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 }); - } - } - } - - 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; + // did we click inside the menu or any of its sub-menu(s) + let isMenuClicked = false; + if (this._menuElm?.contains(e.target)) { + isMenuClicked = true; } - } - - /** Construct the Option Items section. */ - protected populateOptionItems(cellMenu: CellMenuOption, optionMenuElm: HTMLElement, optionItems: Array, args: any) { - if (!args || !optionItems || !cellMenu) { - return; + if (!isMenuClicked) { + document + .querySelectorAll(`.slick-cell-menu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => { + if (subElm.contains(e.target)) { + isMenuClicked = true; + } + }); } - // 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); - } - - 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 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-cell-menu-item'; - liElm.role = 'menuitem'; - - if ((item as 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) { - liElm.classList.add('slick-cell-menu-item-disabled'); - } - - // if the item is hidden then add the hidden css class - if ((item as 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 MenuOptionItem).tooltip) { - liElm.title = (item as MenuOptionItem).tooltip || ''; - } - - const iconElm = document.createElement('div'); - iconElm.className = 'slick-cell-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-cell-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.closeMenu(e, { cell: this._currentCell, row: this._currentRow, grid: this._grid }); } } - /** Construct the Command Items section. */ - protected populateCommandItems(cellMenu: CellMenuOption, commandMenuElm: HTMLElement, commandItems: Array, args: any) { - if (!args || !commandItems || !cellMenu) { + /** Build the Command Items section. */ + protected populateCommandOrOptionItems( + itemType: MenuType, + 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; } - // 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); + // 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'); + this[`_${itemType}TitleElm`]!.className = 'slick-menu-title'; + this[`_${itemType}TitleElm`]!.textContent = cellMenu[`${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) { @@ -584,36 +643,36 @@ 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 "handleMenuItemClick" has the correct flag and won't trigger a command/option 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-cell-menu-item'; liElm.role = 'menuitem'; - if ((item as MenuCommandItem).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 MenuCommandItem).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 MenuCommandItem).hidden) { + if ((item as MenuCommandItem | MenuOptionItem).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 | 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'); @@ -621,104 +680,88 @@ export class SlickCellMenu 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-cell-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 || item === 'divider') { - return; - } - 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); - } + // 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._cellMenuProperties.subItemChevronClass) { + chevronElm.classList.add(...this._cellMenuProperties.subItemChevronClass.split(' ')); + } else { + chevronElm.textContent = '⮞'; // ⮞ or ▸ + } - if (!e.defaultPrevented) { - this.closeMenu(e, { cell, row, grid: this._grid }); + liElm.classList.add('slick-submenu-item'); + liElm.appendChild(chevronElm); + continue; } } } - 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: 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); + + if (optionOrCommand !== undefined && !(item as any)[`${type}Items`]) { + // user could execute a callback through 2 ways + // via the onCommand/onOptionSelected 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 }); + // unless prevented, close the menu + 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._lastMenuTypeClicked = type; } } diff --git a/src/plugins/slick.contextmenu.ts b/src/plugins/slick.contextmenu.ts index 2cbc013c4..182f312a9 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. @@ -164,6 +169,7 @@ export class SlickContextMenu implements SlickPlugin { // -- // protected props + protected _bindingEventService = new BindingEventService(); protected _contextMenuProperties: ContextMenuOption; protected _currentCell = -1; protected _currentRow = -1; @@ -173,8 +179,9 @@ export class SlickContextMenu implements SlickPlugin { protected _handler = new EventHandler(); protected _commandTitleElm?: HTMLSpanElement; protected _optionTitleElm?: HTMLSpanElement; + protected _lastMenuTypeClicked = ''; protected _menuElm?: HTMLDivElement | null; - protected _bindingEventService = new BindingEventService(); + protected _subMenuParentId = ''; protected _defaults: ContextMenuOption = { autoAdjustDrop: true, // dropup/dropdown autoAlignSide: true, // left/right @@ -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,90 @@ 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'; + // to avoid having multiple sub-menu trees opened, + // we need to somehow keep trace of which parent menu the tree belongs to + // and we should keep ref of only the first sub-menu parent, we can use the command name (remove any whitespaces though) + const subMenuCommand = (item as MenuCommandItem)?.command; + let subMenuId = (level === 1 && subMenuCommand) ? subMenuCommand.replaceAll(' ', '') : ''; + if (subMenuId) { + this._subMenuParentId = subMenuId; + } + if (level > 1) { + subMenuId = this._subMenuParentId; + } + + const menuClasses = `slick-context-menu slick-menu-level-${level} ${this._gridUid}`; + const bodyMenuElm = document.body.querySelector(`.slick-context-menu.slick-menu-level-${level}${this.getGridUidSelector()}`); + + // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all + if (bodyMenuElm) { + if (bodyMenuElm.dataset.subMenuParent === subMenuId) { + return bodyMenuElm; + } + this.destroySubMenus(); + } + + const menuElm = document.createElement('div'); + menuElm.className = menuClasses; + if (level > 0) { + menuElm.classList.add('slick-submenu'); + if (subMenuId) { + menuElm.dataset.subMenuParent = subMenuId; + } + } + menuElm.ariaLabel = level > 1 ? 'SubMenu' : 'Context Menu'; + menuElm.role = 'menu'; if (width) { - 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 +356,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 +382,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) { @@ -349,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({ @@ -362,6 +442,20 @@ export class SlickContextMenu implements SlickPlugin { this._menuElm.remove(); this._menuElm = null; } + 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() { + document.querySelectorAll(`.slick-context-menu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => subElm.remove()); } protected checkIsColumnAllowed(columnIds: Array, columnId: number | string) { @@ -379,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); @@ -402,135 +501,69 @@ 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; + /** When users click outside the Cell Menu, we will typically close the Cell Menu (and any sub-menus) */ + protected handleBodyMouseDown(e: DOMMouseOrTouchEvent) { + // did we click inside the menu or any of its sub-menu(s) + let isMenuClicked = false; + if (this._menuElm?.contains(e.target)) { + isMenuClicked = true; } - - // 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); + if (!isMenuClicked) { + document + .querySelectorAll(`.slick-context-menu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => { + if (subElm.contains(e.target)) { + isMenuClicked = true; + } + }); } - 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 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 +571,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 +608,129 @@ 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); + 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 +738,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 +765,30 @@ 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'; + 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') { - 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.cellmenu.scss b/src/styles/slick.cellmenu.scss index 09a6c5982..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 { @@ -30,7 +33,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; @@ -80,6 +83,10 @@ border: 1px solid transparent; border-radius: 3px; display: block; + + .sub-item-chevron { + float: right; + } } .slick-cell-menu-item:hover { border-color: silver; diff --git a/src/styles/slick.contextmenu.scss b/src/styles/slick.contextmenu.scss index 35422ed2c..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 { @@ -30,7 +33,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 +88,10 @@ padding: 2px 4px; border: 1px solid transparent; border-radius: 3px; + + .sub-item-chevron { + float: right; + } } .slick-context-menu-item:hover { border-color: silver;