From 24f30f6220b627c3e9c1b2d5d684ed9c04b67d10 Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Fri, 20 Oct 2023 17:30:44 -0400 Subject: [PATCH] feat: add sub-menu(s) to GridMenu control (#868) * feat: add sub-menu(s) to GridMenu control - currently only works by `click` event on sub-menus, I tried to implement it with `mouseover`/`mouseout` but it's a lot more complex (we need to know if we are mousing over the correct sub-menu or over something else and this and that... wow just too much work)... so `click` should be enough for now, even though it's slightly less user friendly --- cypress/e2e/example-grid-menu.cy.ts | 131 ++++++ examples/example-grid-menu.html | 53 ++- src/controls/slick.gridmenu.ts | 379 +++++++++++++----- ...idMenuCommandItemCallbackArgs.interface.ts | 4 +- src/models/gridMenuItem.interface.ts | 5 +- src/models/gridMenuOption.interface.ts | 6 + src/styles/slick.gridmenu.scss | 7 + 7 files changed, 480 insertions(+), 105 deletions(-) diff --git a/cypress/e2e/example-grid-menu.cy.ts b/cypress/e2e/example-grid-menu.cy.ts index 91bfb5d4c..679c2e4a0 100644 --- a/cypress/e2e/example-grid-menu.cy.ts +++ b/cypress/e2e/example-grid-menu.cy.ts @@ -287,4 +287,135 @@ describe('Example - Grid Menu', () => { .should('be.visible') .click({ force: true }); }); + + it('should be able to open Grid 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('button.slick-gridmenu-button') + .click({ force: true }); + + cy.get('.slick-gridmenu.slick-menu-level-0 .slick-gridmenu-command-list') + .find('.slick-gridmenu-item') + .contains('Export') + .click(); + + cy.get('.slick-gridmenu.slick-menu-level-1 .slick-gridmenu-command-list') + .should('exist') + .find('.slick-gridmenu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + cy.get('.slick-gridmenu.slick-menu-level-1 .slick-gridmenu-command-list') + .find('.slick-gridmenu-item') + .contains('Text') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Text')); + }); + + it('should be able to open Grid Menu and click on Export->Excel->xls and expect alert triggered with Excel (xls) Export', () => { + const subCommands1 = ['Text', 'Excel']; + const subCommands2 = ['Excel (csv)', 'Excel (xls)']; + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('#myGrid') + .find('button.slick-gridmenu-button') + .click({ force: true }); + + cy.get('.slick-gridmenu.slick-menu-level-0 .slick-gridmenu-command-list') + .find('.slick-gridmenu-item') + .contains('Export') + .click(); + + cy.get('.slick-gridmenu.slick-menu-level-1 .slick-gridmenu-command-list') + .should('exist') + .find('.slick-gridmenu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + cy.get('.slick-submenu').should('have.length', 1); + cy.get('.slick-gridmenu.slick-menu-level-1 .slick-gridmenu-command-list') + .find('.slick-gridmenu-item') + .contains('Excel') + .click(); + + cy.get('.slick-gridmenu.slick-menu-level-2 .slick-gridmenu-command-list').as('subMenuList2'); + + cy.get('@subMenuList2') + .find('.slick-menu-title') + .contains('available formats'); + + cy.get('@subMenuList2') + .should('exist') + .find('.slick-gridmenu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2[index])); + cy.get('.slick-submenu').should('have.length', 2); + + cy.get('.slick-gridmenu.slick-menu-level-2 .slick-gridmenu-command-list') + .find('.slick-gridmenu-item') + .contains('Excel (xls)') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Exporting as Excel (xls)')); + cy.get('.slick-submenu').should('have.length', 0); + }); + + 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('button.slick-gridmenu-button') + .click({ force: true }); + + cy.get('.slick-gridmenu.slick-menu-level-0 .slick-gridmenu-command-list') + .find('.slick-gridmenu-item') + .contains('Export') + .click(); + + cy.get('.slick-gridmenu.slick-menu-level-1 .slick-gridmenu-command-list') + .should('exist') + .find('.slick-gridmenu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-gridmenu.slick-menu-level-0') + .find('.slick-gridmenu-item') + .contains('Feedback') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 1); + cy.get('.slick-gridmenu.slick-menu-level-1') + .should('exist') + .find('.slick-gridmenu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands2[index])); + + // click on Feedback->ContactUs + cy.get('.slick-gridmenu.slick-menu-level-1.dropleft') // left align + .find('.slick-gridmenu-item') + .contains('Contact Us') + .should('exist') + .click(); + + cy.get('.slick-submenu').should('have.length', 2); + cy.get('.slick-gridmenu.slick-menu-level-2.dropright') // right align + .should('exist') + .find('.slick-gridmenu-item') + .each(($command, index) => expect($command.text()).to.eq(subCommands2_1[index])); + + cy.get('.slick-gridmenu.slick-menu-level-2'); + + cy.get('.slick-gridmenu.slick-menu-level-2 .slick-gridmenu-command-list') + .find('.slick-gridmenu-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); + }); }); diff --git a/examples/example-grid-menu.html b/examples/example-grid-menu.html index ad0ea6234..907e2445d 100644 --- a/examples/example-grid-menu.html +++ b/examples/example-grid-menu.html @@ -87,6 +87,12 @@ .slick-columnpicker-list input[type=checkbox]:checked + label:before { opacity: 1; /* checked icon */ } + .slick-gridmenu.slick-submenu { + background-color: #fbfbfb; + /* border-width: 2px; */ + box-shadow: 0 2px 4px 2px rgba(146, 152, 163, 0.4); + /* min-width: 150px; */ + } @@ -175,6 +181,7 @@

View Source:

// when not passed, it will act as a regular Column Picker (with default Grid Menu image of drag-handle.png) gridMenu: { useClickToRepositionMenu: false, // defaults to true (false would use the icon offset to reposition the grid menu) + subItemChevronClass: 'sgi sgi-chevron-right', menuUsabilityOverride: function (args) { // we could disable the menu entirely by returning false return true; @@ -244,6 +251,36 @@

View Source:

title: "Disabled Command", disabled: true, command: "custom-command" + }, + "divider", + { + // we can also have multiple nested sub-menus + command: 'export', title: 'Export', + customItems: [ + { command: "export-txt", title: "Text" }, + { + command: 'sub-menu', title: 'Excel', cssClass: "green", subMenuTitle: "available formats", subMenuTitleCssClass: "italic orange", + customItems: [ + { command: "export-csv", title: "Excel (csv)" }, + { command: "export-xls", title: "Excel (xls)" }, + ] + } + ] + }, + { + command: 'feedback', title: 'Feedback', + customItems: [ + { command: "request-update", 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", + customItems: [ + { 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" }, + ] + } + ] } ] } @@ -383,7 +420,16 @@

View Source:

grid.setSortColumns([]); dataView.refresh(); } else { - alert("Command: " + args.command); + switch (args.command) { + case "export-csv": + case "export-txt": + case "export-xls": + alert("Exporting as " + args.item.title); + break; + default: + alert("Command: " + args.command); + break; + } } }); @@ -404,6 +450,11 @@

View Source:

gridMenuControl.onMenuClose.subscribe(function(e, args) { console.log('Menu is closing'); grid.autosizeColumns(); + + // you can prevent Grid Menu from closing + // e.preventDefault(); + // console.log('default prevented') + // return false; }); grid.onAutosizeColumns.subscribe(function(e, args) { diff --git a/src/controls/slick.gridmenu.ts b/src/controls/slick.gridmenu.ts index 698b83d10..5df284680 100644 --- a/src/controls/slick.gridmenu.ts +++ b/src/controls/slick.gridmenu.ts @@ -55,17 +55,18 @@ const Utils = IIFE_ONLY ? Slick.Utils : Utils_; * * * Available menu options: - * hideForceFitButton: Hide the "Force fit columns" button (defaults to false) - * hideSyncResizeButton: Hide the "Synchronous resize" button (defaults to false) - * forceFitTitle: Text of the title "Force fit columns" - * contentMinWidth: minimum width of grid menu content (command, column list), defaults to 0 (auto) - * height: Height of the Grid Menu content, when provided it will be used instead of the max-height (defaults to undefined) - * menuWidth: Grid menu button width (defaults to 18) - * resizeOnShowHeaderRow: Do we want to resize on the show header row event - * syncResizeTitle: Text of the title "Synchronous resize" - * useClickToRepositionMenu: Use the Click offset to reposition the Grid Menu (defaults to true), when set to False it will use the icon offset to reposition the grid menu - * menuUsabilityOverride: Callback method that user can override the default behavior of enabling/disabling the menu from being usable (must be combined with a custom formatter) - * marginBottom: Margin to use at the bottom of the grid menu, only in effect when height is undefined (defaults to 15) + * hideForceFitButton: Hide the "Force fit columns" button (defaults to false) + * hideSyncResizeButton: Hide the "Synchronous resize" button (defaults to false) + * forceFitTitle: Text of the title "Force fit columns" + * contentMinWidth: minimum width of grid menu content (command, column list), defaults to 0 (auto) + * height: Height of the Grid Menu content, when provided it will be used instead of the max-height (defaults to undefined) + * menuWidth: Grid menu button width (defaults to 18) + * resizeOnShowHeaderRow: Do we want to resize on the show header row event + * syncResizeTitle: Text of the title "Synchronous resize" + * useClickToRepositionMenu: Use the Click offset to reposition the Grid Menu (defaults to true), when set to False it will use the icon offset to reposition the grid menu + * menuUsabilityOverride: Callback method that user can override the default behavior of enabling/disabling the menu from being usable (must be combined with a custom formatter) + * marginBottom: Margin to use at the bottom of the grid menu, only in effect when height is undefined (defaults to 15) + * subItemChevronClass: CSS class that can be added on the right side of a sub-item parent (typically a chevron-right icon) * * Available custom menu item options: * action: Optionally define a callback function that gets executed when item is chosen (and/or use the onCommand event) @@ -79,6 +80,8 @@ const Utils = IIFE_ONLY ? Slick.Utils : Utils_; * iconCssClass: A CSS class to be added to the menu item icon. * iconImage: A url to the icon image. * textCssClass: A CSS class to be added to the menu item text. + * subMenuTitle: Optional sub-menu title that will shows up when sub-menu commmands/options list is opened + * subMenuTitleCssClass: Optional sub-menu title CSS class to use with `subMenuTitle` * itemVisibilityOverride: Callback method that user can override the default behavior of showing/hiding an item from the list * itemUsabilityOverride: Callback method that user can override the default behavior of enabling/disabling an item from the list * @@ -131,16 +134,17 @@ export class SlickGridMenu { protected _gridOptions: GridOption; protected _gridUid: string; protected _isMenuOpen = false; - protected _gridMenuOptions: GridMenuOption | null = null; + protected _columnCheckboxes: HTMLInputElement[] = []; protected _columnTitleElm!: HTMLElement; protected _customTitleElm!: HTMLElement; - protected _customMenuElm!: HTMLElement; + protected _customMenuElm!: HTMLDivElement; protected _headerElm: HTMLDivElement | null = null; protected _listElm!: HTMLElement; protected _buttonElm!: HTMLElement; protected _menuElm!: HTMLElement; - protected _columnCheckboxes: HTMLInputElement[] = []; - protected _defaults = { + protected _subMenuParentId = ''; + protected _gridMenuOptions: GridMenuOption | null = null; + protected _defaults: GridMenuOption = { showButton: true, hideForceFitButton: false, hideSyncResizeButton: false, @@ -151,7 +155,7 @@ export class SlickGridMenu { resizeOnShowHeaderRow: false, syncResizeTitle: 'Synchronous resize', useClickToRepositionMenu: true, - headerColumnValueExtractor: (columnDef: Column) => columnDef.name, + headerColumnValueExtractor: (columnDef: Column) => columnDef.name as string, }; constructor(protected columns: Column[], protected readonly grid: SlickGrid, gridOptions: GridOption) { @@ -224,38 +228,102 @@ export class SlickGridMenu { this._bindingEventService.bind(this._buttonElm, 'click', this.showGridMenu.bind(this) as EventListener); } - this._menuElm = document.createElement('div'); - this._menuElm.className = `slick-gridmenu ${this._gridUid}`; - this._menuElm.style.display = 'none'; + this._menuElm = this.createMenu(0); + this.populateColumnPicker(); document.body.appendChild(this._menuElm); - const buttonElm = document.createElement('button'); - buttonElm.type = 'button'; - buttonElm.className = 'close'; - buttonElm.dataset.dismiss = 'slick-gridmenu'; - buttonElm.ariaLabel = 'Close'; + // Hide the menu on outside click. + this._bindingEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener); - const spanCloseElm = document.createElement('span'); - spanCloseElm.className = 'close'; - spanCloseElm.ariaHidden = 'true'; - spanCloseElm.innerHTML = '×'; - buttonElm.appendChild(spanCloseElm); - this._menuElm.appendChild(buttonElm); + // destroy the picker if user leaves the page + this._bindingEventService.bind(document.body, 'beforeunload', this.destroy.bind(this)); + } + + /** Create the menu or sub-menu(s) but without the column picker which is a separate single process */ + createMenu(level = 0, item?: GridMenuItem | 'divider') { + // create a new cell menu + const maxHeight = isNaN(this._gridMenuOptions?.maxHeight as number) ? this._gridMenuOptions?.maxHeight : `${this._gridMenuOptions?.maxHeight ?? 0}px`; + const width = isNaN(this._gridMenuOptions?.width as number) ? this._gridMenuOptions?.width : `${this._gridMenuOptions?.maxWidth ?? 0}px`; + + // to avoid having multiple sub-menu trees opened, + // we need to somehow keep trace of which parent menu the tree belongs to + // and we should keep ref of only the first sub-menu parent, we can use the command name (remove any whitespaces though) + const subMenuCommand = (item as GridMenuItem)?.command; + let subMenuId = (level === 1 && subMenuCommand) ? subMenuCommand.replaceAll(' ', '') : ''; + if (subMenuId) { + this._subMenuParentId = subMenuId; + } + if (level > 1) { + subMenuId = this._subMenuParentId; + } + + const menuClasses = `slick-gridmenu slick-menu-level-${level} ${this._gridUid}`; + const bodyMenuElm = document.body.querySelector(`.slick-gridmenu.slick-menu-level-${level}${this.getGridUidSelector()}`); + + // return menu/sub-menu if it's already opened unless we are on different sub-menu tree if so close them all + if (bodyMenuElm) { + if (bodyMenuElm.dataset.subMenuParent === subMenuId) { + return bodyMenuElm; + } + this.destroySubMenus(); + } + + const menuElm = document.createElement('div'); + menuElm.role = 'menu'; + menuElm.className = menuClasses; + if (level > 0) { + menuElm.classList.add('slick-submenu'); + if (subMenuId) { + menuElm.dataset.subMenuParent = subMenuId; + } + } + menuElm.ariaLabel = level > 1 ? 'SubMenu' : 'Grid Menu'; + + if (width) { + menuElm.style.width = width as string; + } + if (maxHeight) { + menuElm.style.maxHeight = maxHeight as string; + } + + menuElm.style.display = 'none'; + + let closeButtonElm: HTMLButtonElement | null = null; + if (level === 0) { + closeButtonElm = document.createElement('button'); + closeButtonElm.type = 'button'; + closeButtonElm.className = 'close'; + closeButtonElm.dataset.dismiss = 'slick-gridmenu'; + closeButtonElm.ariaLabel = 'Close'; + + const spanCloseElm = document.createElement('span'); + spanCloseElm.className = 'close'; + spanCloseElm.ariaHidden = 'true'; + spanCloseElm.innerHTML = '×'; + closeButtonElm.appendChild(spanCloseElm); + menuElm.appendChild(closeButtonElm); + } + // -- Command List section this._customMenuElm = document.createElement('div'); - this._customMenuElm.className = 'slick-gridmenu-custom'; + this._customMenuElm.className = `slick-gridmenu-custom slick-gridmenu-command-list slick-menu-level-${level}`; this._customMenuElm.role = 'menu'; + menuElm.appendChild(this._customMenuElm); - this._menuElm.appendChild(this._customMenuElm); + const commandItems = (item as GridMenuItem)?.customItems ?? this._gridMenuOptions?.customItems ?? []; + if (commandItems.length > 0) { - this.populateCustomMenus(this._gridMenuOptions || {}, this._customMenuElm); - this.populateColumnPicker(); + // when creating sub-menu add its sub-menu title when exists + if (item && level > 0) { + this.addSubMenuTitleWhenExists(item, this._customMenuElm); // add sub-menu title when exists + } + } + this.populateCustomMenus(commandItems, this._customMenuElm, { grid: this.grid, level }); - // Hide the menu on outside click. - this._bindingEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener); + // increment level for possible next sub-menus if exists + level++; - // destroy the picker if user leaves the page - this._bindingEventService.bind(document.body, 'beforeunload', this.destroy.bind(this)); + return menuElm; } /** Destroy the plugin by unsubscribing every events & also delete the menu DOM elements */ @@ -288,23 +356,26 @@ export class SlickGridMenu { this._menuElm?.remove(); } - protected populateCustomMenus(gridMenuOptions: GridMenuOption, customMenuElm: HTMLElement) { - // Construct the custom menu items. - if (!gridMenuOptions || !gridMenuOptions.customItems) { - return; - } + /** Close and destroy all previously opened sub-menus */ + destroySubMenus() { + document.querySelectorAll(`.slick-gridmenu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => subElm.remove()); + } + /** Construct the custom command menu items. */ + protected populateCustomMenus(customItems: Array, customMenuElm: HTMLElement, args: { grid: SlickGrid, level: number }) { // user could pass a title on top of the custom section - if (this._gridMenuOptions?.customTitle) { + const isSubMenu = args.level > 0; + if (this._gridMenuOptions?.customTitle && !isSubMenu) { this._customTitleElm = document.createElement('div'); this._customTitleElm.className = 'title'; this._customTitleElm.innerHTML = this._gridMenuOptions.customTitle; customMenuElm.appendChild(this._customTitleElm); } - for (let i = 0, ln = gridMenuOptions.customItems.length; i < ln; i++) { + for (let i = 0, ln = customItems.length; i < ln; i++) { let addClickListener = true; - const item = gridMenuOptions.customItems[i]; + const item = customItems[i]; const callbackArgs = { grid: this.grid, menu: this._menuElm, @@ -322,7 +393,7 @@ export class SlickGridMenu { } // 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 GridMenuItem).disabled = isItemUsable ? false : true; } @@ -377,7 +448,22 @@ export class SlickGridMenu { customMenuElm.appendChild(liElm); if (addClickListener) { - this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item) as EventListener); + this._bindingEventService.bind(liElm, 'click', this.handleMenuItemClick.bind(this, item, args.level) as EventListener); + } + + // the option/command item could be a sub-menu if it has another list of commands/options + if ((item as GridMenuItem).customItems) { + const chevronElm = document.createElement('span'); + chevronElm.className = 'sub-item-chevron'; + if (this._gridMenuOptions?.subItemChevronClass) { + chevronElm.classList.add(...this._gridMenuOptions.subItemChevronClass.split(' ')); + } else { + chevronElm.textContent = '⮞'; // ⮞ or ▸ + } + + liElm.classList.add('slick-submenu-item'); + liElm.appendChild(chevronElm); + continue; } } } @@ -414,7 +500,8 @@ export class SlickGridMenu { Utils.emptyElement(this._listElm); Utils.emptyElement(this._customMenuElm); - this.populateCustomMenus(this._gridMenuOptions || {}, this._customMenuElm); + const commandItems = this._gridMenuOptions?.customItems ?? []; + this.populateCustomMenus(commandItems, this._customMenuElm, { grid: this.grid, level: 0 }); this.updateColumnOrder(); this._columnCheckboxes = []; @@ -463,7 +550,7 @@ export class SlickGridMenu { if (this._gridMenuOptions?.headerColumnValueExtractor) { columnLabel = this._gridMenuOptions.headerColumnValueExtractor(this.columns[i], this._gridOptions); } else { - columnLabel = this._defaults.headerColumnValueExtractor(this.columns[i]); + columnLabel = this._defaults.headerColumnValueExtractor!(this.columns[i]); } const labelElm = document.createElement('label'); @@ -478,7 +565,7 @@ export class SlickGridMenu { } if (!(this._gridMenuOptions?.hideForceFitButton)) { - const forceFitTitle = (this._gridMenuOptions?.forceFitTitle) || this._defaults.forceFitTitle; + const forceFitTitle = (this._gridMenuOptions?.forceFitTitle) || this._defaults.forceFitTitle as string; const liElm = document.createElement('li'); liElm.ariaLabel = forceFitTitle; @@ -502,7 +589,7 @@ export class SlickGridMenu { } if (!(this._gridMenuOptions?.hideSyncResizeButton)) { - const syncResizeTitle = (this._gridMenuOptions?.syncResizeTitle) || this._defaults.syncResizeTitle; + const syncResizeTitle = (this._gridMenuOptions?.syncResizeTitle) || this._defaults.syncResizeTitle as string; const liElm = document.createElement('li'); liElm.ariaLabel = syncResizeTitle; @@ -533,23 +620,10 @@ export class SlickGridMenu { this._menuElm.style.display = 'block'; this._menuElm.style.opacity = '0'; - const menuIconOffset = Utils.offset(buttonElm); // get button offset position - const menuWidth = this._menuElm.offsetWidth; - const useClickToRepositionMenu = (this._gridMenuOptions?.useClickToRepositionMenu !== undefined) ? this._gridMenuOptions.useClickToRepositionMenu : this._defaults.useClickToRepositionMenu; - const contentMinWidth = (this._gridMenuOptions?.contentMinWidth) ? this._gridMenuOptions.contentMinWidth : this._defaults.contentMinWidth; - const currentMenuWidth = (contentMinWidth > menuWidth) ? contentMinWidth : menuWidth + 5; - const nextPositionTop = (useClickToRepositionMenu && targetEvent.pageY > 0) ? targetEvent.pageY : menuIconOffset!.top + 10; - const nextPositionLeft = (useClickToRepositionMenu && targetEvent.pageX > 0) ? targetEvent.pageX : menuIconOffset!.left + 10; - const menuMarginBottom = (this._gridMenuOptions?.marginBottom !== undefined) ? this._gridMenuOptions.marginBottom : this._defaults.marginBottom; - - this._menuElm.style.top = `${nextPositionTop + 10}px`; - this._menuElm.style.left = `${nextPositionLeft - currentMenuWidth + 10}px`; - - if (contentMinWidth > 0) { - this._menuElm.style.minWidth = `${contentMinWidth}px`; - } + this.repositionMenu(e, this._menuElm, buttonElm); // set "height" when defined OR ELSE use the "max-height" with available window size and optional margin bottom + const menuMarginBottom = (this._gridMenuOptions?.marginBottom !== undefined) ? this._gridMenuOptions.marginBottom : this._defaults.marginBottom as number; if (this._gridMenuOptions?.height !== undefined) { this._menuElm.style.height = `${this._gridMenuOptions.height}px`; } else { @@ -568,61 +642,83 @@ export class SlickGridMenu { } } - protected handleBodyMouseDown(event: DOMMouseOrTouchEvent) { - if ((this._menuElm !== event.target && !(this._menuElm?.contains(event.target)) && this._isMenuOpen) || event.target.className === 'close') { - this.hideMenu(event); - } + protected getGridUidSelector() { + const gridUid = this.grid.getUID() || ''; + return gridUid ? `.${gridUid}` : ''; } - protected handleMenuItemClick(item: any, e: DOMMouseOrTouchEvent) { - const command = item.command || ''; - - if (item.disabled || item.divider || item === 'divider') { - return; + protected handleBodyMouseDown(e: DOMMouseOrTouchEvent) { + // did we click inside the menu or any of its sub-menu(s) + let isMenuClicked = false; + if (this._menuElm?.contains(e.target)) { + isMenuClicked = true; } - - if (Utils.isDefined(command) && command !== '') { - const callbackArgs = { - grid: this.grid, - command, - item, - allColumns: this.columns, - visibleColumns: this.getVisibleColumns() - }; - this.onCommand.notify(callbackArgs, e, this); - - // execute action callback when defined - if (typeof item.action === 'function') { - item.action.call(this, e, callbackArgs); - } + if (!isMenuClicked) { + document + .querySelectorAll(`.slick-gridmenu.slick-submenu${this.getGridUidSelector()}`) + .forEach(subElm => { + if (subElm.contains(e.target)) { + isMenuClicked = true; + } + }); } - // does the user want to leave open the Grid Menu after executing a command? - const leaveOpen = !!(this._gridMenuOptions?.leaveOpen); - if (!leaveOpen && !e.defaultPrevented) { + if ((this._menuElm !== e.target && !isMenuClicked && !e.defaultPrevented && this._isMenuOpen) || e.target.className === 'close') { this.hideMenu(e); } + } - // Stop propagation so that it doesn't register as a header click event. - e.preventDefault(); - e.stopPropagation(); + protected handleMenuItemClick(item: GridMenuItem | 'divider', level = 0, e: DOMMouseOrTouchEvent) { + if (item !== 'divider' && !item.disabled && !item.divider) { + const command = item.command || ''; + + if (Utils.isDefined(command) && !item.customItems) { + const callbackArgs: GridMenuCommandItemCallbackArgs = { + grid: this.grid, + command, + item, + allColumns: this.columns, + visibleColumns: this.getVisibleColumns() + }; + this.onCommand.notify(callbackArgs, e, this); + + // execute action callback when defined + if (typeof item.action === 'function') { + item.action.call(this, e, callbackArgs); + } + + // does the user want to leave open the Grid Menu after executing a command? + const leaveOpen = !!(this._gridMenuOptions?.leaveOpen); + if (!leaveOpen && !e.defaultPrevented) { + this.hideMenu(e); + } + + // Stop propagation so that it doesn't register as a header click event. + e.preventDefault(); + e.stopPropagation(); + } else if ((item as GridMenuItem).customItems) { + this.repositionSubMenu(item, level, e); + } else { + this.destroySubMenus(); + } + } } hideMenu(e: DOMMouseOrTouchEvent) { if (this._menuElm) { - Utils.hide(this._menuElm); - this._isMenuOpen = false; - const callbackArgs = { grid: this.grid, menu: this._menuElm, allColumns: this.columns, visibleColumns: this.getVisibleColumns() }; - if (this.onMenuClose.notify(callbackArgs, e, this).getReturnValue() === false) { + if (this._isMenuOpen && this.onMenuClose.notify(callbackArgs, e, this).getReturnValue() === false) { return; } + this._isMenuOpen = false; + Utils.hide(this._menuElm); } + this.destroySubMenus(); } /** Update the Titles of each sections (command, customTitle, ...) */ @@ -635,6 +731,87 @@ export class SlickGridMenu { } } + protected addSubMenuTitleWhenExists(item: GridMenuItem | '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 repositionSubMenu(item: GridMenuItem | 'divider', level: number, e: DOMMouseOrTouchEvent) { + // when we're clicking a grid cell OR our last menu type (command/option) differs then we know that we need to start fresh and close any sub-menus that might still be open + if (e.target.classList.contains('slick-cell')) { + this.destroySubMenus(); + } + + // creating sub-menu, we'll also pass level & the item object since we might have "subMenuTitle" to show + const subMenuElm = this.createMenu(level + 1, item); + subMenuElm.style.display = 'block'; + document.body.appendChild(subMenuElm); + this.repositionMenu(e, subMenuElm); + } + + /** + * Reposition the menu drop (up/down) and the side (left/right) + * @param {*} event + */ + protected repositionMenu(e: DOMMouseOrTouchEvent, menuElm: HTMLElement, buttonElm?: HTMLButtonElement) { + const targetEvent = e.touches ? e.touches[0] : e; + const isSubMenu = menuElm.classList.contains('slick-submenu'); + const parentElm = isSubMenu + ? e.target.closest('.slick-gridmenu-item') as HTMLDivElement + : targetEvent.target as HTMLElement; + + const menuIconOffset = Utils.offset(buttonElm || this._buttonElm); // get button offset position + const menuWidth = menuElm.offsetWidth; + const useClickToRepositionMenu = (this._gridMenuOptions?.useClickToRepositionMenu !== undefined) ? this._gridMenuOptions.useClickToRepositionMenu : this._defaults.useClickToRepositionMenu; + const contentMinWidth = (this._gridMenuOptions?.contentMinWidth) ? this._gridMenuOptions.contentMinWidth : this._defaults.contentMinWidth as number; + const currentMenuWidth = (contentMinWidth > menuWidth) ? contentMinWidth : menuWidth + 5; + let menuOffsetTop = (useClickToRepositionMenu && targetEvent.pageY > 0) ? targetEvent.pageY : menuIconOffset!.top + 10; + let menuOffsetLeft = (useClickToRepositionMenu && targetEvent.pageX > 0) ? targetEvent.pageX : menuIconOffset!.left + 10; + + if (isSubMenu && parentElm) { + const parentOffset = Utils.offset(parentElm); + menuOffsetLeft = parentOffset?.left ?? 0; + menuOffsetTop = parentOffset?.top ?? 0; + const gridPos = this.grid.getGridPosition(); + let subMenuPosCalc = menuOffsetLeft + Number(menuWidth); // calculate coordinate at caller element far right + if (isSubMenu) { + subMenuPosCalc += parentElm.clientWidth; + } + const browserWidth = document.documentElement.clientWidth; + const dropSide = (subMenuPosCalc >= gridPos.width || subMenuPosCalc >= browserWidth) ? 'left' : 'right'; + if (dropSide === 'left') { + menuElm.classList.remove('dropright'); + menuElm.classList.add('dropleft'); + menuOffsetLeft -= menuWidth; + } else { + menuElm.classList.remove('dropleft'); + menuElm.classList.add('dropright'); + if (isSubMenu) { + menuOffsetLeft += parentElm.offsetWidth; + } + } + } else { + menuOffsetTop += 10; + menuOffsetLeft = menuOffsetLeft - currentMenuWidth + 10; + } + + menuElm.style.top = `${menuOffsetTop}px`; + menuElm.style.left = `${menuOffsetLeft}px`; + + if (contentMinWidth > 0) { + this._menuElm.style.minWidth = `${contentMinWidth}px`; + } + } + protected updateColumnOrder() { // Because columns can be reordered, we have to update the `columns` // to reflect the new order, however we can't just take `grid.getColumns()`, diff --git a/src/models/gridMenuCommandItemCallbackArgs.interface.ts b/src/models/gridMenuCommandItemCallbackArgs.interface.ts index d704ff943..566726d3b 100644 --- a/src/models/gridMenuCommandItemCallbackArgs.interface.ts +++ b/src/models/gridMenuCommandItemCallbackArgs.interface.ts @@ -1,4 +1,4 @@ -import type { Column, MenuCommandItem } from './index'; +import type { Column, GridMenuItem } from './index'; import type { SlickGrid } from '../slick.grid'; @@ -14,7 +14,7 @@ export interface GridMenuCommandItemCallbackArgs { command: string; /** Menu item selected */ - item: MenuCommandItem; + item: GridMenuItem; /** Slick Grid object */ grid: SlickGrid; diff --git a/src/models/gridMenuItem.interface.ts b/src/models/gridMenuItem.interface.ts index 9e6fd85c5..cd2984c91 100644 --- a/src/models/gridMenuItem.interface.ts +++ b/src/models/gridMenuItem.interface.ts @@ -1,7 +1,10 @@ import type { GridMenuCallbackArgs, GridMenuCommandItemCallbackArgs, MenuCommandItem } from './index'; import type { SlickEventData } from '../slick.core'; -export interface GridMenuItem extends MenuCommandItem { +export interface GridMenuItem extends Omit, 'commandItems'> { + /** Array of Command Items (title, command, disabled, ...) */ + customItems?: Array; + // -- // action/override callbacks diff --git a/src/models/gridMenuOption.interface.ts b/src/models/gridMenuOption.interface.ts index c604b8f81..ebc4e34d9 100644 --- a/src/models/gridMenuOption.interface.ts +++ b/src/models/gridMenuOption.interface.ts @@ -46,6 +46,9 @@ export interface GridMenuOption { */ maxHeight?: number | string; + /** Maximum width that the grid menu can have, it could be a number (250) or text ("none") */ + maxWidth?: number | string; + /** Defaults to 16 pixels (only the number), which is the width in pixels of the Grid Menu icon container */ menuWidth?: number; @@ -55,6 +58,9 @@ export interface GridMenuOption { /** Defaults to true, allows the user to control if the default gridMenu button (located on the top right corner by default CSS) should be created or omitted */ showButton?: boolean; + /** CSS class that can be added on the right side of a sub-item parent (typically a chevron-right icon) */ + subItemChevronClass?: string; + /** Defaults to "Synchronous resize" which is 1 of the last 2 checkbox title shown at the end of the picker list */ syncResizeTitle?: string; diff --git a/src/styles/slick.gridmenu.scss b/src/styles/slick.gridmenu.scss index ad31725ec..3a8d151c7 100644 --- a/src/styles/slick.gridmenu.scss +++ b/src/styles/slick.gridmenu.scss @@ -11,6 +11,10 @@ z-index: 2000; overflow:auto; resize: both; + + &.slick-submenu { + min-width: 100px; + } } .slick-gridmenu-button { @@ -83,6 +87,9 @@ border: 1px solid transparent; border-radius: 3px; display: block; + .sub-item-chevron { + float: right; + } } .slick-gridmenu-item:hover { border-color: silver;