From d45881e5776aa40ffafa9e3a9db117b7b05df4e9 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 6 Feb 2026 18:11:18 -0500 Subject: [PATCH 01/24] feat: add custom menu slot renderers --- demos/vanilla/src/app-routing.ts | 2 + demos/vanilla/src/app.html | 1 + demos/vanilla/src/examples/example40.html | 47 ++ demos/vanilla/src/examples/example40.ts | 579 ++++++++++++++++++ docs/column-functionalities/cell-menu.md | 5 + docs/grid-functionalities/context-menu.md | 5 + docs/grid-functionalities/grid-menu.md | 5 + .../header-menu-header-buttons.md | 5 + docs/menu-slots.md | 496 +++++++++++++++ .../examples/example-header-menu-slots.html | 227 +++++++ .../common/src/extensions/menuBaseClass.ts | 96 ++- .../interfaces/cellMenuOption.interface.ts | 14 + .../interfaces/contextMenuOption.interface.ts | 14 + .../interfaces/gridMenuOption.interface.ts | 14 + .../interfaces/headerMenuOption.interface.ts | 14 + .../src/interfaces/menuItem.interface.ts | 35 ++ 16 files changed, 1538 insertions(+), 21 deletions(-) create mode 100644 demos/vanilla/src/examples/example40.html create mode 100644 demos/vanilla/src/examples/example40.ts create mode 100644 docs/menu-slots.md create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example-header-menu-slots.html diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index 54de24e7ce..3a557e7581 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -37,6 +37,7 @@ import Example36 from './examples/example36.js'; import Example37 from './examples/example37.js'; import Example38 from './examples/example38.js'; import Example39 from './examples/example39.js'; +import Example40 from './examples/example40.js'; import Icons from './examples/icons.js'; import type { RouterConfig } from './interfaces.js'; @@ -84,6 +85,7 @@ export class AppRouting { { route: 'example37', name: 'example37', view: './examples/example37.html', viewModel: Example37, title: 'Example37' }, { route: 'example38', name: 'example38', view: './examples/example38.html', viewModel: Example38, title: 'Example38' }, { route: 'example39', name: 'example39', view: './examples/example39.html', viewModel: Example39, title: 'Example39' }, + { route: 'example40', name: 'example40', view: './examples/example40.html', viewModel: Example40, title: 'Example40' }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/app.html b/demos/vanilla/src/app.html index e665df91ee..5e7542fc5a 100644 --- a/demos/vanilla/src/app.html +++ b/demos/vanilla/src/app.html @@ -79,6 +79,7 @@

Slickgrid-Universal

Example37 - Hybrid Selection Model Example38 - Spreadsheet Drag-Fill Example39 - Master/Detail Grids + Example40 - Menus with Slots diff --git a/demos/vanilla/src/examples/example40.html b/demos/vanilla/src/examples/example40.html new file mode 100644 index 0000000000..b4e9efd3dd --- /dev/null +++ b/demos/vanilla/src/examples/example40.html @@ -0,0 +1,47 @@ +

+ Example 40 - Menus with Slots + (Custom Menu Item Content) + +

+ +
+

+ + Menu Slots Demo +

+

+ Click on the menu buttons to see the new single slot functionality working across all menu types (Header Menu, Cell + Menu, Context Menu, Grid Menu): +

+ +

+ Note: The demo focuses on the custom rendering capability via slotRenderer and + defaultItemRenderer, which work across all menu plugins (SlickHeaderMenu, SlickCellMenu, SlickContextMenu, + SlickGridMenu). Also note that the keyboard shortcuts displayed in the menus (e.g., Alt+↑, F5) are for demo + purposes only and do not actually trigger any actions. + +

+
+ +
diff --git a/demos/vanilla/src/examples/example40.ts b/demos/vanilla/src/examples/example40.ts new file mode 100644 index 0000000000..4d08c486c2 --- /dev/null +++ b/demos/vanilla/src/examples/example40.ts @@ -0,0 +1,579 @@ +import { BindingEventService } from '@slickgrid-universal/binding'; +import { Filters, Formatters, type Column, type GridOption } from '@slickgrid-universal/common'; +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; +import { ExampleGridOptions } from './example-grid-options.js'; + +interface ReportItem { + id: number; + title: string; + duration: number; + cost: number; + percentComplete: number; + start: Date; + finish: Date; + action?: string; +} + +export default class Example40 { + private _bindingEventService: BindingEventService; + columnDefinitions: Column[]; + gridOptions: GridOption; + dataset: ReportItem[]; + sgb: SlickVanillaGridBundle; + + constructor() { + this._bindingEventService = new BindingEventService(); + } + + attached() { + this.initializeGrid(); + this.dataset = this.loadData(1000); + const gridContainerElm = document.querySelector(`.grid40`) as HTMLDivElement; + + this.sgb = new Slicker.GridBundle( + gridContainerElm, + this.columnDefinitions, + { ...ExampleGridOptions, ...this.gridOptions }, + this.dataset + ); + } + + dispose() { + this.sgb?.dispose(); + this._bindingEventService.unbindAll(); + } + + initializeGrid() { + // This example demonstrates Menu Slot functionality across all menu types: + // - SlickHeaderMenu (Title, Duration, Cost, % Complete columns) + // - SlickCellMenu (Action column with chevron button) + // - SlickContextMenu (right-click context menu) + // - SlickGridMenu (grid menu button) + // All demonstrate: slotRenderer callbacks (item, args) returning strings or HTMLElements + + this.columnDefinitions = [ + { + id: 'title', + name: 'Title', + field: 'title', + sortable: true, + filterable: true, + minWidth: 100, + // Demo: Header Menu with Slot - complete custom HTML with keyboard shortcuts + header: { + menu: { + commandItems: [ + { + command: 'sort-asc', + title: 'Sort Ascending', + positionOrder: 50, + // Slot renderer replaces entire menu item content + slotRenderer: () => ` +
+ + Sort Ascending + Alt+↑ +
+ `, + }, + { + command: 'sort-desc', + title: 'Sort Descending', + positionOrder: 51, + slotRenderer: () => ` +
+ + Sort Descending + Alt+↓ +
+ `, + }, + ], + }, + }, + }, + { + id: 'duration', + name: 'Duration', + field: 'duration', + sortable: true, + filterable: true, + minWidth: 100, + // Demo: Header Menu with Slot - showing badge and status dot + header: { + menu: { + commandItems: [ + { + command: 'column-resize-by-content', + title: 'Resize by Content', + positionOrder: 47, + // Slot renderer with badge + slotRenderer: () => ` +
+ + Resize by Content + NEW +
+ `, + }, + { divider: true, command: '', positionOrder: 48 }, + { + command: 'sort-asc', + title: 'Sort Ascending', + iconCssClass: 'mdi mdi-sort-ascending', + positionOrder: 50, + }, + { + command: 'sort-desc', + title: 'Sort Descending', + iconCssClass: 'mdi mdi-sort-descending', + positionOrder: 51, + }, + { divider: true, command: '', positionOrder: 52 }, + { + command: 'clear-sort', + title: 'Remove Sort', + positionOrder: 58, + // Slot renderer with status indicator + slotRenderer: () => ` +
+ + Remove Sort + +
+ `, + }, + ], + }, + }, + }, + { + id: 'start', + name: 'Start', + field: 'start', + sortable: true, + formatter: Formatters.dateIso, + filterable: true, + filter: { model: Filters.compoundDate }, + minWidth: 100, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + sortable: true, + formatter: Formatters.dateIso, + filterable: true, + filter: { model: Filters.dateRange }, + minWidth: 100, + }, + { + id: 'cost', + name: 'Cost', + field: 'cost', + width: 90, + sortable: true, + filterable: true, + formatter: Formatters.dollar, + // Demo: Header Menu with Slot - showing slotRenderer with callback (item, args) + header: { + menu: { + commandItems: [ + { + command: 'custom-action', + title: 'Advanced Export', + // Demo: Native HTMLElement with event listeners using slotRenderer (full DOM control) + slotRenderer: (item, _args) => { + const containerDiv = document.createElement('div'); + containerDiv.style.cssText = 'display: flex; align-items: center;'; + + const iconDiv = document.createElement('div'); + iconDiv.style.cssText = + 'width: 18px; height: 18px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 3px; display: flex; align-items: center; justify-content: center; margin-right: 4px; transition: transform 0.2s;'; + iconDiv.innerHTML = '📊'; + + const textSpan = document.createElement('span'); + textSpan.style.cssText = 'flex: 1;'; + textSpan.textContent = item.title; + + const kbdElm = document.createElement('kbd'); + kbdElm.style.cssText = + 'background: #eee; border: 1px solid #ccc; border-radius: 2px; padding: 2px 4px; font-size: 10px; margin-left: 10px; display: inline-flex; align-items: center; height: 18px;'; + kbdElm.textContent = 'Ctrl+E'; + + containerDiv.appendChild(iconDiv); + containerDiv.appendChild(textSpan); + containerDiv.appendChild(kbdElm); + + // Add native event listeners for hover effects + containerDiv.addEventListener('mouseenter', () => { + iconDiv.style.transform = 'scale(1.1)'; + containerDiv.style.backgroundColor = '#f5f5f5'; + }); + containerDiv.addEventListener('mouseleave', () => { + iconDiv.style.transform = 'scale(1)'; + containerDiv.style.backgroundColor = ''; + }); + + return containerDiv; + }, + action: (_e, _args) => { + alert('Custom export action triggered!'); + }, + }, + { divider: true, command: '', positionOrder: 48 }, + { + command: 'filter-column', + title: 'Filter Column', + positionOrder: 55, + // Slot renderer with status indicator and beta badge + slotRenderer: () => ` +
+ + Filter Column + BETA +
+ `, + }, + ], + }, + }, + }, + { + id: 'percentComplete', + name: '% Complete', + field: 'percentComplete', + sortable: true, + filterable: true, + filter: { model: Filters.slider, operator: '>=' }, + // Demo: Header Menu with Slot - showing interactive element (checkbox) + header: { + menu: { + commandItems: [ + { + command: 'auto-refresh', + title: 'Auto Refresh', + positionOrder: 45, + iconCssClass: 'mdi mdi-refresh', + }, + ], + }, + }, + }, + { + id: 'action', + name: 'Action', + field: 'action', + width: 70, + minWidth: 70, + maxWidth: 70, + cssClass: 'justify-center flex', + formatter: () => `
`, + excludeFromExport: true, + // Demo: Cell Menu with slot examples (demonstrating defaultItemRenderer at menu level) + cellMenu: { + hideCloseButton: false, + commandTitle: 'Cell Actions', + // Demo: Menu-level default renderer that applies to all items unless overridden + defaultItemRenderer: (item, _args) => { + return ` +
+ ${item.iconCssClass ? `` : ''} + ${item.title} +
+ `; + }, + commandItems: [ + { + command: 'copy-cell', + title: 'Copy Cell Value', + positionOrder: 50, + iconCssClass: 'mdi mdi-content-copy', + action: (_e, args) => { + console.log('Copy cell value:', args.dataContext[args.column.field]); + alert(`Copied: ${args.dataContext[args.column.field]}`); + }, + }, + 'divider', + { + command: 'export-row', + title: 'Export Row', + positionOrder: 51, + iconCssClass: 'mdi mdi-download', + action: (_e, args) => { + console.log('Export row:', args.dataContext); + alert(`Export row #${args.dataContext.id}`); + }, + }, + { + command: 'export', + title: 'Export', + positionOrder: 52, + iconCssClass: 'mdi mdi-download', + commandItems: [ + { + command: 'export-excel', + title: 'Export as Excel', + iconCssClass: 'mdi mdi-file-excel-outline text-success', + action: (_e, args) => { + alert(`Export row #${args.dataContext.id} to Excel`); + }, + }, + { + command: 'export-csv', + title: 'Export as CSV', + iconCssClass: 'mdi mdi-file-document-outline', + action: (_e, args) => { + alert(`Export row #${args.dataContext.id} to CSV`); + }, + }, + { + command: 'export-pdf', + title: 'Export as PDF', + iconCssClass: 'mdi mdi-file-pdf-outline text-red', + action: (_e, args) => { + alert(`Export row #${args.dataContext.id} to PDF`); + }, + }, + ], + }, + { divider: true, command: '', positionOrder: 52 }, + { + command: 'edit-row', + title: 'Edit Row', + positionOrder: 53, + // Individual slotRenderer overrides the defaultItemRenderer + slotRenderer: (_item, args) => ` +
+
+ +
+ Edit Row #${args.dataContext.id} +
+ `, + action: (_e, args) => { + console.log('Edit row:', args.dataContext); + alert(`Edit row #${args.dataContext.id}`); + }, + }, + { + command: 'delete-row', + title: 'Delete Row', + positionOrder: 54, + iconCssClass: 'mdi mdi-delete', + action: (_e, args) => { + if (confirm(`Delete row #${args.dataContext.id}?`)) { + console.log('Delete row:', args.dataContext); + alert(`Deleted row #${args.dataContext.id}`); + } + }, + }, + ], + }, + }, + ]; + + this.gridOptions = { + autoResize: { + container: '.demo-container', + }, + enableAutoResize: true, + enableCellNavigation: true, + enableFiltering: true, + enableSorting: true, + + // Header Menu with slots (already configured in columns above) + enableHeaderMenu: true, + headerMenu: { + hideColumnHideCommand: false, + // Demo: Menu-level default renderer for all header menu items + defaultItemRenderer: (item, _args) => { + return ` +
+ ${item.iconCssClass ? `` : ''} + ${item.title} +
+ `; + }, + }, + + // Cell Menu with slots (configured in the Action column above) + enableCellMenu: true, + + // Context Menu with slot examples + enableContextMenu: true, + contextMenu: { + // Demo: Menu-level default renderer for context menu items + defaultItemRenderer: (item, _args) => { + return ` +
+ ${item.iconCssClass ? `` : ''} + ${item.title} +
+ `; + }, + commandItems: [ + { + positionOrder: 60, + command: 'edit-cell', + title: 'Edit Cell', + // Demo: Individual slotRenderer overrides the menu's defaultItemRenderer + slotRenderer: (item, _args) => { + const containerDiv = document.createElement('div'); + containerDiv.style.cssText = 'display: flex; align-items: center;'; + + const iconDiv = document.createElement('div'); + iconDiv.style.cssText = + 'width: 18px; height: 18px; background: linear-gradient(135deg, #00c853 0%, #64dd17 100%); border-radius: 3px; display: flex; align-items: center; justify-content: center; margin-right: 4px; transition: all 0.2s;'; + iconDiv.innerHTML = ''; + + const textSpan = document.createElement('span'); + textSpan.style.cssText = 'flex: 1;'; + textSpan.textContent = item.title; + + const kbdElm = document.createElement('kbd'); + kbdElm.style.cssText = + 'background: #eee; border: 1px solid #ccc; border-radius: 2px; padding: 2px 4px; font-size: 10px; margin-left: 10px; display: inline-flex; align-items: center; height: 18px;'; + kbdElm.textContent = 'F2'; + + containerDiv.appendChild(iconDiv); + containerDiv.appendChild(textSpan); + containerDiv.appendChild(kbdElm); + + // Native event listeners for interactive effects + containerDiv.addEventListener('mouseenter', () => { + iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; + iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; + }); + containerDiv.addEventListener('mouseleave', () => { + iconDiv.style.transform = 'rotate(0deg) scale(1)'; + iconDiv.style.boxShadow = 'none'; + }); + + return containerDiv; + }, + action: () => alert('Edit cell'), + }, + { divider: true, command: '' }, + { + command: 'export', + title: 'Export', + iconCssClass: 'mdi mdi-download', + commandItems: [ + { + command: 'export-excel', + title: 'Export as Excel', + iconCssClass: 'mdi mdi-file-excel-outline text-success', + action: () => alert('Export to Excel'), + }, + { + command: 'export-csv', + title: 'Export as CSV', + iconCssClass: 'mdi mdi-file-document-outline', + action: () => alert('Export to CSV'), + }, + { + command: 'export-pdf', + title: 'Export as PDF', + iconCssClass: 'mdi mdi-file-pdf-outline text-danger', + action: () => alert('Export to PDF'), + }, + ], + }, + { divider: true, command: '' }, + { + command: 'delete-row', + title: 'Delete Row', + iconCssClass: 'mdi mdi-delete', + action: () => alert('Delete row'), + }, + ], + }, + + // Grid Menu with slot examples (demonstrating defaultItemRenderer at menu level) + enableGridMenu: true, + gridMenu: { + // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) + defaultItemRenderer: (item, _args) => { + return ` +
+ ${item.iconCssClass ? `` : ''} + ${item.title} +
+ `; + }, + commandItems: [ + { + command: 'toggle-filter', + title: 'Toggle Filter Row', + iconCssClass: 'mdi mdi-filter-outline', + action: () => alert('Toggle filter row'), + }, + { + command: 'clear-filters', + title: 'Clear All Filters', + iconCssClass: 'mdi mdi-filter-remove-outline', + action: () => alert('Clear filters'), + }, + { divider: true, command: '' }, + { + command: 'export-excel', + title: 'Export to Excel', + iconCssClass: 'mdi mdi-file-excel-outline', + action: () => alert('Export to Excel'), + }, + { + command: 'export-csv', + title: 'Export to CSV', + iconCssClass: 'mdi mdi-download', + // Individual slotRenderer overrides the defaultItemRenderer for this item + slotRenderer: (item, _args) => ` +
+ + ${item.title} + CUSTOM +
+ `, + action: () => alert('Export to CSV'), + }, + { + command: 'refresh-data', + title: 'Refresh Data', + iconCssClass: 'mdi mdi-refresh', + // Demo: slotRenderer with keyboard shortcut + slotRenderer: (item) => ` +
+ + ${item.title} + F5 +
+ `, + action: () => alert('Refresh data'), + }, + ], + }, + }; + } + + loadData(count: number): ReportItem[] { + const tmpData: ReportItem[] = []; + for (let i = 0; i < count; i++) { + const randomDuration = Math.round(Math.random() * 100); + const randomYear = 2000 + Math.floor(Math.random() * 10); + const randomMonth = Math.floor(Math.random() * 11); + const randomDay = Math.floor(Math.random() * 29); + const randomPercent = Math.round(Math.random() * 100); + + tmpData[i] = { + id: i, + title: 'Task ' + i, + duration: randomDuration, + cost: Math.round(Math.random() * 10000) / 100, + percentComplete: randomPercent, + start: new Date(randomYear, randomMonth, randomDay), + finish: new Date(randomYear, randomMonth + 1, randomDay), + }; + } + return tmpData; + } +} diff --git a/docs/column-functionalities/cell-menu.md b/docs/column-functionalities/cell-menu.md index 8dbbb14c5c..32a869b81f 100644 --- a/docs/column-functionalities/cell-menu.md +++ b/docs/column-functionalities/cell-menu.md @@ -140,6 +140,11 @@ this.gridOptions = { }; ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultItemRenderer` callbacks to create custom HTML or HTMLElement content for your menu items. This allows you to add badges, keyboard shortcuts, status indicators, and more. + +See [Custom Menu Slots](../../menu-slots.md) for detailed examples and best practices on rendering custom menu item content across all menu types. + ### Override Callback Methods What if you want to dynamically disable or hide a Command/Option or even disable the entire menu in certain circumstances? For these cases, you would use the override callback methods, the method must return a `boolean`. The list of override available are the following - `menuUsabilityOverride` returning false would make the Cell Menu unavailable to the user diff --git a/docs/grid-functionalities/context-menu.md b/docs/grid-functionalities/context-menu.md index 4b9ecd1b42..e7f883b257 100644 --- a/docs/grid-functionalities/context-menu.md +++ b/docs/grid-functionalities/context-menu.md @@ -122,6 +122,11 @@ contextMenu: { } ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultItemRenderer` callbacks to create custom HTML or HTMLElement content for your menu items. This allows you to add badges, keyboard shortcuts, status indicators, and more. + +See [Custom Menu Slots](../menu-slots.md) for detailed examples and best practices on rendering custom menu item content across all menu types. + ### Override Callback Methods What if you want to dynamically disable or hide a Command/Option or even disable the entire menu in certain circumstances? For these cases, you would use the override callback methods, the method must return a `boolean`. The list of override available are the following - `menuUsabilityOverride` returning false would make the Context Menu unavailable to the user diff --git a/docs/grid-functionalities/grid-menu.md b/docs/grid-functionalities/grid-menu.md index e69d0c03ee..1fc525998f 100644 --- a/docs/grid-functionalities/grid-menu.md +++ b/docs/grid-functionalities/grid-menu.md @@ -105,6 +105,11 @@ gridMenu: { For more info on all the available properties of the custom commands, you can read refer to the doc written in the Grid Menu [implementation](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/extensions/slickGridMenu.ts) itself. +### Custom Menu Item Rendering +To customize the appearance of menu items with custom HTML, badges, icons, or interactive elements, you can use the `slotRenderer` or `defaultItemRenderer` callbacks. + +See [Custom Menu Slots](../menu-slots.md) for detailed examples and best practices on rendering custom menu item content. + ### How to change an icon of all default commands? You can change any of the default command icon(s) by changing the `icon[X-command]`, for example, see below for the defaults. ```ts diff --git a/docs/grid-functionalities/header-menu-header-buttons.md b/docs/grid-functionalities/header-menu-header-buttons.md index 555e7ca51c..4f785f43fb 100644 --- a/docs/grid-functionalities/header-menu-header-buttons.md +++ b/docs/grid-functionalities/header-menu-header-buttons.md @@ -58,6 +58,11 @@ There are 2 callback hooks which are accessible in the Grid Options For more info on all the available properties of the custom commands, you can read refer to the doc written in the Grid Menu [implementation](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/extensions/slickHeaderButtons.ts) itself. +### Custom Menu Item Rendering +To customize the appearance of menu items with custom HTML, badges, icons, or interactive elements, you can use the `slotRenderer` or `defaultItemRenderer` callbacks. + +See [Custom Menu Slots](../menu-slots.md) for detailed examples and best practices on rendering custom menu item content. + ### How to change icon(s) of the default commands? You can change any of the default command icon(s) by changing the `icon[X-command]`, for example, see below for the defaults. ```ts diff --git a/docs/menu-slots.md b/docs/menu-slots.md new file mode 100644 index 0000000000..74d892b2c3 --- /dev/null +++ b/docs/menu-slots.md @@ -0,0 +1,496 @@ +## Custom Menu Slots - Rendering + +All menu plugins (Header Menu, Cell Menu, Context Menu, Grid Menu) support **cross-framework compatible slot rendering** for custom content injection in menu items. This is achieved through the `slotRenderer` callback at the item level combined with an optional `defaultItemRenderer` at the menu level. + +### Core Concept + +Each menu item can define a `slotRenderer` callback function that receives the item and args, and returns either an HTML string or an HTMLElement. This single API works uniformly across all menu plugins. + +### Slot Renderer Callback + +```typescript +slotRenderer?: (item: any, args: any, event?: Event) => string | HTMLElement +``` + +- **item** - The menu item object containing command, title, iconCssClass, etc. +- **args** - The callback args providing access to grid, column, dataContext, and other context +- **event** - Optional DOM event passed during click handling (allows `stopPropagation()`) + +### Basic Example - HTML String Rendering + +```typescript +const menuItem = { + command: 'custom-command', + title: 'Custom Action', + iconCssClass: 'mdi mdi-star', + // Return custom HTML string for the entire menu item + slotRenderer: () => ` +
+ + Custom Action + NEW +
+ ` +}; +``` + +### Advanced Example - HTMLElement Objects + +```typescript +// Create custom element with full DOM control +const menuItem = { + command: 'notifications', + title: 'Notifications', + // Return HTMLElement for more control and event listeners + slotRenderer: (item, args) => { + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.alignItems = 'center'; + + const icon = document.createElement('i'); + icon.className = 'mdi mdi-bell'; + icon.style.marginRight = '8px'; + + const text = document.createElement('span'); + text.textContent = item.title; + + const badge = document.createElement('span'); + badge.className = 'badge'; + badge.textContent = '5'; + badge.style.marginLeft = 'auto'; + + container.appendChild(icon); + container.appendChild(text); + container.appendChild(badge); + + return container; + } +}; +``` + +### Default Menu-Level Renderer + +Set a `defaultItemRenderer` at the menu option level to apply to all items (unless overridden by individual `slotRenderer`): + +```typescript +const menuOption = { + // Apply this renderer to all menu items (can be overridden per item) + defaultItemRenderer: (item, args) => { + return ` +
+ ${item.iconCssClass ? `` : ''} + ${item.title} +
+ `; + }, + commandItems: [ + { + command: 'action-1', + title: 'Action One', + iconCssClass: 'mdi mdi-check', + // This item uses defaultItemRenderer + }, + { + command: 'custom', + title: 'Custom Item', + // This item overrides defaultItemRenderer with its own slotRenderer + slotRenderer: () => ` +
+ Custom rendering overrides default +
+ ` + } + ] +}; +``` + +### Menu Types & Configuration + +The `slotRenderer` and `defaultItemRenderer` work identically across all menu plugins: + +#### Header Menu +```typescript +const columnDef = { + id: 'name', + header: { + menu: { + defaultItemRenderer: (item, args) => `
${item.title}
`, + commandItems: [ + { + command: 'sort', + title: 'Sort', + slotRenderer: () => '
Custom sort
' + } + ] + } + } +}; +``` + +#### Cell Menu +```typescript +const columnDef = { + id: 'action', + cellMenu: { + defaultItemRenderer: (item, args) => `
${item.title}
`, + commandItems: [ + { + command: 'edit', + title: 'Edit', + slotRenderer: (item, args) => `
Edit row ${args.dataContext.id}
` + } + ] + } +}; +``` + +#### Context Menu +```typescript +const gridOptions = { + enableContextMenu: true, + contextMenu: { + defaultItemRenderer: (item, args) => `
${item.title}
`, + commandItems: [ + { + command: 'export', + title: 'Export', + slotRenderer: () => '
📊 Export data
' + } + ] + } +}; +``` + +#### Grid Menu +```typescript +const gridOptions = { + enableGridMenu: true, + gridMenu: { + defaultItemRenderer: (item, args) => `
${item.title}
`, + commandItems: [ + { + command: 'refresh', + title: 'Refresh', + slotRenderer: () => '
🔄 Refresh data
' + } + ] + } +}; +``` + +### Framework Integration Examples + +#### Vanilla JavaScript +```typescript +const menuItem = { + command: 'custom', + title: 'Action', + slotRenderer: () => ` + + ` +}; +``` + +#### Angular - Dynamic Components +```typescript +// In component class +const menuItem = { + command: 'with-component', + title: 'With Angular Component', + slotRenderer: (item, args) => { + // Create a placeholder element + const placeholder = document.createElement('div'); + placeholder.id = `angular-slot-${Date.now()}`; + + // Schedule component creation for after rendering + setTimeout(() => { + const element = document.getElementById(placeholder.id); + if (element) { + const componentRef = this.viewContainerRef.createComponent(MyComponent); + element.appendChild(componentRef.location.nativeElement); + } + }, 0); + + return placeholder; + } +}; +``` + +#### React - Using Hooks +```typescript +// Define menu item with slotRenderer +const menuItem = { + command: 'with-react', + title: 'With React Component', + slotRenderer: (item, args) => { + const container = document.createElement('div'); + container.id = `react-slot-${Date.now()}`; + + // Schedule component render for after menu renders + setTimeout(() => { + const element = document.getElementById(container.id); + if (element) { + ReactDOM.render(, element); + } + }, 0); + + return container; + } +}; +``` + +#### Vue - Using createApp +```typescript +// Define menu item with slotRenderer +const menuItem = { + command: 'with-vue', + title: 'With Vue Component', + slotRenderer: (item, args) => { + const container = document.createElement('div'); + container.id = `vue-slot-${Date.now()}`; + + // Schedule component mount for after menu renders + setTimeout(() => { + const element = document.getElementById(container.id); + if (element && !element._appInstance) { + const app = createApp(MyComponent, { data: args }); + app.mount(element); + element._appInstance = app; + } + }, 0); + + return container; + } +}; +``` + +### Real-World Use Cases + +#### 1. Add Keyboard Shortcuts +```typescript +{ + command: 'copy', + title: 'Copy', + iconCssClass: 'mdi mdi-content-copy', + slotRenderer: () => ` +
+ + Copy + Ctrl+C +
+ ` +} +``` + +#### 2. Add Status Indicators +```typescript +{ + command: 'filter', + title: 'Filter', + iconCssClass: 'mdi mdi-filter', + slotRenderer: () => ` +
+ + Filter + +
+ ` +} +``` + +#### 3. Add Dynamic Content Based on Context +```typescript +{ + command: 'edit-row', + title: 'Edit Row', + slotRenderer: (item, args) => ` +
+ + Edit Row #${args.dataContext?.id || 'N/A'} +
+ ` +} +``` + +#### 4. Add Interactive Elements +```typescript +{ + command: 'toggle-setting', + title: 'Auto Refresh', + slotRenderer: (item, args, event) => { + const container = document.createElement('label'); + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.gap = '8px'; + container.style.marginRight = 'auto'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.addEventListener('change', (e) => { + // Prevent menu item click from firing when toggling checkbox + event?.stopPropagation?.(); + console.log('Auto refresh:', checkbox.checked); + }); + + const label = document.createElement('span'); + label.textContent = item.title; + + container.appendChild(label); + container.appendChild(checkbox); + return container; + } +} +``` + +#### 5. Add Badges and Status Labels +```typescript +{ + command: 'export-excel', + title: 'Export as Excel', + slotRenderer: (item, args) => ` +
+ + ${item.title} + RECOMMENDED +
+ ` +} +``` + +#### 6. Gradient and Styled Icons +```typescript +{ + command: 'advanced-export', + title: 'Advanced Export', + slotRenderer: (item, args) => { + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.gap = '8px'; + + const iconDiv = document.createElement('div'); + iconDiv.style.width = '20px'; + iconDiv.style.height = '20px'; + iconDiv.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; + iconDiv.style.borderRadius = '4px'; + iconDiv.style.display = 'flex'; + iconDiv.style.alignItems = 'center'; + iconDiv.style.justifyContent = 'center'; + iconDiv.style.color = 'white'; + iconDiv.style.fontSize = '12px'; + iconDiv.innerHTML = '📊'; + + const textSpan = document.createElement('span'); + textSpan.textContent = item.title; + + container.appendChild(iconDiv); + container.appendChild(textSpan); + return container; + } +} +``` + +### Notes and Best Practices + +- **HTML strings** are inserted via `innerHTML` - ensure content is sanitized if user-provided +- **HTMLElement objects** are appended directly - safer for dynamic content and allows event listeners +- **Cross-framework compatible** - works in vanilla JS, Angular, React, Vue, Aurelia using the same API +- **Priority order** - Item-level `slotRenderer` overrides menu-level `defaultItemRenderer` +- **Accessibility** - Include proper ARIA attributes when creating custom elements +- **Event handling** - Call `event.stopPropagation()` in interactive elements to prevent menu commands from firing +- **Default fallback** - If neither `slotRenderer` nor `defaultItemRenderer` is provided, the default icon + text rendering is used +- **Performance** - Avoid heavy DOM manipulation inside renderer callbacks (they may be called multiple times) +- **Event parameter** - The optional `event` parameter is passed during click handling and allows you to control menu behavior +- **All menus supported** - This API works uniformly across Header Menu, Cell Menu, Context Menu, and Grid Menu + +### Styling Custom Menu Items + +```css +/* Example CSS for styled menu items */ +.slick-menu-item { + padding: 4px 8px; +} + +.slick-menu-item div { + display: flex; + align-items: center; + gap: 8px; +} + +.slick-menu-item kbd { + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 3px; + padding: 2px 6px; + font-size: 11px; + font-family: monospace; + color: #666; +} + +.slick-menu-item .badge { + background: #ff6b6b; + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 9px; + font-weight: bold; + white-space: nowrap; +} + +.slick-menu-item:hover { + background: #f5f5f5; +} + +.slick-menu-item.slick-menu-item-disabled { + opacity: 0.5; + cursor: not-allowed; +} +``` + +### Migration from Static Rendering + +**Before (Static HTML Title):** +```typescript +{ + command: 'action', + title: 'Action ⭐', // Emoji embedded in title + iconCssClass: 'mdi mdi-star' +} +``` + +**After (Custom Rendering):** +```typescript +{ + command: 'action', + title: 'Action', + slotRenderer: () => ` +
+ + Action + +
+ ` +} +``` + +### Error Handling + +When creating custom renderers, handle potential errors gracefully: + +```typescript +{ + command: 'safe-render', + title: 'Safe Render', + slotRenderer: (item, args) => { + try { + if (args?.dataContext?.status === 'error') { + return `
❌ Error loading
`; + } + return `
✓ Data loaded
`; + } catch (error) { + console.error('Render error:', error); + return '
Render error
'; + } + } +} +``` diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example-header-menu-slots.html b/examples/vite-demo-vanilla-bundle/src/examples/example-header-menu-slots.html new file mode 100644 index 0000000000..8c5544182c --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example-header-menu-slots.html @@ -0,0 +1,227 @@ + + + + + + Header Menu with Slots - Example + + + +

Header Menu with Slots - Example

+

This example demonstrates the new slot functionality in Header Menu items.

+ +
+ +
+

Slot Examples Demonstrated:

+
    +
  • Resize by Content - Badge using slotContentAfter (HTML string)
  • +
  • Sort Commands - Keyboard shortcuts using slotContentAfter
  • +
  • Clear Sort - Status indicator using slotIconAfter (HTMLElement)
  • +
  • Custom Action - Custom renderer with complete control
  • +
+
+ + + + diff --git a/packages/common/src/extensions/menuBaseClass.ts b/packages/common/src/extensions/menuBaseClass.ts index e2ed767321..c20b33cd2b 100644 --- a/packages/common/src/extensions/menuBaseClass.ts +++ b/packages/common/src/extensions/menuBaseClass.ts @@ -131,6 +131,28 @@ export class MenuBaseClass string | HTMLElement, + item: any, + args: any + ): void { + const result = slotRenderer(item, args); + if (typeof result === 'string') { + parentElm.innerHTML = this.grid.sanitizeHtmlString(result); + } else if (result instanceof HTMLElement) { + parentElm.appendChild(result); + } + } + protected addSubMenuTitleWhenExists(item: ExtractMenuType, commandOrOptionMenu: HTMLDivElement): void { if (item !== 'divider' && (item as MenuCommandItem | MenuOptionItem | GridMenuItem)?.subMenuTitle) { const subMenuTitleElm = document.createElement('div'); @@ -288,27 +310,47 @@ export class MenuBaseClass) => - itemClickCallback.call(this, e, itemType, item, level, args?.column)) as EventListener, + ((e: DOMMouseOrTouchEvent) => { + // If there's a slotRenderer, call it with the event + const slotRenderer = (item as MenuCommandItem | MenuOptionItem).slotRenderer || (this._addonOptions as any).defaultItemRenderer; + if (slotRenderer) { + slotRenderer(item as MenuCommandItem | MenuOptionItem, args, e); + } + + // If the click was stopped by an interactive element handler, don't trigger the menu action + if (e.defaultPrevented) { + return; + } + + itemClickCallback.call(this, e, itemType, item, level, args?.column); + }) as EventListener, undefined, eventGroupName ); diff --git a/packages/common/src/interfaces/cellMenuOption.interface.ts b/packages/common/src/interfaces/cellMenuOption.interface.ts index 39cfe7c1aa..04ebe5ff0a 100644 --- a/packages/common/src/interfaces/cellMenuOption.interface.ts +++ b/packages/common/src/interfaces/cellMenuOption.interface.ts @@ -79,6 +79,20 @@ export interface CellMenuOption { // -- // action/override callbacks + /** + * Default slot renderer for all menu items. + * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. + * The renderer receives both the menu item and args for full context access. + * + * @param item - The menu item object (MenuCommandItem or MenuOptionItem) + * @param args - The callback args providing access to grid, column, dataContext, etc. + * @returns Either an HTML string or an HTMLElement + * + * @example + * defaultItemRenderer: (item, args) => `
${item.title}
` + */ + defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; + /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ menuUsabilityOverride?: (args: MenuCallbackArgs) => boolean; } diff --git a/packages/common/src/interfaces/contextMenuOption.interface.ts b/packages/common/src/interfaces/contextMenuOption.interface.ts index 04375fb1fa..a83aaf65a9 100644 --- a/packages/common/src/interfaces/contextMenuOption.interface.ts +++ b/packages/common/src/interfaces/contextMenuOption.interface.ts @@ -134,6 +134,20 @@ export interface ContextMenuOption { // -- // action/override callbacks + /** + * Default slot renderer for all menu items. + * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. + * The renderer receives both the menu item and args for full context access. + * + * @param item - The menu item object (MenuCommandItem or MenuOptionItem) + * @param args - The callback args providing access to grid, column, dataContext, etc. + * @returns Either an HTML string or an HTMLElement + * + * @example + * defaultItemRenderer: (item, args) => `
${item.title}
` + */ + defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; + /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ menuUsabilityOverride?: (args: MenuCallbackArgs) => boolean; } diff --git a/packages/common/src/interfaces/gridMenuOption.interface.ts b/packages/common/src/interfaces/gridMenuOption.interface.ts index 3fe30b59d1..32f7e92f16 100644 --- a/packages/common/src/interfaces/gridMenuOption.interface.ts +++ b/packages/common/src/interfaces/gridMenuOption.interface.ts @@ -189,6 +189,20 @@ export interface GridMenuOption { // -- // action/override callbacks + /** + * Default slot renderer for all menu items. + * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. + * The renderer receives both the menu item and args for full context access. + * + * @param item - The menu item object (MenuCommandItem or MenuOptionItem) + * @param args - The callback args providing access to grid, column, dataContext, etc. + * @returns Either an HTML string or an HTMLElement + * + * @example + * defaultItemRenderer: (item, args) => `
${item.title}
` + */ + defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; + /** Callback method to override the column name output used by the ColumnPicker/GridMenu. */ headerColumnValueExtractor?: (column: Column, gridOptions?: GridOption) => string | HTMLElement | DocumentFragment; diff --git a/packages/common/src/interfaces/headerMenuOption.interface.ts b/packages/common/src/interfaces/headerMenuOption.interface.ts index a34af91931..3fe873e03e 100644 --- a/packages/common/src/interfaces/headerMenuOption.interface.ts +++ b/packages/common/src/interfaces/headerMenuOption.interface.ts @@ -99,6 +99,20 @@ export interface HeaderMenuOption { // -- // Methods + /** + * Default slot renderer for all menu items. + * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. + * The renderer receives both the menu item and args for full context access. + * + * @param item - The menu item object (MenuCommandItem or MenuOptionItem) + * @param args - The callback args providing access to grid, column, dataContext, etc. + * @returns Either an HTML string or an HTMLElement + * + * @example + * defaultItemRenderer: (item, args) => `
${item.title}
` + */ + defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; + /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ menuUsabilityOverride?: (args: { grid: SlickGrid; column: Column; menu: HTMLElement }) => boolean; } diff --git a/packages/common/src/interfaces/menuItem.interface.ts b/packages/common/src/interfaces/menuItem.interface.ts index fb541b8db0..1d4bffa31d 100644 --- a/packages/common/src/interfaces/menuItem.interface.ts +++ b/packages/common/src/interfaces/menuItem.interface.ts @@ -43,6 +43,41 @@ export interface MenuItem { /** Item tooltip to show while hovering the command. */ tooltip?: string; + // -- + // Slot support for custom content injection (cross-framework compatible) + + /** + * Slot renderer callback for the entire menu item. + * @param item - The menu item object + * @param args - The callback args providing access to grid, column, dataContext, etc. + * @param event - Optional DOM event (passed during click handling) that allows the renderer to call stopPropagation() + * @returns Either an HTML string or an HTMLElement + * + * @example + * // Return HTML string + * slotRenderer: (item, args) => `
${item.title}
` + * + * // Return HTMLElement with event listeners + * slotRenderer: (item, args) => { + * const div = document.createElement('div'); + * div.textContent = `${item.title} (Row ${args.dataContext.id})`; + * div.addEventListener('click', () => console.log('clicked')); + * return div; + * } + * + * // Interactive element that prevents menu action + * slotRenderer: (item, args, event) => { + * const checkbox = document.createElement('input'); + * checkbox.type = 'checkbox'; + * checkbox.addEventListener('change', (e) => { + * event?.stopPropagation(); // Stop the menu item click from firing + * console.log('checkbox toggled'); + * }); + * return checkbox; + * } + */ + slotRenderer?: (item: any, args: O, event?: Event) => string | HTMLElement; + // -- // action/override callbacks From 30bdb7b3bf001407b66550a71eade54e7705fccf Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 10 Feb 2026 00:09:00 -0500 Subject: [PATCH 02/24] chore: improve slot demo & code implementation --- demos/vanilla/src/examples/example12.ts | 2 +- demos/vanilla/src/examples/example40.html | 22 +- demos/vanilla/src/examples/example40.scss | 137 +++++++++++ demos/vanilla/src/examples/example40.ts | 223 ++++++++++-------- packages/common/src/extensions/index.ts | 1 + .../common/src/extensions/menuBaseClass.ts | 104 +++----- .../interfaces/cellMenuOption.interface.ts | 25 +- .../interfaces/contextMenuOption.interface.ts | 25 +- .../interfaces/gridMenuOption.interface.ts | 32 +-- .../interfaces/headerMenuOption.interface.ts | 25 +- .../src/interfaces/menuItem.interface.ts | 11 - .../src/interfaces/menuOption.interface.ts | 32 +++ 12 files changed, 347 insertions(+), 292 deletions(-) create mode 100644 demos/vanilla/src/examples/example40.scss create mode 100644 packages/common/src/interfaces/menuOption.interface.ts diff --git a/demos/vanilla/src/examples/example12.ts b/demos/vanilla/src/examples/example12.ts index d9a0bfb4dd..91374ac716 100644 --- a/demos/vanilla/src/examples/example12.ts +++ b/demos/vanilla/src/examples/example12.ts @@ -240,7 +240,7 @@ export default class Example12 { minValue: 0, maxValue: 100, }, - customTooltip: { position: 'center' }, + customTooltip: { position: 'center' }, }, // { // id: 'percentComplete2', name: '% Complete', field: 'analysis.percentComplete', minWidth: 100, diff --git a/demos/vanilla/src/examples/example40.html b/demos/vanilla/src/examples/example40.html index b4e9efd3dd..b5fa4aa2b2 100644 --- a/demos/vanilla/src/examples/example40.html +++ b/demos/vanilla/src/examples/example40.html @@ -1,6 +1,10 @@

Example 40 - Menus with Slots - (Custom Menu Item Content) + (Custom Menu Item Renderer) + +

-
+

- Menu Slots Demo + Menu Slots Demo with Custom Renderer

Click on the menu buttons to see the new single slot functionality working across all menu types (Header Menu, Cell Menu, Context Menu, Grid Menu):

-
    -
  • - Header Menu - Click column header buttons (⋮) to see custom HTML with keyboard shortcuts, badges, and interactive - elements -
  • -
  • - Cell Menu - Click the chevron button (⌄) in the Action column to see cell-specific commands with - slots -
  • -
  • Context Menu - Right-click on any cell to see edit/delete commands with custom rendering
  • -
  • Grid Menu - Click the grid menu button (top-right) to see filter/export commands with status indicators
  • -

Note: The demo focuses on the custom rendering capability via slotRenderer and diff --git a/demos/vanilla/src/examples/example40.scss b/demos/vanilla/src/examples/example40.scss new file mode 100644 index 0000000000..9c7e16f377 --- /dev/null +++ b/demos/vanilla/src/examples/example40.scss @@ -0,0 +1,137 @@ +body { + --slick-menu-item-height: 30px; + --slick-menu-line-height: 30px; + --slick-column-picker-item-height: 28px; + --slick-column-picker-line-height: 28px; + --slick-menu-item-border-radius: 4px; + --slick-menu-item-hover-border: 1px solid #148dff; + --slick-column-picker-item-hover-color: #fff; + --slick-column-picker-item-border-radius: 4px; + --slick-column-picker-item-hover-border: 1px solid #148dff; + --slick-menu-item-hover-color: #fff; + .slick-cell-menu, + .slick-context-menu, + .slick-grid-menu, + .slick-header-menu { + .slick-menu-item:hover { + color: #0a34b5; + } + } + .slick-menu-footer { + padding: 4px 6px; + border-top: 1px solid #c0c0c0; + } +} + +.key-hint { + background: #eee; + border: 1px solid #ccc; + border-radius: 2px; + padding: 2px 4px; + font-size: 10px; + margin-left: 10px; + white-space: nowrap; + display: inline-flex; + align-items: center; + height: 20px; + + &.beta, + &.danger, + &.warn { + color: white; + font-size: 8px; + font-weight: bold; + } + &.beta { + background: #4444ff; + border: 1px solid #5454ff; + } + + &.danger { + background: #ff4444; + border: 1px solid #fb5a5a; + } + + &.warn { + background: #ff9800; + border: 1px solid #fba321; + } +} + +.edit-cell { + background: #eee; + border: 1px solid #ccc; + border-radius: 2px; + padding: 2px 4px; + font-size: 10px; + margin-left: 10px; + display: inline-flex; + align-items: center; + height: 18px; +} + +.export-timestamp { + background-color: #4c4c4c; + color: #fff; + padding: 8px; + border-radius: 4px; + position: absolute; + z-index: 999999; +} + +.advanced-export-icon, +.edit-cell-icon, +.recalc-icon { + width: 20px; + height: 20px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 4px; + transition: transform 0.2s; + color: white; + font-size: 10px; +} +.advanced-export-icon { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} +.edit-cell-icon { + background: linear-gradient(135deg, #00c853 0%, #64dd17 100%); +} +.recalc-icon { + background: linear-gradient(135deg, #c800a3 0%, #a31189 100%); +} + +.round-tag { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + background: #44ff44; + box-shadow: 0 0 4px #44ff44; + margin-left: 10px; +} + +.menu-item { + display: flex; + align-items: center; + flex: 1; + justify-content: space-between; + + .menu-item-label.warn { + flex: 1; + color: #f09000; + } +} +.menu-item-icon { + margin-right: 4px; + font-size: 18px; + &.warn { + color: #ff9800; + } +} + +.menu-item-label { + flex: 1; +} diff --git a/demos/vanilla/src/examples/example40.ts b/demos/vanilla/src/examples/example40.ts index 4d08c486c2..bcefe8227e 100644 --- a/demos/vanilla/src/examples/example40.ts +++ b/demos/vanilla/src/examples/example40.ts @@ -1,7 +1,10 @@ +import { format as tempoFormat } from '@formkit/tempo'; import { BindingEventService } from '@slickgrid-universal/binding'; -import { Filters, Formatters, type Column, type GridOption } from '@slickgrid-universal/common'; +import { createDomElement, Filters, Formatters, getOffset, type Column, type GridOption } from '@slickgrid-universal/common'; +import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; import { ExampleGridOptions } from './example-grid-options.js'; +import './example40.scss'; interface ReportItem { id: number; @@ -20,6 +23,7 @@ export default class Example40 { gridOptions: GridOption; dataset: ReportItem[]; sgb: SlickVanillaGridBundle; + subTitleStyle = 'display: block'; constructor() { this._bindingEventService = new BindingEventService(); @@ -45,11 +49,7 @@ export default class Example40 { initializeGrid() { // This example demonstrates Menu Slot functionality across all menu types: - // - SlickHeaderMenu (Title, Duration, Cost, % Complete columns) - // - SlickCellMenu (Action column with chevron button) - // - SlickContextMenu (right-click context menu) - // - SlickGridMenu (grid menu button) - // All demonstrate: slotRenderer callbacks (item, args) returning strings or HTMLElements + // - SlickHeaderMenu, SlickCellMenu, SlickContextMenu, SlickGridMenu this.columnDefinitions = [ { @@ -67,12 +67,12 @@ export default class Example40 { command: 'sort-asc', title: 'Sort Ascending', positionOrder: 50, - // Slot renderer replaces entire menu item content + // Slot renderer replaces entire menu item content (can be HTML string or native DOM elements) slotRenderer: () => ` -

- - Sort Ascending - Alt+↑ + `, }, @@ -80,13 +80,17 @@ export default class Example40 { command: 'sort-desc', title: 'Sort Descending', positionOrder: 51, - slotRenderer: () => ` -
- - Sort Descending - Alt+↓ -
- `, + // Slot renderer using native DOM elements + slotRenderer: () => { + const menuItemElm = createDomElement('div', { className: 'menu-item' }); + const iconElm = createDomElement('i', { className: 'mdi mdi-sort-descending menu-item-icon' }); + const menuItemLabelElm = createDomElement('span', { className: 'menu-item-label', textContent: 'Sort Descending' }); + const kbdElm = createDomElement('kbd', { className: 'key-hint', textContent: 'Alt+↓' }); + menuItemElm.appendChild(iconElm); + menuItemElm.appendChild(menuItemLabelElm); + menuItemElm.appendChild(kbdElm); + return menuItemElm; + }, }, ], }, @@ -109,10 +113,10 @@ export default class Example40 { positionOrder: 47, // Slot renderer with badge slotRenderer: () => ` -
- - Resize by Content - NEW + `, }, @@ -136,10 +140,10 @@ export default class Example40 { positionOrder: 58, // Slot renderer with status indicator slotRenderer: () => ` -
- - Remove Sort - + `, }, @@ -184,35 +188,31 @@ export default class Example40 { title: 'Advanced Export', // Demo: Native HTMLElement with event listeners using slotRenderer (full DOM control) slotRenderer: (item, _args) => { - const containerDiv = document.createElement('div'); - containerDiv.style.cssText = 'display: flex; align-items: center;'; - - const iconDiv = document.createElement('div'); - iconDiv.style.cssText = - 'width: 18px; height: 18px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 3px; display: flex; align-items: center; justify-content: center; margin-right: 4px; transition: transform 0.2s;'; - iconDiv.innerHTML = '📊'; - - const textSpan = document.createElement('span'); - textSpan.style.cssText = 'flex: 1;'; - textSpan.textContent = item.title; - - const kbdElm = document.createElement('kbd'); - kbdElm.style.cssText = - 'background: #eee; border: 1px solid #ccc; border-radius: 2px; padding: 2px 4px; font-size: 10px; margin-left: 10px; display: inline-flex; align-items: center; height: 18px;'; - kbdElm.textContent = 'Ctrl+E'; - + // you can use `createDomElement()` from Slickgrid for easier DOM element creation + const containerDiv = createDomElement('div', { className: 'menu-item' }); + const iconDiv = createDomElement('div', { className: 'advanced-export-icon', textContent: '📊' }); + const textSpan = createDomElement('span', { textContent: item.title, style: { flex: '1' } }); + const kbdElm = createDomElement('kbd', { className: 'key-hint', textContent: 'Ctrl+E' }); containerDiv.appendChild(iconDiv); containerDiv.appendChild(textSpan); containerDiv.appendChild(kbdElm); // Add native event listeners for hover effects containerDiv.addEventListener('mouseenter', () => { - iconDiv.style.transform = 'scale(1.1)'; - containerDiv.style.backgroundColor = '#f5f5f5'; + iconDiv.style.transform = 'scale(1.15)'; + iconDiv.style.background = 'linear-gradient(135deg, #d8dcef 0%, #ffffff 100%)'; + containerDiv.parentElement!.style.backgroundColor = '#854685'; + const div = this.buildChartTooltip(getOffset(containerDiv)); + document.body.appendChild(div); + containerDiv.style.color = 'white'; + containerDiv.querySelector('.key-hint')!.style.color = 'black'; }); containerDiv.addEventListener('mouseleave', () => { + iconDiv.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; iconDiv.style.transform = 'scale(1)'; - containerDiv.style.backgroundColor = ''; + containerDiv.parentElement!.style.backgroundColor = 'white'; + containerDiv.style.color = 'black'; + document.querySelector('.export-timestamp')?.remove(); }); return containerDiv; @@ -228,10 +228,10 @@ export default class Example40 { positionOrder: 55, // Slot renderer with status indicator and beta badge slotRenderer: () => ` -
- - Filter Column - BETA + `, }, @@ -251,10 +251,18 @@ export default class Example40 { menu: { commandItems: [ { - command: 'auto-refresh', - title: 'Auto Refresh', + command: 'recalc', + title: 'Recalculate', positionOrder: 45, iconCssClass: 'mdi mdi-refresh', + slotRenderer: () => { + return ` + + `; + }, }, ], }, @@ -277,9 +285,9 @@ export default class Example40 { // Demo: Menu-level default renderer that applies to all items unless overridden defaultItemRenderer: (item, _args) => { return ` -
+ `; }, @@ -344,11 +352,9 @@ export default class Example40 { positionOrder: 53, // Individual slotRenderer overrides the defaultItemRenderer slotRenderer: (_item, args) => ` -
-
- -
- Edit Row #${args.dataContext.id} + `, action: (_e, args) => { @@ -360,11 +366,11 @@ export default class Example40 { command: 'delete-row', title: 'Delete Row', positionOrder: 54, - iconCssClass: 'mdi mdi-delete', - action: (_e, args) => { - if (confirm(`Delete row #${args.dataContext.id}?`)) { - console.log('Delete row:', args.dataContext); - alert(`Deleted row #${args.dataContext.id}`); + iconCssClass: 'mdi mdi-delete text-danger', + action: (_event, args) => { + const dataContext = args.dataContext; + if (confirm(`Do you really want to delete row (${args.row! + 1}) with "${dataContext.title}"`)) { + this.sgb?.instances?.gridService.deleteItemById(dataContext.id); } }, }, @@ -389,9 +395,9 @@ export default class Example40 { // Demo: Menu-level default renderer for all header menu items defaultItemRenderer: (item, _args) => { return ` -
- ${item.iconCssClass ? `` : ''} - ${item.title} + `; }, @@ -406,9 +412,9 @@ export default class Example40 { // Demo: Menu-level default renderer for context menu items defaultItemRenderer: (item, _args) => { return ` -
- ${item.iconCssClass ? `` : ''} - ${item.title} + `; }, @@ -419,23 +425,11 @@ export default class Example40 { title: 'Edit Cell', // Demo: Individual slotRenderer overrides the menu's defaultItemRenderer slotRenderer: (item, _args) => { - const containerDiv = document.createElement('div'); - containerDiv.style.cssText = 'display: flex; align-items: center;'; - - const iconDiv = document.createElement('div'); - iconDiv.style.cssText = - 'width: 18px; height: 18px; background: linear-gradient(135deg, #00c853 0%, #64dd17 100%); border-radius: 3px; display: flex; align-items: center; justify-content: center; margin-right: 4px; transition: all 0.2s;'; - iconDiv.innerHTML = ''; - - const textSpan = document.createElement('span'); - textSpan.style.cssText = 'flex: 1;'; - textSpan.textContent = item.title; - - const kbdElm = document.createElement('kbd'); - kbdElm.style.cssText = - 'background: #eee; border: 1px solid #ccc; border-radius: 2px; padding: 2px 4px; font-size: 10px; margin-left: 10px; display: inline-flex; align-items: center; height: 18px;'; - kbdElm.textContent = 'F2'; - + // you can use `createDomElement()` from Slickgrid for easier DOM element creation + const containerDiv = createDomElement('div', { className: 'menu-item' }); + const iconDiv = createDomElement('div', { className: 'edit-cell-icon', textContent: '✎' }); + const textSpan = createDomElement('span', { textContent: item.title, style: { flex: '1' } }); + const kbdElm = createDomElement('kbd', { className: 'edit-cell', textContent: 'F2' }); containerDiv.appendChild(iconDiv); containerDiv.appendChild(textSpan); containerDiv.appendChild(kbdElm); @@ -484,7 +478,7 @@ export default class Example40 { { command: 'delete-row', title: 'Delete Row', - iconCssClass: 'mdi mdi-delete', + iconCssClass: 'mdi mdi-delete text-danger', action: () => alert('Delete row'), }, ], @@ -496,9 +490,9 @@ export default class Example40 { // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) defaultItemRenderer: (item, _args) => { return ` -
- ${item.iconCssClass ? `` : ''} - ${item.title} + `; }, @@ -528,10 +522,10 @@ export default class Example40 { iconCssClass: 'mdi mdi-download', // Individual slotRenderer overrides the defaultItemRenderer for this item slotRenderer: (item, _args) => ` -
- - ${item.title} - CUSTOM + `, action: () => alert('Export to CSV'), @@ -541,20 +535,40 @@ export default class Example40 { title: 'Refresh Data', iconCssClass: 'mdi mdi-refresh', // Demo: slotRenderer with keyboard shortcut - slotRenderer: (item) => ` -
- - ${item.title} - F5 -
- `, + slotRenderer: (item) => { + // you can use `createDomElement()` from Slickgrid for easier DOM element creation + const menuItemElm = createDomElement('div', { className: 'menu-item' }); + const iconElm = createDomElement('i', { className: `${item.iconCssClass} menu-item-icon` }); + const menuItemLabelElm = createDomElement('span', { className: 'menu-item-label', textContent: item.title }); + const kbdElm = createDomElement('kbd', { className: 'key-hint', textContent: 'F5' }); + menuItemElm.appendChild(iconElm); + menuItemElm.appendChild(menuItemLabelElm); + menuItemElm.appendChild(kbdElm); + return menuItemElm; + }, action: () => alert('Refresh data'), }, ], }, + + // tooltip plugin + externalResources: [new SlickCustomTooltip()], }; } + /** create a basic chart export tooltip */ + buildChartTooltip(containerOffset) { + const div = createDomElement('div', { + className: 'export-timestamp', + textContent: `📈 Export timestamp: ${tempoFormat(new Date(), 'YYYY-MM-DD hh:mm:ss a')}`, + style: { + top: `${containerOffset.top + 35}px`, + left: `${containerOffset.left - 70}px`, + }, + }); + return div; + } + loadData(count: number): ReportItem[] { const tmpData: ReportItem[] = []; for (let i = 0; i < count; i++) { @@ -576,4 +590,9 @@ export default class Example40 { } return tmpData; } + + toggleSubTitle() { + this.subTitleStyle = this.subTitleStyle === 'display: block' ? 'display: none' : 'display: block'; + this.sgb.resizerService.resizeGrid(); + } } diff --git a/packages/common/src/extensions/index.ts b/packages/common/src/extensions/index.ts index c6f3a0aff2..107c7049ce 100644 --- a/packages/common/src/extensions/index.ts +++ b/packages/common/src/extensions/index.ts @@ -1,4 +1,5 @@ export * from './extensionUtility.js'; +export * from './menuBaseClass.js'; export * from './slickAutoTooltip.js'; export * from './slickCellExcelCopyManager.js'; export * from './slickCellExternalCopyManager.js'; diff --git a/packages/common/src/extensions/menuBaseClass.ts b/packages/common/src/extensions/menuBaseClass.ts index c20b33cd2b..12d5b0fb3e 100644 --- a/packages/common/src/extensions/menuBaseClass.ts +++ b/packages/common/src/extensions/menuBaseClass.ts @@ -29,12 +29,20 @@ import type { } from '../interfaces/index.js'; import type { SharedService } from '../services/shared.service.js'; +export type ExtractMenuType = T extends 'command' ? A : T extends 'option' ? A : A extends 'divider' ? A : never; export type MenuType = 'command' | 'option'; +export type MenuCommandOptionItem = MenuCommandItem | MenuOptionItem; export type ExtendableItemTypes = HeaderButtonItem | MenuCommandItem | MenuOptionItem | 'divider'; - -export type ExtractMenuType = T extends 'command' ? A : T extends 'option' ? A : A extends 'divider' ? A : never; - -export class MenuBaseClass { +export type MenuPlugin = CellMenu | ContextMenu | GridMenu | HeaderMenu; +export type itemEventCallback = ( + e: DOMMouseOrTouchEvent, + type: MenuType, + item: ExtractMenuType, + level: number, + columnDef?: Column +) => void; + +export class MenuBaseClass { protected _addonOptions: M = {} as unknown as M; protected _bindEventService: BindingEventService; protected _camelPluginName = ''; @@ -154,11 +162,11 @@ export class MenuBaseClass, commandOrOptionMenu: HTMLDivElement): void { - if (item !== 'divider' && (item as MenuCommandItem | MenuOptionItem | GridMenuItem)?.subMenuTitle) { + if (item !== 'divider' && (item as MenuCommandOptionItem | GridMenuItem)?.subMenuTitle) { const subMenuTitleElm = document.createElement('div'); subMenuTitleElm.className = 'slick-menu-title'; - subMenuTitleElm.textContent = (item as MenuCommandItem | MenuOptionItem | GridMenuItem).subMenuTitle as string; - const subMenuTitleClass = (item as MenuCommandItem | MenuOptionItem | GridMenuItem).subMenuTitleCssClass as string; + subMenuTitleElm.textContent = (item as MenuCommandOptionItem | GridMenuItem).subMenuTitle as string; + const subMenuTitleClass = (item as MenuCommandOptionItem | GridMenuItem).subMenuTitleCssClass as string; if (subMenuTitleClass) { subMenuTitleElm.classList.add(...classNameToList(subMenuTitleClass)); } @@ -173,20 +181,8 @@ export class MenuBaseClass>, args: unknown, - itemClickCallback: ( - e: DOMMouseOrTouchEvent, - type: MenuType, - item: ExtractMenuType, - level: number, - columnDef?: Column - ) => void, - itemMouseoverCallback?: ( - e: DOMMouseOrTouchEvent, - type: MenuType, - item: ExtractMenuType, - level: number, - columnDef?: Column - ) => void + itemClickCallback: itemEventCallback, + itemMouseoverCallback?: itemEventCallback ): void { if (args && commandOrOptionItems && menuOptions) { for (const item of commandOrOptionItems) { @@ -239,20 +235,8 @@ export class MenuBaseClass, args: any, - itemClickCallback: ( - e: DOMMouseOrTouchEvent, - type: MenuType, - item: ExtractMenuType, - level: number, - columnDef?: Column - ) => void, - itemMouseoverCallback?: ( - e: DOMMouseOrTouchEvent, - type: MenuType, - item: ExtractMenuType, - level: number, - columnDef?: Column - ) => void + itemClickCallback: itemEventCallback, + itemMouseoverCallback?: itemEventCallback ): HTMLLIElement | null { let commandLiElm: HTMLLIElement | null = null; @@ -288,7 +272,7 @@ export class MenuBaseClass) => { - // If there's a slotRenderer, call it with the event - const slotRenderer = (item as MenuCommandItem | MenuOptionItem).slotRenderer || (this._addonOptions as any).defaultItemRenderer; + // if there's a slot renderer, call it with the event + const slotRenderer = (item as MenuCommandOptionItem).slotRenderer || (this._addonOptions as MenuPlugin).defaultItemRenderer; if (slotRenderer) { - slotRenderer(item as MenuCommandItem | MenuOptionItem, args, e); + slotRenderer(item as MenuCommandOptionItem, args, e); } - // If the click was stopped by an interactive element handler, don't trigger the menu action + // if the click was stopped by an interactive element handler, don't trigger the menu action if (e.defaultPrevented) { return; } @@ -378,10 +349,7 @@ export class MenuBaseClass `
${item.title}
` - */ - defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; - - /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ - menuUsabilityOverride?: (args: MenuCallbackArgs) => boolean; } diff --git a/packages/common/src/interfaces/contextMenuOption.interface.ts b/packages/common/src/interfaces/contextMenuOption.interface.ts index a83aaf65a9..ee2772ddc4 100644 --- a/packages/common/src/interfaces/contextMenuOption.interface.ts +++ b/packages/common/src/interfaces/contextMenuOption.interface.ts @@ -1,7 +1,8 @@ import type { ContextMenuLabel } from './contextMenuLabel.interface.js'; -import type { MenuCallbackArgs, MenuCommandItem, MenuOptionItem } from './index.js'; +import type { MenuCommandItem, MenuOptionItem } from './index.js'; +import type { MenuOption } from './menuOption.interface.js'; -export interface ContextMenuOption { +export interface ContextMenuOption extends MenuOption { /** Defaults to true, Auto-align dropup or dropdown menu to the left or right depending on grid viewport available space */ autoAdjustDrop?: boolean; @@ -130,24 +131,4 @@ export interface ContextMenuOption { /** Defaults to "mouseover", what event type shoud we use to open sub-menu(s), 2 options are available: "mouseover" or "click" */ subMenuOpenByEvent?: 'mouseover' | 'click'; - - // -- - // action/override callbacks - - /** - * Default slot renderer for all menu items. - * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. - * The renderer receives both the menu item and args for full context access. - * - * @param item - The menu item object (MenuCommandItem or MenuOptionItem) - * @param args - The callback args providing access to grid, column, dataContext, etc. - * @returns Either an HTML string or an HTMLElement - * - * @example - * defaultItemRenderer: (item, args) => `
${item.title}
` - */ - defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; - - /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ - menuUsabilityOverride?: (args: MenuCallbackArgs) => boolean; } diff --git a/packages/common/src/interfaces/gridMenuOption.interface.ts b/packages/common/src/interfaces/gridMenuOption.interface.ts index 32f7e92f16..61bb78f6d7 100644 --- a/packages/common/src/interfaces/gridMenuOption.interface.ts +++ b/packages/common/src/interfaces/gridMenuOption.interface.ts @@ -1,14 +1,7 @@ -import type { - Column, - GridMenuCallbackArgs, - GridMenuCommandItemCallbackArgs, - GridMenuLabel, - GridOption, - MenuCallbackArgs, - MenuCommandItem, -} from './index.js'; - -export interface GridMenuOption { +import type { Column, GridMenuCallbackArgs, GridMenuCommandItemCallbackArgs, GridMenuLabel, GridOption, MenuCommandItem } from './index.js'; +import type { MenuOption } from './menuOption.interface.js'; + +export interface GridMenuOption extends MenuOption { /** Defaults to true, Auto-align drop menu to the left or right depending on grid viewport available space */ autoAlignSide?: boolean; @@ -189,23 +182,6 @@ export interface GridMenuOption { // -- // action/override callbacks - /** - * Default slot renderer for all menu items. - * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. - * The renderer receives both the menu item and args for full context access. - * - * @param item - The menu item object (MenuCommandItem or MenuOptionItem) - * @param args - The callback args providing access to grid, column, dataContext, etc. - * @returns Either an HTML string or an HTMLElement - * - * @example - * defaultItemRenderer: (item, args) => `
${item.title}
` - */ - defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; - /** Callback method to override the column name output used by the ColumnPicker/GridMenu. */ headerColumnValueExtractor?: (column: Column, gridOptions?: GridOption) => string | HTMLElement | DocumentFragment; - - /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ - menuUsabilityOverride?: (args: MenuCallbackArgs) => boolean; } diff --git a/packages/common/src/interfaces/headerMenuOption.interface.ts b/packages/common/src/interfaces/headerMenuOption.interface.ts index 3fe873e03e..de0eb7987d 100644 --- a/packages/common/src/interfaces/headerMenuOption.interface.ts +++ b/packages/common/src/interfaces/headerMenuOption.interface.ts @@ -1,8 +1,7 @@ -import type { SlickGrid } from '../core/index.js'; import type { HeaderMenuLabel } from './headerMenuLabel.interface.js'; -import type { Column } from './index.js'; +import type { MenuOption } from './menuOption.interface.js'; -export interface HeaderMenuOption { +export interface HeaderMenuOption extends MenuOption { /** Auto-align drop menu to the left when not enough viewport space to show on the right */ autoAlign?: boolean; @@ -95,24 +94,4 @@ export interface HeaderMenuOption { /** Item tooltip. */ tooltip?: string; - - // -- - // Methods - - /** - * Default slot renderer for all menu items. - * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. - * The renderer receives both the menu item and args for full context access. - * - * @param item - The menu item object (MenuCommandItem or MenuOptionItem) - * @param args - The callback args providing access to grid, column, dataContext, etc. - * @returns Either an HTML string or an HTMLElement - * - * @example - * defaultItemRenderer: (item, args) => `
${item.title}
` - */ - defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; - - /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ - menuUsabilityOverride?: (args: { grid: SlickGrid; column: Column; menu: HTMLElement }) => boolean; } diff --git a/packages/common/src/interfaces/menuItem.interface.ts b/packages/common/src/interfaces/menuItem.interface.ts index 1d4bffa31d..5259cd81b7 100644 --- a/packages/common/src/interfaces/menuItem.interface.ts +++ b/packages/common/src/interfaces/menuItem.interface.ts @@ -64,17 +64,6 @@ export interface MenuItem { * div.addEventListener('click', () => console.log('clicked')); * return div; * } - * - * // Interactive element that prevents menu action - * slotRenderer: (item, args, event) => { - * const checkbox = document.createElement('input'); - * checkbox.type = 'checkbox'; - * checkbox.addEventListener('change', (e) => { - * event?.stopPropagation(); // Stop the menu item click from firing - * console.log('checkbox toggled'); - * }); - * return checkbox; - * } */ slotRenderer?: (item: any, args: O, event?: Event) => string | HTMLElement; diff --git a/packages/common/src/interfaces/menuOption.interface.ts b/packages/common/src/interfaces/menuOption.interface.ts new file mode 100644 index 0000000000..025ffa2184 --- /dev/null +++ b/packages/common/src/interfaces/menuOption.interface.ts @@ -0,0 +1,32 @@ +import type { SlickGrid } from '../core/index.js'; +import type { Column } from './index.js'; + +export interface MenuOption { + // -- + // Methods + + /** + * Default slot renderer for all menu items. + * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. + * The renderer receives both the menu item and args for full context access. + * + * @param item - The menu item object (`MenuCommandItem` or `MenuOptionItem`) + * @param args - The callback args providing access to grid, column, dataContext, etc. + * @returns Either an HTML string or an HTMLElement + * + * @example + * // Return HTML string + * defaultItemRenderer: (item, args) => `
${item.title}
` + * + * // Return HTMLElement + * defaultItemRenderer: (item, args) => { + * const div = document.createElement('div'); + * div.textContent = `${item.title} (Row ${args.dataContext.id})`; + * return div; + * } + */ + defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; + + /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ + menuUsabilityOverride?: (args: { grid: SlickGrid; column: Column; menu: HTMLElement }) => boolean; +} From f7b7a15fbfa918e254e9791ff1f2f49aeb814757 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 10 Feb 2026 19:24:29 -0500 Subject: [PATCH 03/24] chore: add menu args TS Generics --- demos/vanilla/src/examples/example12.ts | 2 +- demos/vanilla/src/examples/example40.ts | 41 +++++++++---------- .../interfaces/cellMenuOption.interface.ts | 5 +-- .../interfaces/contextMenuOption.interface.ts | 5 +-- .../interfaces/gridMenuOption.interface.ts | 15 +++++-- .../interfaces/headerMenuOption.interface.ts | 5 +-- packages/common/src/interfaces/index.ts | 1 + .../src/interfaces/menuOption.interface.ts | 8 ++-- 8 files changed, 43 insertions(+), 39 deletions(-) diff --git a/demos/vanilla/src/examples/example12.ts b/demos/vanilla/src/examples/example12.ts index 91374ac716..d9a0bfb4dd 100644 --- a/demos/vanilla/src/examples/example12.ts +++ b/demos/vanilla/src/examples/example12.ts @@ -240,7 +240,7 @@ export default class Example12 { minValue: 0, maxValue: 100, }, - customTooltip: { position: 'center' }, + customTooltip: { position: 'center' }, }, // { // id: 'percentComplete2', name: '% Complete', field: 'analysis.percentComplete', minWidth: 100, diff --git a/demos/vanilla/src/examples/example40.ts b/demos/vanilla/src/examples/example40.ts index bcefe8227e..ce822557fa 100644 --- a/demos/vanilla/src/examples/example40.ts +++ b/demos/vanilla/src/examples/example40.ts @@ -23,7 +23,7 @@ export default class Example40 { gridOptions: GridOption; dataset: ReportItem[]; sgb: SlickVanillaGridBundle; - subTitleStyle = 'display: block'; + subTitleStyle = 'display: none'; constructor() { this._bindingEventService = new BindingEventService(); @@ -245,6 +245,7 @@ export default class Example40 { field: 'percentComplete', sortable: true, filterable: true, + type: 'number', filter: { model: Filters.slider, operator: '>=' }, // Demo: Header Menu with Slot - showing interactive element (checkbox) header: { @@ -255,14 +256,12 @@ export default class Example40 { title: 'Recalculate', positionOrder: 45, iconCssClass: 'mdi mdi-refresh', - slotRenderer: () => { - return ` - - `; - }, + slotRenderer: () => ` + + `, }, ], }, @@ -285,11 +284,11 @@ export default class Example40 { // Demo: Menu-level default renderer that applies to all items unless overridden defaultItemRenderer: (item, _args) => { return ` - - `; + + `; }, commandItems: [ { @@ -353,8 +352,8 @@ export default class Example40 { // Individual slotRenderer overrides the defaultItemRenderer slotRenderer: (_item, args) => ` `, action: (_e, args) => { @@ -490,11 +489,11 @@ export default class Example40 { // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) defaultItemRenderer: (item, _args) => { return ` - - `; + + `; }, commandItems: [ { diff --git a/packages/common/src/interfaces/cellMenuOption.interface.ts b/packages/common/src/interfaces/cellMenuOption.interface.ts index 8bb0c295fc..e037eeff28 100644 --- a/packages/common/src/interfaces/cellMenuOption.interface.ts +++ b/packages/common/src/interfaces/cellMenuOption.interface.ts @@ -1,7 +1,6 @@ -import type { MenuCommandItem, MenuOptionItem } from './index.js'; -import type { MenuOption } from './menuOption.interface.js'; +import type { MenuCommandItem, MenuFromCellCallbackArgs, MenuOption, MenuOptionItem } from './index.js'; -export interface CellMenuOption extends MenuOption { +export interface CellMenuOption extends MenuOption { /** Defaults to true, Auto-align dropup or dropdown menu to the left or right depending on grid viewport available space */ autoAdjustDrop?: boolean; diff --git a/packages/common/src/interfaces/contextMenuOption.interface.ts b/packages/common/src/interfaces/contextMenuOption.interface.ts index ee2772ddc4..94bf2c264d 100644 --- a/packages/common/src/interfaces/contextMenuOption.interface.ts +++ b/packages/common/src/interfaces/contextMenuOption.interface.ts @@ -1,8 +1,7 @@ import type { ContextMenuLabel } from './contextMenuLabel.interface.js'; -import type { MenuCommandItem, MenuOptionItem } from './index.js'; -import type { MenuOption } from './menuOption.interface.js'; +import type { MenuCommandItem, MenuFromCellCallbackArgs, MenuOption, MenuOptionItem } from './index.js'; -export interface ContextMenuOption extends MenuOption { +export interface ContextMenuOption extends MenuOption { /** Defaults to true, Auto-align dropup or dropdown menu to the left or right depending on grid viewport available space */ autoAdjustDrop?: boolean; diff --git a/packages/common/src/interfaces/gridMenuOption.interface.ts b/packages/common/src/interfaces/gridMenuOption.interface.ts index 61bb78f6d7..e246ab4c15 100644 --- a/packages/common/src/interfaces/gridMenuOption.interface.ts +++ b/packages/common/src/interfaces/gridMenuOption.interface.ts @@ -1,7 +1,14 @@ -import type { Column, GridMenuCallbackArgs, GridMenuCommandItemCallbackArgs, GridMenuLabel, GridOption, MenuCommandItem } from './index.js'; -import type { MenuOption } from './menuOption.interface.js'; - -export interface GridMenuOption extends MenuOption { +import type { + Column, + GridMenuCallbackArgs, + GridMenuCommandItemCallbackArgs, + GridMenuLabel, + GridOption, + MenuCommandItem, + MenuOption, +} from './index.js'; + +export interface GridMenuOption extends MenuOption { /** Defaults to true, Auto-align drop menu to the left or right depending on grid viewport available space */ autoAlignSide?: boolean; diff --git a/packages/common/src/interfaces/headerMenuOption.interface.ts b/packages/common/src/interfaces/headerMenuOption.interface.ts index de0eb7987d..e9482a3bab 100644 --- a/packages/common/src/interfaces/headerMenuOption.interface.ts +++ b/packages/common/src/interfaces/headerMenuOption.interface.ts @@ -1,7 +1,6 @@ -import type { HeaderMenuLabel } from './headerMenuLabel.interface.js'; -import type { MenuOption } from './menuOption.interface.js'; +import type { HeaderMenuCommandItemCallbackArgs, HeaderMenuLabel, MenuOption } from './index.js'; -export interface HeaderMenuOption extends MenuOption { +export interface HeaderMenuOption extends MenuOption { /** Auto-align drop menu to the left when not enough viewport space to show on the right */ autoAlign?: boolean; diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index 68707c59a1..cc509d1f9a 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -109,6 +109,7 @@ export type * from './menuCommandItem.interface.js'; export type * from './menuCommandItemCallbackArgs.interface.js'; export type * from './menuFromCellCallbackArgs.interface.js'; export type * from './menuItem.interface.js'; +export type * from './menuOption.interface.js'; export type * from './menuOptionItem.interface.js'; export type * from './menuOptionItemCallbackArgs.interface.js'; export type * from './metrics.interface.js'; diff --git a/packages/common/src/interfaces/menuOption.interface.ts b/packages/common/src/interfaces/menuOption.interface.ts index 025ffa2184..7698b0adbd 100644 --- a/packages/common/src/interfaces/menuOption.interface.ts +++ b/packages/common/src/interfaces/menuOption.interface.ts @@ -1,7 +1,7 @@ import type { SlickGrid } from '../core/index.js'; -import type { Column } from './index.js'; +import type { Column, GridMenuCommandItemCallbackArgs, HeaderMenuCommandItemCallbackArgs, MenuFromCellCallbackArgs } from './index.js'; -export interface MenuOption { +export interface MenuOption { // -- // Methods @@ -18,14 +18,14 @@ export interface MenuOption { * // Return HTML string * defaultItemRenderer: (item, args) => `
${item.title}
` * - * // Return HTMLElement + * // Return HTMLElement (e.g. Cell or Context Menu) * defaultItemRenderer: (item, args) => { * const div = document.createElement('div'); * div.textContent = `${item.title} (Row ${args.dataContext.id})`; * return div; * } */ - defaultItemRenderer?: (item: any, args: any) => string | HTMLElement; + defaultItemRenderer?: (item: any, args: T) => string | HTMLElement; /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ menuUsabilityOverride?: (args: { grid: SlickGrid; column: Column; menu: HTMLElement }) => boolean; From 5411d2cc94c8377bf0f6512c7685443631a3b58a Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 11 Feb 2026 15:55:10 -0500 Subject: [PATCH 04/24] chore: add unit tests for slot renderer & default renderer --- .../__tests__/slickCellMenu.plugin.spec.ts | 164 ++++++++++++ .../__tests__/slickContextMenu.spec.ts | 169 +++++++++++++ .../__tests__/slickGridMenu.spec.ts | 234 ++++++++++++++++-- .../__tests__/slickHeaderMenu.spec.ts | 210 ++++++++++++++++ 4 files changed, 762 insertions(+), 15 deletions(-) diff --git a/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts b/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts index 79a2888321..26972eee05 100644 --- a/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts +++ b/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts @@ -70,6 +70,7 @@ const gridStub = { setColumns: vi.fn(), setOptions: vi.fn(), setSortColumns: vi.fn(), + sanitizeHtmlString: (str: string) => str, updateColumnHeader: vi.fn(), onClick: new SlickEvent(), onScroll: new SlickEvent(), @@ -788,6 +789,169 @@ describe('CellMenu Plugin', () => { }); }); + describe('with slot renderer', () => { + it('should render menu item with slotRenderer returning HTMLElement', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem) => { + const div = document.createElement('div'); + div.className = 'custom-slot-content'; + div.textContent = `Custom: ${item.title}`; + return div; + }); + + plugin.dispose(); + plugin.init(); + columnsMock[3].cellMenu!.commandItems = [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }]; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const customSlotElm = commandListElm.querySelector('.custom-slot-content') as HTMLDivElement; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm.textContent).toBe('Custom: Test Command'); + }); + + it('should render menu item with slotRenderer returning string', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem) => `String: ${item.title}`); + + plugin.dispose(); + plugin.init(); + columnsMock[3].cellMenu!.commandItems = [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }]; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const customSlotElm = commandListElm.querySelector('.custom-string') as HTMLDivElement; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm.textContent).toContain('String: Test Command'); + }); + + it('should render menu item with defaultItemRenderer when item has no slotRenderer', () => { + const mockDefaultRenderer = vi.fn((item: MenuCommandItem) => { + const div = document.createElement('div'); + div.className = 'default-renderer-content'; + div.textContent = `Default: ${item.title}`; + return div; + }); + + plugin.dispose(); + plugin.init({ defaultItemRenderer: mockDefaultRenderer }); + columnsMock[3].cellMenu!.commandItems = [{ command: 'test-cmd', title: 'Test Command' }]; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const defaultRendererElm = commandListElm.querySelector('.default-renderer-content') as HTMLDivElement; + + expect(mockDefaultRenderer).toHaveBeenCalled(); + expect(defaultRendererElm).toBeTruthy(); + expect(defaultRendererElm.textContent).toBe('Default: Test Command'); + }); + + it('should prioritize item slotRenderer over defaultItemRenderer', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem) => { + const div = document.createElement('div'); + div.className = 'slot-prioritized'; + div.textContent = 'Slot renderer prioritized'; + return div; + }); + const mockDefaultRenderer = vi.fn(); + + plugin.dispose(); + plugin.init({ defaultItemRenderer: mockDefaultRenderer }); + columnsMock[3].cellMenu!.commandItems = [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }]; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const slotRendererElm = commandListElm.querySelector('.slot-prioritized') as HTMLDivElement; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(mockDefaultRenderer).not.toHaveBeenCalled(); + expect(slotRendererElm).toBeTruthy(); + }); + + it('should pass correct arguments (item and args) to slotRenderer callback', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem, args: any) => { + const div = document.createElement('div'); + div.className = 'renderer-args-test'; + div.textContent = `Item: ${item.command}, Grid: ${args?.grid ? 'present' : 'missing'}`; + return div; + }); + + plugin.dispose(); + plugin.init(); + columnsMock[3].cellMenu!.commandItems = [{ command: 'test-cmd', title: 'Test Command with Args', slotRenderer: mockSlotRenderer }]; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + expect(mockSlotRenderer).toHaveBeenCalled(); + const callArgs = mockSlotRenderer.mock.calls[0]; + expect(callArgs[0].command).toBe('test-cmd'); + expect(callArgs[1]).toBeDefined(); + expect(callArgs[1].grid).toBe(gridStub); + }); + + it('should call slotRenderer with click event as third argument when menu item is clicked', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'click-test'; + return div; + }); + + plugin.dispose(); + plugin.init(); + columnsMock[3].cellMenu!.commandItems = [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }]; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const menuItemElm = commandListElm.querySelector('.slick-menu-item') as HTMLDivElement; + + // Click the menu item + menuItemElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true })); + + // Verify slotRenderer was called with the click event as the third argument + expect(mockSlotRenderer).toHaveBeenCalledTimes(2); // once for render, once for click + const clickCallArgs = mockSlotRenderer.mock.calls[1]; // second call is from click + expect(clickCallArgs[2]).toBeDefined(); + expect(clickCallArgs[2]!.type).toBe('click'); + }); + + it('should not trigger menu action when slotRenderer calls preventDefault on click event', () => { + const mockAction = vi.fn(); + const mockSlotRenderer = vi.fn((item: MenuCommandItem, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'prevent-default-test'; + const button = document.createElement('button'); + button.textContent = 'Interactive'; + button.onclick = (e) => { + e.preventDefault(); // Prevent default action + }; + div.appendChild(button); + return div; + }); + + plugin.dispose(); + plugin.init(); + columnsMock[3].cellMenu!.commandItems = [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer, action: mockAction }]; + gridStub.onClick.notify({ cell: 3, row: 1, grid: gridStub }, eventData, gridStub); + + const cellMenuElm = document.body.querySelector('.slick-cell-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = cellMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const menuItemElm = commandListElm.querySelector('.slick-menu-item') as HTMLDivElement; + const buttonElm = menuItemElm.querySelector('button') as HTMLButtonElement; + + // Click the button inside the slotRenderer, which calls preventDefault + buttonElm.click(); + + // Verify the action callback was not called because preventDefault was called + expect(mockAction).not.toHaveBeenCalled(); + }); + }); + describe('with Options Items', () => { beforeEach(() => { columnsMock[4].cellMenu!.optionItems = deepCopy(optionItemsMock); diff --git a/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts b/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts index 02a071ac03..1652a5456a 100644 --- a/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickContextMenu.spec.ts @@ -132,6 +132,7 @@ const gridStub = { setColumns: vi.fn(), setOptions: vi.fn(), setSortColumns: vi.fn(), + sanitizeHtmlString: (str: string) => str, updateColumnHeader: vi.fn(), onClick: new SlickEvent(), onContextMenu: new SlickEvent(), @@ -819,6 +820,174 @@ describe('ContextMenu Plugin', () => { }); }); + describe('with slot renderer', () => { + beforeEach(() => { + // Clean up any leftover state from previous tests + delete gridOptionsMock.contextMenu!.defaultItemRenderer; + gridOptionsMock.contextMenu!.commandItems = []; + gridOptionsMock.contextMenu!.hideCopyCellValueCommand = true; + }); + + it('should render menu item with slotRenderer returning HTMLElement', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem) => { + const div = document.createElement('div'); + div.className = 'custom-slot-content'; + div.textContent = `Custom: ${item.title}`; + return div; + }); + + plugin.dispose(); + plugin.init({ commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] }); + gridStub.onContextMenu.notify({ grid: gridStub }, eventData, gridStub); + + const contextMenuElm = document.body.querySelector('.slick-context-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = contextMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const customSlotElm = commandListElm.querySelector('.custom-slot-content') as HTMLDivElement; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm.textContent).toBe('Custom: Test Command'); + }); + + it('should render menu item with slotRenderer returning string', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem) => `String: ${item.title}`); + + plugin.dispose(); + plugin.init({ commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] }); + gridStub.onContextMenu.notify({ grid: gridStub }, eventData, gridStub); + + const contextMenuElm = document.body.querySelector('.slick-context-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = contextMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const customSlotElm = commandListElm.querySelector('.custom-string') as HTMLDivElement; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm.textContent).toContain('String: Test Command'); + }); + + it('should render menu item with defaultItemRenderer when item has no slotRenderer', () => { + const mockDefaultRenderer = vi.fn((item: MenuCommandItem) => { + const div = document.createElement('div'); + div.className = 'default-renderer-content'; + div.textContent = `Default: ${item.title}`; + return div; + }); + + plugin.dispose(); + plugin.init({ commandItems: [{ command: 'test-cmd', title: 'Test Command' }], defaultItemRenderer: mockDefaultRenderer }); + gridStub.onContextMenu.notify({ grid: gridStub }, eventData, gridStub); + + const contextMenuElm = document.body.querySelector('.slick-context-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = contextMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const defaultRendererElm = commandListElm.querySelector('.default-renderer-content') as HTMLDivElement; + + expect(mockDefaultRenderer).toHaveBeenCalled(); + expect(defaultRendererElm).toBeTruthy(); + expect(defaultRendererElm.textContent).toBe('Default: Test Command'); + }); + + it('should prioritize item slotRenderer over defaultItemRenderer', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem) => { + const div = document.createElement('div'); + div.className = 'slot-prioritized'; + div.textContent = 'Slot renderer prioritized'; + return div; + }); + const mockDefaultRenderer = vi.fn(); + + plugin.dispose(); + plugin.init({ + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }], + defaultItemRenderer: mockDefaultRenderer, + }); + gridStub.onContextMenu.notify({ grid: gridStub }, eventData, gridStub); + + const contextMenuElm = document.body.querySelector('.slick-context-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = contextMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const slotRendererElm = commandListElm.querySelector('.slot-prioritized') as HTMLDivElement; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(mockDefaultRenderer).not.toHaveBeenCalled(); + expect(slotRendererElm).toBeTruthy(); + }); + + it('should pass correct arguments (item and args) to slotRenderer callback', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem, args: any) => { + const div = document.createElement('div'); + div.className = 'renderer-args-test'; + div.textContent = `Item: ${item.command}, Grid: ${args?.grid ? 'present' : 'missing'}`; + return div; + }); + + plugin.dispose(); + plugin.init({ commandItems: [{ command: 'test-cmd', title: 'Test Command with Args', slotRenderer: mockSlotRenderer }] }); + gridStub.onContextMenu.notify({ grid: gridStub }, eventData, gridStub); + + expect(mockSlotRenderer).toHaveBeenCalled(); + const callArgs = mockSlotRenderer.mock.calls[0]; + expect(callArgs[0].command).toBe('test-cmd'); + expect(callArgs[1]).toBeDefined(); + expect(callArgs[1].grid).toBe(gridStub); + }); + + it('should call slotRenderer with click event as third argument when menu item is clicked', () => { + const mockSlotRenderer = vi.fn((item: MenuCommandItem, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'click-test'; + return div; + }); + + plugin.dispose(); + plugin.init({ commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] }); + gridStub.onContextMenu.notify({ grid: gridStub }, eventData, gridStub); + + const contextMenuElm = document.body.querySelector('.slick-context-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = contextMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const menuItemElm = commandListElm.querySelector('.slick-menu-item') as HTMLDivElement; + + // Click the menu item + menuItemElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true })); + + // Verify slotRenderer was called with the click event as the third argument + expect(mockSlotRenderer).toHaveBeenCalledTimes(2); // once for render, once for click + const clickCallArgs = mockSlotRenderer.mock.calls[1]; // second call is from click + expect(clickCallArgs[2]).toBeDefined(); + expect(clickCallArgs[2]!.type).toBe('click'); + }); + + it('should not trigger menu action when slotRenderer calls preventDefault on click event', () => { + const mockAction = vi.fn(); + const mockSlotRenderer = vi.fn((item: MenuCommandItem, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'prevent-default-test'; + const button = document.createElement('button'); + button.textContent = 'Interactive'; + button.onclick = (e) => { + e.preventDefault(); // Prevent default action + }; + div.appendChild(button); + return div; + }); + + plugin.dispose(); + plugin.init({ + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer, action: mockAction }], + }); + gridStub.onContextMenu.notify({ grid: gridStub }, eventData, gridStub); + + const contextMenuElm = document.body.querySelector('.slick-context-menu.slickgrid12345') as HTMLDivElement; + const commandListElm = contextMenuElm.querySelector('.slick-menu-command-list') as HTMLDivElement; + const menuItemElm = commandListElm.querySelector('.slick-menu-item') as HTMLDivElement; + const buttonElm = menuItemElm.querySelector('button') as HTMLButtonElement; + + // Click the button inside the slotRenderer, which calls preventDefault + buttonElm.click(); + + // Verify the action callback was not called because preventDefault was called + expect(mockAction).not.toHaveBeenCalled(); + }); + }); + describe('with Custom Commands List', () => { beforeEach(() => { slickCellElm = document.createElement('div'); diff --git a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts index bd716d32b0..e5a715cfc9 100644 --- a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts @@ -4,7 +4,7 @@ import { TranslateServiceStub } from '../../../../../test/translateServiceStub.j import { SlickEvent, SlickEventData, type SlickDataView, type SlickGrid } from '../../core/index.js'; import * as utils from '../../core/utils.js'; import { ExtensionUtility } from '../../extensions/extensionUtility.js'; -import type { Column, DOMEvent, GridMenu, GridOption } from '../../interfaces/index.js'; +import type { Column, DOMEvent, GridMenu, GridOption, MenuCommandItem } from '../../interfaces/index.js'; import { BackendUtilityService, SharedService, @@ -70,6 +70,7 @@ const gridStub = { setSelectedRows: vi.fn(), updateColumnById: vi.fn(), updateColumns: vi.fn(), + sanitizeHtmlString: (str: string) => str, setTopPanelVisibility: vi.fn(), setPreHeaderPanelVisibility: vi.fn(), setOptions: vi.fn(), @@ -1249,6 +1250,206 @@ describe('GridMenuControl', () => { }); }); + describe('with slot renderer', () => { + beforeEach(() => { + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + vi.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(columnsMock); + vi.spyOn(gridStub, 'getVisibleColumns').mockReturnValue(columnsMock.slice(0, 1)); + }); + + it('should render menu item with slotRenderer returning HTMLElement', () => { + const mockSlotRenderer = vi.fn((item: any) => { + const div = document.createElement('div'); + div.className = 'custom-slot-content'; + div.textContent = `Custom: ${item.title}`; + return div; + }); + + control.columns = columnsMock; + gridOptionsMock.gridMenu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any, + }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + control.init(); + control.openGridMenu(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement | null; + buttonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const commandListElm = control.menuElement?.querySelector('.slick-menu-command-list') as HTMLDivElement | null; + const customSlotElm = commandListElm?.querySelector('.custom-slot-content') as HTMLDivElement | null; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm?.textContent).toBe('Custom: Test Command'); + }); + + it('should render menu item with slotRenderer returning string', () => { + const mockSlotRenderer = vi.fn((item: any) => `String: ${item.title}`); + + control.columns = columnsMock; + gridOptionsMock.gridMenu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any, + }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + control.init(); + control.openGridMenu(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement | null; + buttonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const commandListElm = control.menuElement?.querySelector('.slick-menu-command-list') as HTMLDivElement | null; + const customSlotElm = commandListElm?.querySelector('.custom-string') as HTMLDivElement | null; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm?.textContent).toContain('String: Test Command'); + }); + + it('should render menu item with defaultItemRenderer when item has no slotRenderer', () => { + const mockDefaultRenderer = vi.fn((item: any) => { + const div = document.createElement('div'); + div.className = 'default-renderer-content'; + div.textContent = `Default: ${item.title}`; + return div; + }); + + control.columns = columnsMock; + gridOptionsMock.gridMenu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command' }] as any, + defaultItemRenderer: mockDefaultRenderer, + }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + control.init(); + control.openGridMenu(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement | null; + buttonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const commandListElm = control.menuElement?.querySelector('.slick-menu-command-list') as HTMLDivElement | null; + const defaultRendererElm = commandListElm?.querySelector('.default-renderer-content') as HTMLDivElement | null; + + expect(mockDefaultRenderer).toHaveBeenCalled(); + expect(defaultRendererElm).toBeTruthy(); + expect(defaultRendererElm?.textContent).toBe('Default: Test Command'); + }); + + it('should prioritize item slotRenderer over defaultItemRenderer', () => { + const mockSlotRenderer = vi.fn((item: any) => { + const div = document.createElement('div'); + div.className = 'slot-prioritized'; + div.textContent = 'Slot renderer prioritized'; + return div; + }); + + control.columns = columnsMock; + gridOptionsMock.gridMenu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any, + defaultItemRenderer: vi.fn(), + }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + control.init(); + control.openGridMenu(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement | null; + buttonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const commandListElm = control.menuElement?.querySelector('.slick-menu-command-list') as HTMLDivElement | null; + const slotRendererElm = commandListElm?.querySelector('.slot-prioritized') as HTMLDivElement | null; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(slotRendererElm).toBeTruthy(); + expect(slotRendererElm?.textContent).toBe('Slot renderer prioritized'); + }); + + it('should pass correct arguments (item and args) to slotRenderer callback', () => { + const mockSlotRenderer = vi.fn((item: any, args: any) => { + const div = document.createElement('div'); + div.className = 'renderer-args-test'; + div.textContent = `Item: ${item.command}, Grid: ${args?.grid ? 'present' : 'missing'}`; + return div; + }); + + control.columns = columnsMock; + gridOptionsMock.gridMenu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command with Args', slotRenderer: mockSlotRenderer }] as any, + }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + control.init(); + control.openGridMenu(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement | null; + buttonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + expect(mockSlotRenderer).toHaveBeenCalled(); + const callArgs = mockSlotRenderer.mock.calls[0]; + expect(callArgs[0].command).toBe('test-cmd'); + expect(callArgs[1]).toBeDefined(); + expect(callArgs[1].grid).toBe(gridStub); + }); + + it('should call slotRenderer with click event as third argument when menu item is clicked', () => { + const mockSlotRenderer = vi.fn((item: any, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'click-test'; + return div; + }); + + control.columns = columnsMock; + gridOptionsMock.gridMenu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any, + }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + control.init(); + control.openGridMenu(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement | null; + buttonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const commandListElm = control.menuElement?.querySelector('.slick-menu-command-list') as HTMLDivElement | null; + const menuItemElm = commandListElm?.querySelector('.slick-menu-item') as HTMLDivElement | null; + + // Click the menu item + menuItemElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true })); + + // Verify slotRenderer was called with the click event as the third argument + // GridMenu calls slotRenderer 3 times: init, openMenu, and click + expect(mockSlotRenderer).toHaveBeenCalledTimes(3); + const clickCallArgs = mockSlotRenderer.mock.calls[2]; // third call is from click + expect(clickCallArgs[2]).toBeDefined(); + expect(clickCallArgs[2]!.type).toBe('click'); + }); + + it('should not trigger menu action when slotRenderer calls preventDefault on click event', () => { + const mockAction = vi.fn(); + const mockSlotRenderer = vi.fn((item: any, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'prevent-default-test'; + const button = document.createElement('button'); + button.textContent = 'Interactive'; + button.onclick = (e) => { + e.preventDefault(); // Prevent default action + }; + div.appendChild(button); + return div; + }); + + control.columns = columnsMock; + gridOptionsMock.gridMenu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer, action: mockAction }] as any, + }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); + control.init(); + control.openGridMenu(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLButtonElement | null; + buttonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const commandListElm = control.menuElement?.querySelector('.slick-menu-command-list') as HTMLDivElement | null; + const menuItemElm = commandListElm?.querySelector('.slick-menu-item') as HTMLDivElement | null; + const buttonInMenuElm = menuItemElm?.querySelector('button') as HTMLButtonElement | null; + + // Click the button inside the slotRenderer, which calls preventDefault + buttonInMenuElm?.click(); + + // Verify the action callback was not called because preventDefault was called + expect(mockAction).not.toHaveBeenCalled(); + }); + }); + describe('addGridMenuCustomCommands method', () => { beforeEach(() => { translateService.use('fr'); @@ -1912,8 +2113,8 @@ describe('GridMenuControl', () => { expect(copyGridOptionsMock.darkMode).toBeTruthy(); }); - it('should call the grid "setHeaderRowVisibility" method when the command triggered is "toggle-filter"', () => { - let copyGridOptionsMock = { + it('should call the grid "setHeaderRowVisibility" method when the command triggered is "toggle-filter" (off to on)', () => { + const copyGridOptionsMock = { ...gridOptionsMock, enableFiltering: true, showHeaderRow: false, @@ -1934,19 +2135,30 @@ describe('GridMenuControl', () => { expect(setHeaderSpy).toHaveBeenCalledWith(true); expect(scrollSpy).toHaveBeenCalledWith(0); expect(updateColumnSpy).toHaveBeenCalledTimes(1); + }); - copyGridOptionsMock = { ...gridOptionsMock, enableFiltering: true, showHeaderRow: true, hideToggleFilterCommand: false } as unknown as GridOption; + it('should call the grid "setHeaderRowVisibility" method when the command triggered is "toggle-filter" (on to off)', () => { + const copyGridOptionsMock = { + ...gridOptionsMock, + enableFiltering: true, + showHeaderRow: true, + hideToggleFilterCommand: false, + } as unknown as GridOption; vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(copyGridOptionsMock); vi.spyOn(gridStub, 'getOptions').mockReturnValue(copyGridOptionsMock); + const setHeaderSpy = vi.spyOn(gridStub, 'setHeaderRowVisibility'); + + control.init(); + control.columns = columnsMock; + const clickEvent = new Event('click', { bubbles: true, cancelable: true, composed: false }); document.querySelector('.slick-grid-menu-button')!.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); control.menuElement!.querySelector('.slick-menu-item[data-command=toggle-filter]')!.dispatchEvent(clickEvent); expect(setHeaderSpy).toHaveBeenCalledWith(false); - expect(updateColumnSpy).toHaveBeenCalledTimes(1); // same as before, so count won't increase }); - it('should call the grid "setPreHeaderPanelVisibility" method when the command triggered is "toggle-preheader"', () => { - let copyGridOptionsMock = { ...gridOptionsMock, showPreHeaderPanel: true, hideTogglePreHeaderCommand: false } as unknown as GridOption; + it('should call the grid "setPreHeaderPanelVisibility" method when the command triggered is "toggle-preheader" (on to off)', () => { + const copyGridOptionsMock = { ...gridOptionsMock, showPreHeaderPanel: true, hideTogglePreHeaderCommand: false } as unknown as GridOption; vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(copyGridOptionsMock); vi.spyOn(gridStub, 'getOptions').mockReturnValue(copyGridOptionsMock); @@ -1957,14 +2169,6 @@ describe('GridMenuControl', () => { control.menuElement!.querySelector('.slick-menu-item[data-command=toggle-preheader]')!.dispatchEvent(clickEvent); expect(gridStub.setPreHeaderPanelVisibility).toHaveBeenCalledWith(false); - - copyGridOptionsMock = { ...gridOptionsMock, showPreHeaderPanel: false, hideTogglePreHeaderCommand: false } as unknown as GridOption; - vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(copyGridOptionsMock); - vi.spyOn(gridStub, 'getOptions').mockReturnValue(copyGridOptionsMock); - document.querySelector('.slick-grid-menu-button')!.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); - control.menuElement!.querySelector('.slick-menu-item[data-command=toggle-preheader]')!.dispatchEvent(clickEvent); - - expect(gridStub.setPreHeaderPanelVisibility).toHaveBeenCalledWith(true); }); it('should call "refreshBackendDataset" method when the command triggered is "refresh-dataset"', () => { diff --git a/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts b/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts index b39d35b6e0..f074f43ea7 100644 --- a/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts @@ -55,6 +55,7 @@ const gridStub = { getUID: () => 'slickgrid12345', getOptions: () => gridOptionsMock, registerPlugin: vi.fn(), + sanitizeHtmlString: (str: string) => str, setColumns: vi.fn(), setOptions: vi.fn(), setSortColumns: vi.fn(), @@ -856,6 +857,215 @@ describe('HeaderMenu Plugin', () => { }); }); + describe('with slot renderer', () => { + let gridContainerDiv: HTMLDivElement; + let headerDiv: HTMLDivElement; + let headersDiv: HTMLDivElement; + let parentContainer: HTMLDivElement; + let eventData: SlickEventData; + + beforeEach(() => { + headerDiv = document.createElement('div'); + headerDiv.className = 'slick-header-column'; + headersDiv = document.createElement('div'); + headersDiv.className = 'slick-header-columns'; + headersDiv.appendChild(headerDiv); + gridContainerDiv = document.createElement('div'); + gridContainerDiv.className = 'slickgrid-container'; + gridContainerDiv.appendChild(headersDiv); + parentContainer = document.createElement('div'); + parentContainer.appendChild(gridContainerDiv); + document.body.appendChild(parentContainer); + sharedService.gridContainerElement = parentContainer; + vi.spyOn(gridStub, 'getContainerNode').mockReturnValue(gridContainerDiv); + vi.spyOn(gridStub, 'getGridPosition').mockReturnValue({ top: 10, bottom: 5, left: 15, right: 22, width: 225 } as any); + eventData = { ...new SlickEventData(), preventDefault: vi.fn() } as any; + }); + + afterEach(() => { + parentContainer?.remove(); + }); + + it('should render menu item with slotRenderer returning HTMLElement', () => { + const mockSlotRenderer = vi.fn((item: any) => { + const div = document.createElement('div'); + div.className = 'custom-slot-content'; + div.textContent = `Custom: ${item.title}`; + return div; + }); + + columnsMock[1].header!.menu = { commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any }; + plugin.init(columnsMock as any); + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as any; + headerButtonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const headerMenuElm = gridContainerDiv.querySelector('.slick-header-menu') as any; + const commandListElm = headerMenuElm?.querySelector('.slick-menu-command-list') as any; + const customSlotElm = commandListElm?.querySelector('.custom-slot-content') as any; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm?.textContent).toBe('Custom: Test Command'); + }); + + it('should render menu item with slotRenderer returning string', () => { + const mockSlotRenderer = vi.fn((item: any) => `String: ${item.title}`); + + columnsMock[1].header!.menu = { commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any }; + plugin.init(columnsMock as any); + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as any; + headerButtonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const headerMenuElm = gridContainerDiv.querySelector('.slick-header-menu') as any; + const commandListElm = headerMenuElm?.querySelector('.slick-menu-command-list') as any; + const customSlotElm = commandListElm?.querySelector('.custom-string') as any; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(customSlotElm).toBeTruthy(); + expect(customSlotElm?.textContent).toContain('String: Test Command'); + }); + + it('should render menu item with defaultItemRenderer when item has no slotRenderer', () => { + const mockDefaultRenderer = vi.fn((item: any) => { + const div = document.createElement('div'); + div.className = 'default-renderer-content'; + div.textContent = `Default: ${item.title}`; + return div; + }); + + columnsMock[1].header!.menu = { commandItems: [{ command: 'test-cmd', title: 'Test Command' }] as any }; + plugin.init(columnsMock as any); + plugin.addonOptions.defaultItemRenderer = mockDefaultRenderer as any; + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as any; + headerButtonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const headerMenuElm = gridContainerDiv.querySelector('.slick-header-menu') as any; + const commandListElm = headerMenuElm?.querySelector('.slick-menu-command-list') as any; + const defaultRendererElm = commandListElm?.querySelector('.default-renderer-content') as any; + + expect(mockDefaultRenderer).toHaveBeenCalled(); + expect(defaultRendererElm).toBeTruthy(); + expect(defaultRendererElm?.textContent).toBe('Default: Test Command'); + }); + + it('should prioritize item slotRenderer over defaultItemRenderer', () => { + const mockSlotRenderer = vi.fn((item: any) => { + const div = document.createElement('div'); + div.className = 'slot-prioritized'; + div.textContent = 'Slot renderer prioritized'; + return div; + }); + const mockDefaultRenderer = vi.fn(); + + columnsMock[1].header!.menu = { commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any }; + plugin.init(columnsMock as any); + plugin.addonOptions.defaultItemRenderer = mockDefaultRenderer as any; + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as any; + headerButtonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const headerMenuElm = gridContainerDiv.querySelector('.slick-header-menu') as any; + const commandListElm = headerMenuElm?.querySelector('.slick-menu-command-list') as any; + const slotRendererElm = commandListElm?.querySelector('.slot-prioritized') as any; + + expect(mockSlotRenderer).toHaveBeenCalled(); + expect(slotRendererElm).toBeTruthy(); + expect(slotRendererElm?.textContent).toBe('Slot renderer prioritized'); + }); + + it('should pass correct arguments (item and args) to slotRenderer callback', () => { + const mockSlotRenderer = vi.fn((item: any, args: any) => { + const div = document.createElement('div'); + div.className = 'renderer-args-test'; + div.textContent = `Item: ${item.command}, Grid: ${args?.grid ? 'present' : 'missing'}`; + return div; + }); + + columnsMock[1].header!.menu = { commandItems: [{ command: 'test-cmd', title: 'Test Command with Args', slotRenderer: mockSlotRenderer }] as any }; + plugin.init(columnsMock as any); + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as any; + headerButtonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + expect(mockSlotRenderer).toHaveBeenCalled(); + const callArgs = mockSlotRenderer.mock.calls[0]; + expect(callArgs[0].command).toBe('test-cmd'); + expect(callArgs[1]).toBeDefined(); + expect(callArgs[1].grid).toBe(gridStub); + }); + + it('should call slotRenderer with click event as third argument when menu item is clicked', () => { + const mockSlotRenderer = vi.fn((item: any, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'click-test'; + return div; + }); + + columnsMock[1].header!.menu = { commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer }] as any }; + plugin.init(columnsMock as any); + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as any; + headerButtonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const headerMenuElm = gridContainerDiv.querySelector('.slick-header-menu') as any; + const commandListElm = headerMenuElm?.querySelector('.slick-menu-command-list') as any; + const menuItemElm = commandListElm?.querySelector('.slick-menu-item') as any; + + // Click the menu item + menuItemElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true })); + + // Verify slotRenderer was called with the click event as the third argument + expect(mockSlotRenderer).toHaveBeenCalledTimes(2); // once for render, once for click + const clickCallArgs = mockSlotRenderer.mock.calls[1]; // second call is from click + expect(clickCallArgs[2]).toBeDefined(); + expect(clickCallArgs[2]!.type).toBe('click'); + }); + + it('should not trigger menu action when slotRenderer calls preventDefault on click event', () => { + const mockAction = vi.fn(); + const mockSlotRenderer = vi.fn((item: any, args: any, event?: Event) => { + const div = document.createElement('div'); + div.className = 'prevent-default-test'; + const button = document.createElement('button'); + button.textContent = 'Interactive'; + button.onclick = (e) => { + e.preventDefault(); // Prevent default action + }; + div.appendChild(button); + return div; + }); + + columnsMock[1].header!.menu = { + commandItems: [{ command: 'test-cmd', title: 'Test Command', slotRenderer: mockSlotRenderer, action: mockAction }] as any, + }; + plugin.init(columnsMock as any); + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as any; + headerButtonElm?.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const headerMenuElm = gridContainerDiv.querySelector('.slick-header-menu') as any; + const commandListElm = headerMenuElm?.querySelector('.slick-menu-command-list') as any; + const menuItemElm = commandListElm?.querySelector('.slick-menu-item') as any; + const buttonElm = menuItemElm?.querySelector('button') as HTMLButtonElement; + + // Click the button inside the slotRenderer, which calls preventDefault + buttonElm?.click(); + + // Verify the action callback was not called because preventDefault was called + expect(mockAction).not.toHaveBeenCalled(); + }); + }); + describe('Internal Custom Commands', () => { let eventData: SlickEventData; From 003c788818d95407f1fd6116ca75d46193770813 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 11 Feb 2026 21:13:37 -0500 Subject: [PATCH 05/24] chore: add new `commandListBuilder` callback --- demos/vanilla/src/examples/example40.html | 18 + demos/vanilla/src/examples/example40.scss | 2 +- demos/vanilla/src/examples/example40.ts | 356 +++++++++++------- docs/menu-slots.md | 80 ++-- .../__tests__/slickGridMenu.spec.ts | 14 + .../__tests__/slickHeaderMenu.spec.ts | 83 +++- .../common/src/extensions/extensionUtility.ts | 2 +- .../common/src/extensions/slickCellMenu.ts | 1 + .../common/src/extensions/slickContextMenu.ts | 30 +- .../common/src/extensions/slickGridMenu.ts | 232 ++++++------ .../common/src/extensions/slickHeaderMenu.ts | 55 +-- .../interfaces/cellMenuOption.interface.ts | 2 +- .../src/interfaces/menuItem.interface.ts | 9 +- .../src/interfaces/menuOption.interface.ts | 42 ++- 14 files changed, 580 insertions(+), 346 deletions(-) diff --git a/demos/vanilla/src/examples/example40.html b/demos/vanilla/src/examples/example40.html index b5fa4aa2b2..31b7f08d46 100644 --- a/demos/vanilla/src/examples/example40.html +++ b/demos/vanilla/src/examples/example40.html @@ -36,4 +36,22 @@

+
+
+ + + + +
+
+
diff --git a/demos/vanilla/src/examples/example40.scss b/demos/vanilla/src/examples/example40.scss index 9c7e16f377..97b79130be 100644 --- a/demos/vanilla/src/examples/example40.scss +++ b/demos/vanilla/src/examples/example40.scss @@ -13,7 +13,7 @@ body { .slick-context-menu, .slick-grid-menu, .slick-header-menu { - .slick-menu-item:hover { + .slick-menu-item:hover:not(.slick-menu-item-disabled) { color: #0a34b5; } } diff --git a/demos/vanilla/src/examples/example40.ts b/demos/vanilla/src/examples/example40.ts index ce822557fa..79a36f26d3 100644 --- a/demos/vanilla/src/examples/example40.ts +++ b/demos/vanilla/src/examples/example40.ts @@ -1,6 +1,17 @@ import { format as tempoFormat } from '@formkit/tempo'; import { BindingEventService } from '@slickgrid-universal/binding'; -import { createDomElement, Filters, Formatters, getOffset, type Column, type GridOption } from '@slickgrid-universal/common'; +import { + Aggregators, + createDomElement, + Filters, + Formatters, + getOffset, + SortComparers, + SortDirectionNumber, + type Column, + type GridOption, + type Grouping, +} from '@slickgrid-universal/common'; import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; import { ExampleGridOptions } from './example-grid-options.js'; @@ -187,11 +198,11 @@ export default class Example40 { command: 'custom-action', title: 'Advanced Export', // Demo: Native HTMLElement with event listeners using slotRenderer (full DOM control) - slotRenderer: (item, _args) => { + slotRenderer: (cmdItem, _args) => { // you can use `createDomElement()` from Slickgrid for easier DOM element creation const containerDiv = createDomElement('div', { className: 'menu-item' }); const iconDiv = createDomElement('div', { className: 'advanced-export-icon', textContent: '📊' }); - const textSpan = createDomElement('span', { textContent: item.title, style: { flex: '1' } }); + const textSpan = createDomElement('span', { textContent: cmdItem.title, style: { flex: '1' } }); const kbdElm = createDomElement('kbd', { className: 'key-hint', textContent: 'Ctrl+E' }); containerDiv.appendChild(iconDiv); containerDiv.appendChild(textSpan); @@ -208,8 +219,8 @@ export default class Example40 { containerDiv.querySelector('.key-hint')!.style.color = 'black'; }); containerDiv.addEventListener('mouseleave', () => { - iconDiv.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; iconDiv.style.transform = 'scale(1)'; + iconDiv.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; containerDiv.parentElement!.style.backgroundColor = 'white'; containerDiv.style.color = 'black'; document.querySelector('.export-timestamp')?.remove(); @@ -282,11 +293,11 @@ export default class Example40 { hideCloseButton: false, commandTitle: 'Cell Actions', // Demo: Menu-level default renderer that applies to all items unless overridden - defaultItemRenderer: (item, _args) => { + defaultItemRenderer: (cmdItem, _args) => { return ` `; }, @@ -386,17 +397,18 @@ export default class Example40 { enableCellNavigation: true, enableFiltering: true, enableSorting: true, + enableGrouping: true, // Header Menu with slots (already configured in columns above) enableHeaderMenu: true, headerMenu: { hideColumnHideCommand: false, // Demo: Menu-level default renderer for all header menu items - defaultItemRenderer: (item, _args) => { + defaultItemRenderer: (cmdItem, _args) => { return ` `; }, @@ -408,146 +420,212 @@ export default class Example40 { // Context Menu with slot examples enableContextMenu: true, contextMenu: { + // build your command items list + // spread built-in commands and optionally filter/sort them however you want + commandListBuilder: (builtInItems) => { + return [ + // filter commands if you want + // ...builtInItems.filter((x) => x !== 'divider' && x.command !== 'copy' && x.command !== 'clear-grouping'), + ...builtInItems, + { divider: true, command: '' }, + { + // positionOrder: 60, + command: 'edit-cell', + title: 'Edit Cell', + // Demo: Individual slotRenderer overrides the menu's defaultItemRenderer + slotRenderer: (cmdItem, _args) => { + // you can use `createDomElement()` from Slickgrid for easier DOM element creation + const containerDiv = createDomElement('div', { className: 'menu-item' }); + const iconDiv = createDomElement('div', { className: 'edit-cell-icon', textContent: '✎' }); + const textSpan = createDomElement('span', { textContent: cmdItem.title, style: { flex: '1' } }); + const kbdElm = createDomElement('kbd', { className: 'edit-cell', textContent: 'F2' }); + containerDiv.appendChild(iconDiv); + containerDiv.appendChild(textSpan); + containerDiv.appendChild(kbdElm); + + // Native event listeners for interactive effects + containerDiv.addEventListener('mouseenter', () => { + iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; + iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; + }); + containerDiv.addEventListener('mouseleave', () => { + iconDiv.style.transform = 'rotate(0deg) scale(1)'; + iconDiv.style.boxShadow = 'none'; + }); + + return containerDiv; + }, + action: () => alert('Edit cell'), + }, + { divider: true, command: '' }, + { + command: 'export', + title: 'Export', + iconCssClass: 'mdi mdi-download', + commandItems: [ + { + command: 'export-excel', + title: 'Export as Excel', + iconCssClass: 'mdi mdi-file-excel-outline text-success', + action: () => alert('Export to Excel'), + }, + { + command: 'export-csv', + title: 'Export as CSV', + iconCssClass: 'mdi mdi-file-document-outline', + action: () => alert('Export to CSV'), + }, + { + command: 'export-pdf', + title: 'Export as PDF', + iconCssClass: 'mdi mdi-file-pdf-outline text-danger', + action: () => alert('Export to PDF'), + }, + ], + }, + { divider: true, command: '' }, + { + command: 'delete-row', + title: 'Delete Row', + iconCssClass: 'mdi mdi-delete text-danger', + action: () => alert('Delete row'), + }, + ]; + // commandItems.sort((a, b) => (a === 'divider' || b === 'divider' ? 0 : a.title! > b.title! ? -1 : 1)); + // console.log('initial command items', commandItems); + // return commandItems; + }, // Demo: Menu-level default renderer for context menu items - defaultItemRenderer: (item, _args) => { + defaultItemRenderer: (cmdItem, _args) => { return ` `; }, - commandItems: [ - { - positionOrder: 60, - command: 'edit-cell', - title: 'Edit Cell', - // Demo: Individual slotRenderer overrides the menu's defaultItemRenderer - slotRenderer: (item, _args) => { - // you can use `createDomElement()` from Slickgrid for easier DOM element creation - const containerDiv = createDomElement('div', { className: 'menu-item' }); - const iconDiv = createDomElement('div', { className: 'edit-cell-icon', textContent: '✎' }); - const textSpan = createDomElement('span', { textContent: item.title, style: { flex: '1' } }); - const kbdElm = createDomElement('kbd', { className: 'edit-cell', textContent: 'F2' }); - containerDiv.appendChild(iconDiv); - containerDiv.appendChild(textSpan); - containerDiv.appendChild(kbdElm); + // commandItems: [ + // { + // positionOrder: 60, + // command: 'edit-cell', + // title: 'Edit Cell', + // // Demo: Individual slotRenderer overrides the menu's defaultItemRenderer + // slotRenderer: (cmdItem, _args) => { + // // you can use `createDomElement()` from Slickgrid for easier DOM element creation + // const containerDiv = createDomElement('div', { className: 'menu-item' }); + // const iconDiv = createDomElement('div', { className: 'edit-cell-icon', textContent: '✎' }); + // const textSpan = createDomElement('span', { textContent: cmdItem.title, style: { flex: '1' } }); + // const kbdElm = createDomElement('kbd', { className: 'edit-cell', textContent: 'F2' }); + // containerDiv.appendChild(iconDiv); + // containerDiv.appendChild(textSpan); + // containerDiv.appendChild(kbdElm); - // Native event listeners for interactive effects - containerDiv.addEventListener('mouseenter', () => { - iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; - iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; - }); - containerDiv.addEventListener('mouseleave', () => { - iconDiv.style.transform = 'rotate(0deg) scale(1)'; - iconDiv.style.boxShadow = 'none'; - }); + // // Native event listeners for interactive effects + // containerDiv.addEventListener('mouseenter', () => { + // iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; + // iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; + // }); + // containerDiv.addEventListener('mouseleave', () => { + // iconDiv.style.transform = 'rotate(0deg) scale(1)'; + // iconDiv.style.boxShadow = 'none'; + // }); - return containerDiv; - }, - action: () => alert('Edit cell'), - }, - { divider: true, command: '' }, - { - command: 'export', - title: 'Export', - iconCssClass: 'mdi mdi-download', - commandItems: [ - { - command: 'export-excel', - title: 'Export as Excel', - iconCssClass: 'mdi mdi-file-excel-outline text-success', - action: () => alert('Export to Excel'), - }, - { - command: 'export-csv', - title: 'Export as CSV', - iconCssClass: 'mdi mdi-file-document-outline', - action: () => alert('Export to CSV'), - }, - { - command: 'export-pdf', - title: 'Export as PDF', - iconCssClass: 'mdi mdi-file-pdf-outline text-danger', - action: () => alert('Export to PDF'), - }, - ], - }, - { divider: true, command: '' }, - { - command: 'delete-row', - title: 'Delete Row', - iconCssClass: 'mdi mdi-delete text-danger', - action: () => alert('Delete row'), - }, - ], + // return containerDiv; + // }, + // action: () => alert('Edit cell'), + // }, + // { divider: true, command: '' }, + // { + // command: 'export', + // title: 'Export', + // iconCssClass: 'mdi mdi-download', + // commandItems: [ + // { + // command: 'export-excel', + // title: 'Export as Excel', + // iconCssClass: 'mdi mdi-file-excel-outline text-success', + // action: () => alert('Export to Excel'), + // }, + // { + // command: 'export-csv', + // title: 'Export as CSV', + // iconCssClass: 'mdi mdi-file-document-outline', + // action: () => alert('Export to CSV'), + // }, + // { + // command: 'export-pdf', + // title: 'Export as PDF', + // iconCssClass: 'mdi mdi-file-pdf-outline text-danger', + // action: () => alert('Export to PDF'), + // }, + // ], + // }, + // { divider: true, command: '' }, + // { + // command: 'delete-row', + // title: 'Delete Row', + // iconCssClass: 'mdi mdi-delete text-danger', + // action: () => alert('Delete row'), + // }, + // ], }, // Grid Menu with slot examples (demonstrating defaultItemRenderer at menu level) enableGridMenu: true, gridMenu: { // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) - defaultItemRenderer: (item, _args) => { + defaultItemRenderer: (cmdItem, _args) => { return ` `; }, - commandItems: [ - { - command: 'toggle-filter', - title: 'Toggle Filter Row', - iconCssClass: 'mdi mdi-filter-outline', - action: () => alert('Toggle filter row'), - }, - { - command: 'clear-filters', - title: 'Clear All Filters', - iconCssClass: 'mdi mdi-filter-remove-outline', - action: () => alert('Clear filters'), - }, - { divider: true, command: '' }, - { - command: 'export-excel', - title: 'Export to Excel', - iconCssClass: 'mdi mdi-file-excel-outline', - action: () => alert('Export to Excel'), - }, - { - command: 'export-csv', - title: 'Export to CSV', - iconCssClass: 'mdi mdi-download', - // Individual slotRenderer overrides the defaultItemRenderer for this item - slotRenderer: (item, _args) => ` + commandListBuilder: (builtInItems) => { + return [ + ...builtInItems, + { divider: true, command: '' }, + { + command: 'export-excel', + title: 'Export to Excel', + iconCssClass: 'mdi mdi-file-excel-outline', + action: () => alert('Export to Excel'), + }, + { + command: 'export-csv', + title: 'Export to CSV', + iconCssClass: 'mdi mdi-download', + // Individual slotRenderer overrides the defaultItemRenderer for this item + slotRenderer: (cmdItem, _args) => ` `, - action: () => alert('Export to CSV'), - }, - { - command: 'refresh-data', - title: 'Refresh Data', - iconCssClass: 'mdi mdi-refresh', - // Demo: slotRenderer with keyboard shortcut - slotRenderer: (item) => { - // you can use `createDomElement()` from Slickgrid for easier DOM element creation - const menuItemElm = createDomElement('div', { className: 'menu-item' }); - const iconElm = createDomElement('i', { className: `${item.iconCssClass} menu-item-icon` }); - const menuItemLabelElm = createDomElement('span', { className: 'menu-item-label', textContent: item.title }); - const kbdElm = createDomElement('kbd', { className: 'key-hint', textContent: 'F5' }); - menuItemElm.appendChild(iconElm); - menuItemElm.appendChild(menuItemLabelElm); - menuItemElm.appendChild(kbdElm); - return menuItemElm; + action: () => alert('Export to CSV'), }, - action: () => alert('Refresh data'), - }, - ], + { + command: 'refresh-data', + title: 'Refresh Data', + iconCssClass: 'mdi mdi-refresh', + // Demo: slotRenderer with keyboard shortcut + slotRenderer: (cmdItem) => { + // you can use `createDomElement()` from Slickgrid for easier DOM element creation + const menuItemElm = createDomElement('div', { className: 'menu-item' }); + const iconElm = createDomElement('i', { className: `${cmdItem.iconCssClass} menu-item-icon` }); + const menuItemLabelElm = createDomElement('span', { className: 'menu-item-label', textContent: cmdItem.title }); + const kbdElm = createDomElement('kbd', { className: 'key-hint', textContent: 'F5' }); + menuItemElm.appendChild(iconElm); + menuItemElm.appendChild(menuItemLabelElm); + menuItemElm.appendChild(kbdElm); + return menuItemElm; + }, + action: () => alert('Refresh data'), + }, + ]; + }, }, // tooltip plugin @@ -568,6 +646,32 @@ export default class Example40 { return div; } + clearGrouping() { + this.sgb?.dataView?.setGrouping([]); + } + + collapseAllGroups() { + this.sgb?.dataView?.collapseAllGroups(); + } + + expandAllGroups() { + this.sgb?.dataView?.expandAllGroups(); + } + + groupByDuration() { + // you need to manually add the sort icon(s) in UI + this.sgb?.slickGrid?.setSortColumns([{ columnId: 'duration', sortAsc: true }]); + this.sgb?.dataView?.setGrouping({ + getter: 'duration', + formatter: (g) => `Duration: ${g.value} (${g.count} items)`, + comparer: (a, b) => SortComparers.numeric(a.value, b.value, SortDirectionNumber.asc), + aggregators: [new Aggregators.Avg('percentComplete'), new Aggregators.Sum('cost')], + aggregateCollapsed: false, + lazyTotalsCalculation: true, + } as Grouping); + this.sgb?.slickGrid?.invalidate(); // invalidate all rows and re-render + } + loadData(count: number): ReportItem[] { const tmpData: ReportItem[] = []; for (let i = 0; i < count; i++) { diff --git a/docs/menu-slots.md b/docs/menu-slots.md index 74d892b2c3..ab7c3367fc 100644 --- a/docs/menu-slots.md +++ b/docs/menu-slots.md @@ -9,10 +9,10 @@ Each menu item can define a `slotRenderer` callback function that receives the i ### Slot Renderer Callback ```typescript -slotRenderer?: (item: any, args: any, event?: Event) => string | HTMLElement +slotRenderer?: (cmdItem: any, args: any, event?: Event) => string | HTMLElement ``` -- **item** - The menu item object containing command, title, iconCssClass, etc. +- **cmdItem** - The menu cmdItem object containing command, title, iconCssClass, etc. - **args** - The callback args providing access to grid, column, dataContext, and other context - **event** - Optional DOM event passed during click handling (allows `stopPropagation()`) @@ -42,27 +42,27 @@ const menuItem = { command: 'notifications', title: 'Notifications', // Return HTMLElement for more control and event listeners - slotRenderer: (item, args) => { + slotRenderer: (cmdItem, args) => { const container = document.createElement('div'); container.style.display = 'flex'; container.style.alignItems = 'center'; - + const icon = document.createElement('i'); icon.className = 'mdi mdi-bell'; icon.style.marginRight = '8px'; - + const text = document.createElement('span'); - text.textContent = item.title; - + text.textContent = cmdItem.title; + const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = '5'; badge.style.marginLeft = 'auto'; - + container.appendChild(icon); container.appendChild(text); container.appendChild(badge); - + return container; } }; @@ -75,11 +75,11 @@ Set a `defaultItemRenderer` at the menu option level to apply to all items (unle ```typescript const menuOption = { // Apply this renderer to all menu items (can be overridden per item) - defaultItemRenderer: (item, args) => { + defaultItemRenderer: (cmdItem, args) => { return `
- ${item.iconCssClass ? `` : ''} - ${item.title} + ${cmdItem.iconCssClass ? `` : ''} + ${cmdItem.title}
`; }, @@ -114,7 +114,7 @@ const columnDef = { id: 'name', header: { menu: { - defaultItemRenderer: (item, args) => `
${item.title}
`, + defaultItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, commandItems: [ { command: 'sort', @@ -132,12 +132,12 @@ const columnDef = { const columnDef = { id: 'action', cellMenu: { - defaultItemRenderer: (item, args) => `
${item.title}
`, + defaultItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, commandItems: [ { command: 'edit', title: 'Edit', - slotRenderer: (item, args) => `
Edit row ${args.dataContext.id}
` + slotRenderer: (cmdItem, args) => `
Edit row ${args.dataContext.id}
` } ] } @@ -149,7 +149,7 @@ const columnDef = { const gridOptions = { enableContextMenu: true, contextMenu: { - defaultItemRenderer: (item, args) => `
${item.title}
`, + defaultItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, commandItems: [ { command: 'export', @@ -166,7 +166,7 @@ const gridOptions = { const gridOptions = { enableGridMenu: true, gridMenu: { - defaultItemRenderer: (item, args) => `
${item.title}
`, + defaultItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, commandItems: [ { command: 'refresh', @@ -197,11 +197,11 @@ const menuItem = { const menuItem = { command: 'with-component', title: 'With Angular Component', - slotRenderer: (item, args) => { + slotRenderer: (cmdItem, args) => { // Create a placeholder element const placeholder = document.createElement('div'); placeholder.id = `angular-slot-${Date.now()}`; - + // Schedule component creation for after rendering setTimeout(() => { const element = document.getElementById(placeholder.id); @@ -210,7 +210,7 @@ const menuItem = { element.appendChild(componentRef.location.nativeElement); } }, 0); - + return placeholder; } }; @@ -222,10 +222,10 @@ const menuItem = { const menuItem = { command: 'with-react', title: 'With React Component', - slotRenderer: (item, args) => { + slotRenderer: (cmdItem, args) => { const container = document.createElement('div'); container.id = `react-slot-${Date.now()}`; - + // Schedule component render for after menu renders setTimeout(() => { const element = document.getElementById(container.id); @@ -233,7 +233,7 @@ const menuItem = { ReactDOM.render(, element); } }, 0); - + return container; } }; @@ -245,10 +245,10 @@ const menuItem = { const menuItem = { command: 'with-vue', title: 'With Vue Component', - slotRenderer: (item, args) => { + slotRenderer: (cmdItem, args) => { const container = document.createElement('div'); container.id = `vue-slot-${Date.now()}`; - + // Schedule component mount for after menu renders setTimeout(() => { const element = document.getElementById(container.id); @@ -258,7 +258,7 @@ const menuItem = { element._appInstance = app; } }, 0); - + return container; } }; @@ -303,7 +303,7 @@ const menuItem = { { command: 'edit-row', title: 'Edit Row', - slotRenderer: (item, args) => ` + slotRenderer: (cmdItem, args) => `
Edit Row #${args.dataContext?.id || 'N/A'} @@ -317,13 +317,13 @@ const menuItem = { { command: 'toggle-setting', title: 'Auto Refresh', - slotRenderer: (item, args, event) => { + slotRenderer: (cmdItem, args, event) => { const container = document.createElement('label'); container.style.display = 'flex'; container.style.alignItems = 'center'; container.style.gap = '8px'; container.style.marginRight = 'auto'; - + const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.addEventListener('change', (e) => { @@ -331,10 +331,10 @@ const menuItem = { event?.stopPropagation?.(); console.log('Auto refresh:', checkbox.checked); }); - + const label = document.createElement('span'); - label.textContent = item.title; - + label.textContent = cmdItem.title; + container.appendChild(label); container.appendChild(checkbox); return container; @@ -347,10 +347,10 @@ const menuItem = { { command: 'export-excel', title: 'Export as Excel', - slotRenderer: (item, args) => ` + slotRenderer: (cmdItem, args) => `
- ${item.title} + ${cmdItem.title} RECOMMENDED
` @@ -362,12 +362,12 @@ const menuItem = { { command: 'advanced-export', title: 'Advanced Export', - slotRenderer: (item, args) => { + slotRenderer: (cmdItem, args) => { const container = document.createElement('div'); container.style.display = 'flex'; container.style.alignItems = 'center'; container.style.gap = '8px'; - + const iconDiv = document.createElement('div'); iconDiv.style.width = '20px'; iconDiv.style.height = '20px'; @@ -379,10 +379,10 @@ const menuItem = { iconDiv.style.color = 'white'; iconDiv.style.fontSize = '12px'; iconDiv.innerHTML = '📊'; - + const textSpan = document.createElement('span'); - textSpan.textContent = item.title; - + textSpan.textContent = cmdItem.title; + container.appendChild(iconDiv); container.appendChild(textSpan); return container; @@ -481,7 +481,7 @@ When creating custom renderers, handle potential errors gracefully: { command: 'safe-render', title: 'Safe Render', - slotRenderer: (item, args) => { + slotRenderer: (cmdItem, args) => { try { if (args?.dataContext?.status === 'error') { return `
❌ Error loading
`; diff --git a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts index e5a715cfc9..f6beff47c2 100644 --- a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts @@ -1483,6 +1483,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-pinning', positionOrder: 52, + action: expect.any(Function), }, ]); }); @@ -1503,6 +1504,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-filter', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1512,6 +1514,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-filter', positionOrder: 53, + action: expect.any(Function), }, { _orgTitle: '', @@ -1521,6 +1524,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'refresh-dataset', positionOrder: 58, + action: expect.any(Function), }, ]); }); @@ -1552,6 +1556,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-filter', positionOrder: 50, + action: expect.any(Function), }, ]); }); @@ -1583,6 +1588,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-filter', positionOrder: 53, + action: expect.any(Function), }, ]); }); @@ -1613,6 +1619,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-dark-mode', positionOrder: 54, + action: expect.any(Function), }, ]); }); @@ -1644,6 +1651,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'refresh-dataset', positionOrder: 58, + action: expect.any(Function), }, ]); }); @@ -1664,6 +1672,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-preheader', positionOrder: 53, + action: expect.any(Function), }, ]); }); @@ -1703,6 +1712,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-sorting', positionOrder: 51, + action: expect.any(Function), }, ]); }); @@ -1752,6 +1762,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-csv', positionOrder: 55, + action: expect.any(Function), }, ]); }); @@ -1804,6 +1815,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-excel', positionOrder: 56, + action: expect.any(Function), }, ]); }); @@ -1837,6 +1849,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-pdf', positionOrder: 57, + action: expect.any(Function), }, ]); }); @@ -1867,6 +1880,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-text-delimited', positionOrder: 58, + action: expect.any(Function), }, ]); }); diff --git a/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts b/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts index f074f43ea7..19d764191b 100644 --- a/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickHeaderMenu.spec.ts @@ -1107,6 +1107,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1123,6 +1124,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1159,6 +1161,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'UNFREEZE_COLUMNS', command: 'unfreeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1175,6 +1178,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'UNFREEZE_COLUMNS', command: 'unfreeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1209,6 +1213,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1250,6 +1255,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1281,6 +1287,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'REMOVE_FILTER', command: 'clear-filter', positionOrder: 57, + action: expect.any(Function), }, ]; const commandDivElm = gridContainerDiv.querySelector('[data-command="clear-filter"]') as HTMLDivElement; @@ -1325,9 +1332,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'COLUMN_RESIZE_BY_CONTENT', command: 'column-resize-by-content', positionOrder: 47, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, - { _orgTitle: '', iconCssClass: 'mdi mdi-close', title: 'Hide Column', titleKey: 'HIDE_COLUMN', command: 'hide-column', positionOrder: 59 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-close', + title: 'Hide Column', + titleKey: 'HIDE_COLUMN', + command: 'hide-column', + positionOrder: 59, + action: expect.any(Function), + }, ]; const commandDivElm = gridContainerDiv.querySelector('[data-command="column-resize-by-content"]') as HTMLDivElement; const commandIconElm = commandDivElm.querySelector('.slick-menu-icon') as HTMLDivElement; @@ -1370,6 +1386,7 @@ describe('HeaderMenu Plugin', () => { positionOrder: 45, title: 'Freeze Columns', titleKey: 'FREEZE_COLUMNS', + action: expect.any(Function), }, { command: 'show-negative-numbers', cssClass: 'mdi mdi-lightbulb-on', tooltip: 'Highlight negative numbers.' }, { @@ -1379,6 +1396,7 @@ describe('HeaderMenu Plugin', () => { positionOrder: 47, title: 'Resize by Content', titleKey: 'COLUMN_RESIZE_BY_CONTENT', + action: expect.any(Function), }, { command: '', divider: true, positionOrder: 48 }, { @@ -1408,7 +1426,15 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FILTER_SHORTCUTS', }, { command: '', divider: true, positionOrder: 56 }, - { _orgTitle: '', command: 'hide-column', iconCssClass: 'mdi mdi-close', positionOrder: 59, title: 'Hide Column', titleKey: 'HIDE_COLUMN' }, + { + _orgTitle: '', + command: 'hide-column', + iconCssClass: 'mdi mdi-close', + positionOrder: 59, + title: 'Hide Column', + titleKey: 'HIDE_COLUMN', + action: expect.any(Function), + }, ]; const shortcutSubMenuElm = gridContainerDiv.querySelector('[data-command="filter-shortcuts-root-menu"]') as HTMLDivElement; shortcutSubMenuElm!.dispatchEvent(new Event('mouseover')); @@ -1452,9 +1478,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, - { _orgTitle: '', iconCssClass: 'mdi mdi-close', title: 'Hide Column', titleKey: 'HIDE_COLUMN', command: 'hide-column', positionOrder: 59 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-close', + title: 'Hide Column', + titleKey: 'HIDE_COLUMN', + command: 'hide-column', + positionOrder: 59, + action: expect.any(Function), + }, ]; const commandDivElm = gridContainerDiv.querySelector('[data-command="hide-column"]') as HTMLDivElement; const commandIconElm = commandDivElm.querySelector('.slick-menu-icon') as HTMLDivElement; @@ -1490,6 +1525,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'REMOVE_FILTER', command: 'clear-filter', positionOrder: 57, + action: expect.any(Function), }, ]; const commandDivElm = gridContainerDiv.querySelector('[data-command="clear-filter"]') as HTMLDivElement; @@ -1530,6 +1566,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1538,9 +1575,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 52 }, - { _orgTitle: '', iconCssClass: 'mdi mdi-sort-variant-off', title: 'Remove Sort', titleKey: 'REMOVE_SORT', command: 'clear-sort', positionOrder: 58 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-sort-variant-off', + title: 'Remove Sort', + titleKey: 'REMOVE_SORT', + command: 'clear-sort', + positionOrder: 58, + action: expect.any(Function), + }, ]); expect(commandIconElm.classList.contains('mdi-sort-variant-off')).toBeTruthy(); expect(commandLabelElm.textContent).toBe('Remove Sort'); @@ -1555,6 +1601,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1563,6 +1610,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 52 }, { @@ -1572,6 +1620,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'REMOVE_SORT', command: 'clear-sort', positionOrder: 58, + action: expect.any(Function), }, ]); @@ -1611,6 +1660,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1655,6 +1705,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 48 }, ]); @@ -1695,6 +1746,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1703,9 +1755,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 52 }, - { _orgTitle: '', iconCssClass: 'mdi mdi-sort-variant-off', title: 'Remove Sort', titleKey: 'REMOVE_SORT', command: 'clear-sort', positionOrder: 58 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-sort-variant-off', + title: 'Remove Sort', + titleKey: 'REMOVE_SORT', + command: 'clear-sort', + positionOrder: 58, + action: expect.any(Function), + }, ]); const clickEvent = new Event('click'); @@ -1747,6 +1808,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1755,9 +1817,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), }, { divider: true, command: '', positionOrder: 52 }, - { _orgTitle: '', iconCssClass: 'mdi mdi-sort-variant-off', title: 'Remove Sort', titleKey: 'REMOVE_SORT', command: 'clear-sort', positionOrder: 58 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-sort-variant-off', + title: 'Remove Sort', + titleKey: 'REMOVE_SORT', + command: 'clear-sort', + positionOrder: 58, + action: expect.any(Function), + }, ]); const clickEvent = new Event('click'); diff --git a/packages/common/src/extensions/extensionUtility.ts b/packages/common/src/extensions/extensionUtility.ts index a171e3fd25..aace0fa4b9 100644 --- a/packages/common/src/extensions/extensionUtility.ts +++ b/packages/common/src/extensions/extensionUtility.ts @@ -75,7 +75,7 @@ export class ExtensionUtility { } /** - * Sort items (by pointers) in an array by a property name + * @deprecated Sort items (by pointers) in an array by a property name * @param {Array} items array * @param {String} property name to sort with */ diff --git a/packages/common/src/extensions/slickCellMenu.ts b/packages/common/src/extensions/slickCellMenu.ts index 3f8a653bfa..a4f18624cb 100644 --- a/packages/common/src/extensions/slickCellMenu.ts +++ b/packages/common/src/extensions/slickCellMenu.ts @@ -152,6 +152,7 @@ export class SlickCellMenu extends MenuFromCellBaseClass { // protected functions // ------------------ + /** @deprecated Sort items (by pointers) in an array by a property name */ protected sortMenuItems(columns: Column[]): void { // sort both items list columns.forEach((columnDef: Column) => { diff --git a/packages/common/src/extensions/slickContextMenu.ts b/packages/common/src/extensions/slickContextMenu.ts index 6020cbc86e..51f2f2a471 100644 --- a/packages/common/src/extensions/slickContextMenu.ts +++ b/packages/common/src/extensions/slickContextMenu.ts @@ -4,7 +4,6 @@ import type { SlickEventData, SlickGrid } from '../core/index.js'; import type { ExtensionUtility } from '../extensions/extensionUtility.js'; import { copyCellToClipboard } from '../formatters/formatterUtilities.js'; import type { - Column, ContextMenu, ContextMenuOption, MenuCallbackArgs, @@ -74,9 +73,10 @@ export class SlickContextMenu extends MenuFromCellBaseClass { this._addonOptions = { ...this._defaults, ...contextMenuOptions }; // merge the original commands with the built-in internal commands - const originalCommandItems = - this._addonOptions && Array.isArray(this._addonOptions.commandItems) ? this._addonOptions.commandItems : []; - this._addonOptions.commandItems = [...originalCommandItems, ...this.addMenuCustomCommands(originalCommandItems)]; + const originalCommandItems = Array.isArray(this._addonOptions?.commandItems) ? this._addonOptions.commandItems : []; + const initialCommandItems = [...originalCommandItems, ...this.addMenuCustomCommands(originalCommandItems)]; + this._addonOptions.commandItems = + (this._addonOptions.commandListBuilder?.(initialCommandItems) as Array) ?? initialCommandItems; this._addonOptions = { ...this._addonOptions }; this.sharedService.gridOptions.contextMenu = this._addonOptions; @@ -174,7 +174,7 @@ export class SlickContextMenu extends MenuFromCellBaseClass { /** Create Context Menu with Custom Commands (copy cell value, export) */ protected addMenuCustomCommands( originalCommandItems: Array - ): (MenuCommandItem> | 'divider')[] { + ): Array> | 'divider'> { const menuCommandItems: Array = []; const gridOptions = (this.sharedService && this.sharedService.gridOptions) || {}; const contextMenu = gridOptions?.contextMenu; @@ -198,8 +198,8 @@ export class SlickContextMenu extends MenuFromCellBaseClass { action: (_e, args) => copyCellToClipboard(args as MenuCommandItemCallbackArgs), itemUsabilityOverride: (args: MenuCallbackArgs) => { // make sure there's an item to copy before enabling this command - const columnDef = args?.column as Column; - const dataContext = args?.dataContext; + const columnDef = args.column; + const dataContext = args.dataContext; if (typeof columnDef.queryFieldNameGetterFn === 'function') { const cellValue = getCellValueFromQueryFieldGetter(columnDef, dataContext, ''); if (cellValue !== '' && cellValue !== undefined) { @@ -229,7 +229,9 @@ export class SlickContextMenu extends MenuFromCellBaseClass { positionOrder: 51, action: () => { const registedServices = this.sharedService?.externalRegisteredResources || []; - const excelService: TextExportService = registedServices.find((service: any) => service.className === 'TextExportService'); + const excelService: TextExportService | undefined = registedServices.find( + (service: any) => service.className === 'TextExportService' + ); if (excelService?.exportToFile) { excelService.exportToFile({ delimiter: ',', @@ -284,7 +286,9 @@ export class SlickContextMenu extends MenuFromCellBaseClass { positionOrder: 53, action: () => { const registedServices = this.sharedService?.externalRegisteredResources || []; - const pdfService: PdfExportService = registedServices.find((service: any) => service.className === 'PdfExportService'); + const pdfService: PdfExportService | undefined = registedServices.find( + (service: any) => service.className === 'PdfExportService' + ); if (pdfService?.exportToPdf) { pdfService.exportToPdf(); } else { @@ -310,7 +314,9 @@ export class SlickContextMenu extends MenuFromCellBaseClass { positionOrder: 54, action: () => { const registedServices = this.sharedService?.externalRegisteredResources || []; - const excelService: TextExportService = registedServices.find((service: any) => service.className === 'TextExportService'); + const excelService: TextExportService | undefined = registedServices.find( + (service: any) => service.className === 'TextExportService' + ); if (excelService?.exportToFile) { excelService.exportToFile({ delimiter: '\t', @@ -330,7 +336,7 @@ export class SlickContextMenu extends MenuFromCellBaseClass { if (gridOptions && (gridOptions.enableGrouping || gridOptions.enableDraggableGrouping || gridOptions.enableTreeData)) { // add a divider (separator) between the top sort commands and the other clear commands if (contextMenu && !contextMenu.hideCopyCellValueCommand) { - menuCommandItems.push({ divider: true, command: '', positionOrder: 54 }); + menuCommandItems.push({ divider: true, command: 'divider-1', positionOrder: 54 }); } // show context menu: Clear Grouping (except for Tree Data which shouldn't have this feature) @@ -424,7 +430,7 @@ export class SlickContextMenu extends MenuFromCellBaseClass { return menuCommandItems; } - /** sort all menu items by their position order when defined */ + /** @deprecated sort all menu items by their position order when defined */ protected sortMenuItems(): void { const contextMenu = this.sharedService?.gridOptions?.contextMenu; if (contextMenu) { diff --git a/packages/common/src/extensions/slickGridMenu.ts b/packages/common/src/extensions/slickGridMenu.ts index cc0ca18af8..119510385d 100644 --- a/packages/common/src/extensions/slickGridMenu.ts +++ b/packages/common/src/extensions/slickGridMenu.ts @@ -171,7 +171,11 @@ export class SlickGridMenu extends MenuBaseClass { // then sort all Grid Menu command items (sorted by pointer, no need to use the return) const gridMenuCommandItems = this._userOriginalGridMenu.commandItems; const originalCommandItems = this._userOriginalGridMenu && Array.isArray(gridMenuCommandItems) ? gridMenuCommandItems : []; - this._addonOptions.commandItems = [...originalCommandItems, ...this.addGridMenuCustomCommands(originalCommandItems)]; + + // merge the original commands with the built-in internal commands + const initialCommandItems = [...originalCommandItems, ...this.addGridMenuCustomCommands(originalCommandItems)]; + this._addonOptions.commandItems = + (this._addonOptions.commandListBuilder?.(initialCommandItems) as Array) ?? initialCommandItems; this.extensionUtility.translateMenuItemsFromTitleKey(this._addonOptions.commandItems || [], 'commandItems'); this.extensionUtility.sortItems(this._addonOptions.commandItems, 'positionOrder'); @@ -555,6 +559,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 52, + action: this.clearPinning.bind(this), }); } } @@ -571,6 +576,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 50, + action: this.clearFilters.bind(this), }); } } @@ -586,6 +592,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 53, + action: this.toggleFilterBar.bind(this), }); } } @@ -601,6 +608,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 58, + action: () => this.extensionUtility.refreshBackendDataset(), }); } } @@ -617,6 +625,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 54, + action: this.toggleDarkMode.bind(this), }); } } @@ -633,6 +642,10 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 53, + action: () => { + const showPreHeaderPanel = this.gridOptions?.showPreHeaderPanel ?? false; + this.grid.setPreHeaderPanelVisibility(!showPreHeaderPanel); + }, }); } } @@ -650,6 +663,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 51, + action: this.clearSorting.bind(this), }); } } @@ -666,6 +680,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 55, + action: this.exportCsv.bind(this), }); } } @@ -681,6 +696,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 56, + action: this.exportExcel.bind(this), }); } } @@ -696,6 +712,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 57, + action: this.exportPdf.bind(this), }); } } @@ -711,6 +728,7 @@ export class SlickGridMenu extends MenuBaseClass { disabled: false, command: commandName, positionOrder: 58, + action: this.exportTextDelimited.bind(this), }); } } @@ -728,117 +746,110 @@ export class SlickGridMenu extends MenuBaseClass { return gridMenuCommandItems; } - /** - * Execute the Grid Menu Custom command callback that was triggered by the onCommand subscribe - * These are the default internal custom commands - * @param event - * @param GridMenuItem args - */ - protected executeGridMenuInternalCustomCommands(_e: Event, args: GridMenuItem): void { + protected clearFilters(): void { + this.filterService.clearFilters(); + this.sharedService.dataView.refresh(); + this.pubSubService.publish('onGridMenuClearAllFilters'); + } + + protected clearPinning(): void { + const newGridOptions = { frozenColumn: -1, frozenRow: -1, frozenBottom: false, enableMouseWheelScrollHandler: false }; + this.grid.setOptions(newGridOptions); + this.sharedService.gridOptions.frozenColumn = newGridOptions.frozenColumn; + this.sharedService.gridOptions.frozenRow = newGridOptions.frozenRow; + this.sharedService.gridOptions.frozenBottom = newGridOptions.frozenBottom; + this.sharedService.gridOptions.enableMouseWheelScrollHandler = newGridOptions.enableMouseWheelScrollHandler; + + // re-update columns to reflect any possible changes + this.grid.updateColumns(); + + // we also need to autosize columns if the option is enabled + const gridOptions = this.gridOptions; + if (gridOptions.enableAutoSizeColumns) { + this.grid.autosizeColumns(); + } + this.pubSubService.publish('onGridMenuClearAllPinning'); + } + + protected clearSorting(): void { + this.sortService.clearSorting(); + this.sharedService.dataView.refresh(); + this.pubSubService.publish('onGridMenuClearAllSorting'); + } + + protected exportCsv(): void { const registeredResources = this.sharedService?.externalRegisteredResources || []; + const exportCsvService: TextExportService = registeredResources.find((service: any) => service.className === 'TextExportService'); + if (exportCsvService?.exportToFile) { + exportCsvService.exportToFile({ + delimiter: ',', + format: 'csv', + }); + } else { + console.error( + `[Slickgrid-Universal] You must register the TextExportService to properly use Export to File in the Grid Menu. Example:: this.gridOptions = { enableTextExport: true, externalResources: [new TextExportService()] };` + ); + } + } - if (args?.command) { - switch (args.command) { - case 'clear-pinning': - const newGridOptions = { frozenColumn: -1, frozenRow: -1, frozenBottom: false, enableMouseWheelScrollHandler: false }; - this.grid.setOptions(newGridOptions); - this.sharedService.gridOptions.frozenColumn = newGridOptions.frozenColumn; - this.sharedService.gridOptions.frozenRow = newGridOptions.frozenRow; - this.sharedService.gridOptions.frozenBottom = newGridOptions.frozenBottom; - this.sharedService.gridOptions.enableMouseWheelScrollHandler = newGridOptions.enableMouseWheelScrollHandler; - - // re-update columns to reflect any possible changes - this.grid.updateColumns(); - - // we also need to autosize columns if the option is enabled - const gridOptions = this.gridOptions; - if (gridOptions.enableAutoSizeColumns) { - this.grid.autosizeColumns(); - } - this.pubSubService.publish('onGridMenuClearAllPinning'); - break; - case 'clear-filter': - this.filterService.clearFilters(); - this.sharedService.dataView.refresh(); - this.pubSubService.publish('onGridMenuClearAllFilters'); - break; - case 'clear-sorting': - this.sortService.clearSorting(); - this.sharedService.dataView.refresh(); - this.pubSubService.publish('onGridMenuClearAllSorting'); - break; - case 'export-csv': - const exportCsvService: TextExportService = registeredResources.find((service: any) => service.className === 'TextExportService'); - if (exportCsvService?.exportToFile) { - exportCsvService.exportToFile({ - delimiter: ',', - format: 'csv', - }); - } else { - console.error( - `[Slickgrid-Universal] You must register the TextExportService to properly use Export to File in the Grid Menu. Example:: this.gridOptions = { enableTextExport: true, externalResources: [new TextExportService()] };` - ); - } - break; - case 'export-excel': - const excelService: ExcelExportService = registeredResources.find((service: any) => service.className === 'ExcelExportService'); - if (excelService?.exportToExcel) { - excelService.exportToExcel(); - } else { - console.error( - `[Slickgrid-Universal] You must register the ExcelExportService to properly use Export to Excel in the Grid Menu. Example:: this.gridOptions = { enableExcelExport: true, externalResources: [new ExcelExportService()] };` - ); - } - break; - case 'export-pdf': - const pdfService: PdfExportService = registeredResources.find((service: any) => service.className === 'PdfExportService'); - if (pdfService?.exportToPdf) { - pdfService.exportToPdf(); - } else { - console.error( - `[Slickgrid-Universal] You must register the PdfExportService to properly use Export to PDF in the Grid Menu. Example:: this.gridOptions = { enablePdfExport: true, externalResources: [new PdfExportService()] };` - ); - } - break; - case 'export-text-delimited': - const exportTxtService: TextExportService = registeredResources.find((service: any) => service.className === 'TextExportService'); - if (exportTxtService?.exportToFile) { - exportTxtService.exportToFile({ - delimiter: '\t', - format: 'txt', - }); - } else { - console.error( - `[Slickgrid-Universal] You must register the TextExportService to properly use Export to File in the Grid Menu. Example:: this.gridOptions = { enableTextExport: true, externalResources: [new TextExportService()] };` - ); - } - break; - case 'toggle-dark-mode': - const currentDarkMode = this.sharedService.gridOptions.darkMode; - this.grid.setOptions({ darkMode: !currentDarkMode }); - this.sharedService.gridOptions.darkMode = !currentDarkMode; - break; - case 'toggle-filter': - let showHeaderRow = this.gridOptions?.showHeaderRow ?? false; - showHeaderRow = !showHeaderRow; // inverse show header flag - this.grid.setHeaderRowVisibility(showHeaderRow); - - // when displaying header row, we'll call "setColumns" which in terms will recreate the header row filters - if (showHeaderRow === true) { - this.grid.updateColumns(); - this.grid.scrollColumnIntoView(0); // quick fix to avoid filter being out of sync with horizontal scroll - } - break; - case 'toggle-preheader': - const showPreHeaderPanel = this.gridOptions?.showPreHeaderPanel ?? false; - this.grid.setPreHeaderPanelVisibility(!showPreHeaderPanel); - break; - case 'refresh-dataset': - this.extensionUtility.refreshBackendDataset(); - break; - default: - break; - } + protected exportExcel(): void { + const registeredResources = this.sharedService?.externalRegisteredResources || []; + const excelService: ExcelExportService | undefined = registeredResources.find( + (service: any) => service.className === 'ExcelExportService' + ); + if (excelService?.exportToExcel) { + excelService.exportToExcel(); + } else { + console.error( + `[Slickgrid-Universal] You must register the ExcelExportService to properly use Export to Excel in the Grid Menu. Example:: this.gridOptions = { enableExcelExport: true, externalResources: [new ExcelExportService()] };` + ); + } + } + + protected exportPdf(): void { + const registeredResources = this.sharedService?.externalRegisteredResources || []; + const pdfService: PdfExportService | undefined = registeredResources.find((service: any) => service.className === 'PdfExportService'); + if (pdfService?.exportToPdf) { + pdfService.exportToPdf(); + } else { + console.error( + `[Slickgrid-Universal] You must register the PdfExportService to properly use Export to PDF in the Grid Menu. Example:: this.gridOptions = { enablePdfExport: true, externalResources: [new PdfExportService()] };` + ); + } + } + + protected exportTextDelimited(): void { + const registeredResources = this.sharedService?.externalRegisteredResources || []; + const exportTxtService: TextExportService | undefined = registeredResources.find( + (service: any) => service.className === 'TextExportService' + ); + if (exportTxtService?.exportToFile) { + exportTxtService.exportToFile({ + delimiter: '\t', + format: 'txt', + }); + } else { + console.error( + `[Slickgrid-Universal] You must register the TextExportService to properly use Export to File in the Grid Menu. Example:: this.gridOptions = { enableTextExport: true, externalResources: [new TextExportService()] };` + ); + } + } + + protected toggleDarkMode(): void { + const currentDarkMode = this.sharedService.gridOptions.darkMode; + this.grid.setOptions({ darkMode: !currentDarkMode }); + this.sharedService.gridOptions.darkMode = !currentDarkMode; + } + + protected toggleFilterBar(): void { + let showHeaderRow = this.gridOptions?.showHeaderRow ?? false; + showHeaderRow = !showHeaderRow; // inverse show header flag + this.grid.setHeaderRowVisibility(showHeaderRow); + + // when displaying header row, we'll call "setColumns" which in terms will recreate the header row filters + if (showHeaderRow === true) { + this.grid.updateColumns(); + this.grid.scrollColumnIntoView(0); // quick fix to avoid filter being out of sync with horizontal scroll } } @@ -898,7 +909,6 @@ export class SlickGridMenu extends MenuBaseClass { // execute Grid Menu callback with command, // we'll also execute optional user defined onCommand callback when provided - this.executeGridMenuInternalCustomCommands(event, callbackArgs); this.pubSubService.publish('onGridMenuCommand', callbackArgs); if (typeof this._addonOptions?.onCommand === 'function') { this._addonOptions.onCommand(event, callbackArgs); diff --git a/packages/common/src/extensions/slickHeaderMenu.ts b/packages/common/src/extensions/slickHeaderMenu.ts index 45e61320e1..254b15f75e 100644 --- a/packages/common/src/extensions/slickHeaderMenu.ts +++ b/packages/common/src/extensions/slickHeaderMenu.ts @@ -220,7 +220,6 @@ export class SlickHeaderMenu extends MenuBaseClass { // execute Grid Menu callback with command, // we'll also execute optional user defined onCommand callback when provided - this.executeHeaderMenuInternalCommands(event, callbackArgs); this.pubSubService.publish('onHeaderMenuCommand', callbackArgs); if (typeof this.addonOptions?.onCommand === 'function') { this.addonOptions.onCommand(event, callbackArgs); @@ -312,6 +311,7 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}UNFREEZE_COLUMNS`, command: cmdUnfreeze, positionOrder: 45, + action: (_e, args) => this.freezeOrUnfreezeColumns(args.column, cmdUnfreeze), }); } } else { @@ -327,6 +327,7 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}FREEZE_COLUMNS`, command: cmdFreeze, positionOrder: 45, + action: (_e, args) => this.freezeOrUnfreezeColumns(args.column, cmdFreeze), }); } } @@ -347,6 +348,7 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}COLUMN_RESIZE_BY_CONTENT`, command: cmdResize, positionOrder: 47, + action: (_e, args) => this.pubSubService.publish('onHeaderMenuColumnResizeByContent', { columnId: args.column.id }), }); } } @@ -367,6 +369,7 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}SORT_ASCENDING`, command: cmdAscName, positionOrder: 50, + action: (e, args) => this.sortColumn(e, args, true), }); } if (!cmdExists(cmdDescName)) { @@ -376,6 +379,7 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}SORT_DESCENDING`, command: cmdDescName, positionOrder: 51, + action: (e, args) => this.sortColumn(e, args, false), }); } @@ -392,6 +396,7 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}REMOVE_SORT`, command: cmdClearSort, positionOrder: 58, + action: (e, args) => this.clearColumnSort(e, args), }); } } @@ -444,6 +449,9 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}REMOVE_FILTER`, command: cmdRemoveFilter, positionOrder: 57, + action: (e, args) => { + this.clearColumnFilter(e, args); + }, }); } } @@ -457,6 +465,12 @@ export class SlickHeaderMenu extends MenuBaseClass { titleKey: `${translationPrefix}HIDE_COLUMN`, command: cmdHideColumn, positionOrder: 59, + action: (_e, args) => { + this.hideColumn(args.column); + if (this.sharedService.gridOptions?.enableAutoSizeColumns) { + this.grid.autosizeColumns(); + } + }, }); } @@ -521,45 +535,6 @@ export class SlickHeaderMenu extends MenuBaseClass { } } - /** Execute the Header Menu Commands that was triggered by the onCommand subscribe */ - protected executeHeaderMenuInternalCommands( - event: DOMMouseOrTouchEvent | SlickEventData, - args: MenuCommandItemCallbackArgs - ): void { - if (args?.command) { - switch (args.command) { - case 'hide-column': - this.hideColumn(args.column); - if (this.sharedService.gridOptions?.enableAutoSizeColumns) { - this.grid.autosizeColumns(); - } - break; - case 'clear-filter': - this.clearColumnFilter(event, args); - break; - case 'clear-sort': - this.clearColumnSort(event, args); - break; - case 'column-resize-by-content': - this.pubSubService.publish('onHeaderMenuColumnResizeByContent', { columnId: args.column.id }); - break; - case 'freeze-columns': - this.freezeOrUnfreezeColumns(args.column, args.command); - break; - case 'unfreeze-columns': - this.freezeOrUnfreezeColumns(args.column, args.command); - break; - case 'sort-asc': - case 'sort-desc': - const isSortingAsc = args.command === 'sort-asc'; - this.sortColumn(event, args, isSortingAsc); - break; - default: - break; - } - } - } - protected createParentMenu(e: DOMMouseOrTouchEvent, columnDef: Column, menu: HeaderMenuItems): void { // let the user modify the menu or cancel altogether, // or provide alternative menu implementation. diff --git a/packages/common/src/interfaces/cellMenuOption.interface.ts b/packages/common/src/interfaces/cellMenuOption.interface.ts index e037eeff28..b07b433981 100644 --- a/packages/common/src/interfaces/cellMenuOption.interface.ts +++ b/packages/common/src/interfaces/cellMenuOption.interface.ts @@ -1,6 +1,6 @@ import type { MenuCommandItem, MenuFromCellCallbackArgs, MenuOption, MenuOptionItem } from './index.js'; -export interface CellMenuOption extends MenuOption { +export interface CellMenuOption extends Omit, 'commandListBuilder'> { /** Defaults to true, Auto-align dropup or dropdown menu to the left or right depending on grid viewport available space */ autoAdjustDrop?: boolean; diff --git a/packages/common/src/interfaces/menuItem.interface.ts b/packages/common/src/interfaces/menuItem.interface.ts index 5259cd81b7..75a3ed6374 100644 --- a/packages/common/src/interfaces/menuItem.interface.ts +++ b/packages/common/src/interfaces/menuItem.interface.ts @@ -16,7 +16,10 @@ export interface MenuItem { /** CSS class to be added to the menu item icon. */ iconCssClass?: string; - /** position order in the list, a lower number will make it on top of the list. Internal commands starts at 50. */ + /** + * position order in the list, a lower number will make it on top of the list. Internal commands starts at 50. + * @deprecated @use `commandListBuilder` + */ positionOrder?: number; /** Optional sub-menu title that will shows up when sub-menu commmands/options list is opened */ @@ -48,7 +51,7 @@ export interface MenuItem { /** * Slot renderer callback for the entire menu item. - * @param item - The menu item object + * @param cmdItem - The menu item object * @param args - The callback args providing access to grid, column, dataContext, etc. * @param event - Optional DOM event (passed during click handling) that allows the renderer to call stopPropagation() * @returns Either an HTML string or an HTMLElement @@ -65,7 +68,7 @@ export interface MenuItem { * return div; * } */ - slotRenderer?: (item: any, args: O, event?: Event) => string | HTMLElement; + slotRenderer?: (cmdItem: any, args: O, event?: Event) => string | HTMLElement; // -- // action/override callbacks diff --git a/packages/common/src/interfaces/menuOption.interface.ts b/packages/common/src/interfaces/menuOption.interface.ts index 7698b0adbd..ef922a69d6 100644 --- a/packages/common/src/interfaces/menuOption.interface.ts +++ b/packages/common/src/interfaces/menuOption.interface.ts @@ -1,10 +1,42 @@ import type { SlickGrid } from '../core/index.js'; -import type { Column, GridMenuCommandItemCallbackArgs, HeaderMenuCommandItemCallbackArgs, MenuFromCellCallbackArgs } from './index.js'; +import type { + Column, + GridMenuCommandItemCallbackArgs, + GridMenuItem, + HeaderMenuCommandItemCallbackArgs, + MenuCommandItem, + MenuFromCellCallbackArgs, +} from './index.js'; export interface MenuOption { // -- // Methods + /** + * Command builder, this function is executed after `commandItems: []` and is also the last call before rendering in the DOM. + * You would typically use this **instead** of the `commandItems: []`, since you can use this callback to filter/sort the final commands. + * + * // for example, you can spread the built-in commands with your own commands + * gridOptions: { + * contextMenu: { + * commandListBuilder: (builtInItems) => { + * return [ + * ...builtInItems, + * { + * command: 'delete-row', + * title: 'Delete Row', + * iconCssClass: 'mdi mdi-delete text-danger', + * action: () => alert('Delete row'), + * } + * ]; + * } + * } + * } + */ + commandListBuilder?: ( + builtInItems: Array + ) => Array; + /** * Default slot renderer for all menu items. * This will be used as the default renderer for all items unless overridden by an individual item's `slotRenderer`. @@ -16,16 +48,16 @@ export interface MenuOption `
${item.title}
` + * defaultItemRenderer: (cmdItem, args) => `
${cmdItem.title}
` * * // Return HTMLElement (e.g. Cell or Context Menu) - * defaultItemRenderer: (item, args) => { + * defaultItemRenderer: (cmdItem, args) => { * const div = document.createElement('div'); - * div.textContent = `${item.title} (Row ${args.dataContext.id})`; + * div.textContent = `${cmdItem.title} (Row ${args.dataContext.id})`; * return div; * } */ - defaultItemRenderer?: (item: any, args: T) => string | HTMLElement; + defaultItemRenderer?: (cmdItem: any, args: T) => string | HTMLElement; /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ menuUsabilityOverride?: (args: { grid: SlickGrid; column: Column; menu: HTMLElement }) => boolean; From 871903c669599bd01d8180d38aa8d57e896976d3 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 11 Feb 2026 22:58:20 -0500 Subject: [PATCH 06/24] chore: fix some typing errors --- .../src/interfaces/menuFromCellCallbackArgs.interface.ts | 7 ++++--- packages/common/src/interfaces/menuOption.interface.ts | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/common/src/interfaces/menuFromCellCallbackArgs.interface.ts b/packages/common/src/interfaces/menuFromCellCallbackArgs.interface.ts index 324b603e0b..6ac7ccb58b 100644 --- a/packages/common/src/interfaces/menuFromCellCallbackArgs.interface.ts +++ b/packages/common/src/interfaces/menuFromCellCallbackArgs.interface.ts @@ -10,9 +10,10 @@ export interface MenuFromCellCallbackArgs { /** Reference to the grid. */ grid: SlickGrid; -} -export interface MenuFromCellWithColumnCallbackArgs extends MenuFromCellCallbackArgs { + /** item data context object */ + dataContext: any; + /** Cell Column definition */ - column?: Column; + column?: Column; } diff --git a/packages/common/src/interfaces/menuOption.interface.ts b/packages/common/src/interfaces/menuOption.interface.ts index ef922a69d6..d122c83003 100644 --- a/packages/common/src/interfaces/menuOption.interface.ts +++ b/packages/common/src/interfaces/menuOption.interface.ts @@ -1,6 +1,4 @@ -import type { SlickGrid } from '../core/index.js'; import type { - Column, GridMenuCommandItemCallbackArgs, GridMenuItem, HeaderMenuCommandItemCallbackArgs, @@ -60,5 +58,5 @@ export interface MenuOption string | HTMLElement; /** Callback method that user can override the default behavior of enabling/disabling an item from the list. */ - menuUsabilityOverride?: (args: { grid: SlickGrid; column: Column; menu: HTMLElement }) => boolean; + menuUsabilityOverride?: (args: T) => boolean; } From b45bfeec1db7d085bd48672bb881322a8ab9aa8f Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 12 Feb 2026 20:41:12 -0500 Subject: [PATCH 07/24] chore: rename `defaultMenuItemRenderer` & add E2E tests --- .../aurelia/test/cypress/support/commands.ts | 33 ++ demos/react/test/cypress/support/commands.ts | 33 ++ demos/vanilla/src/examples/example40.html | 2 +- demos/vanilla/src/examples/example40.ts | 119 ++---- demos/vue/test/cypress/support/commands.ts | 33 ++ docs/column-functionalities/cell-menu.md | 4 +- docs/grid-functionalities/context-menu.md | 2 +- docs/grid-functionalities/grid-menu.md | 2 +- .../header-menu-header-buttons.md | 2 +- docs/menu-slots.md | 24 +- .../test/cypress/support/commands.ts | 33 ++ .../__tests__/slickCellMenu.plugin.spec.ts | 8 +- .../__tests__/slickContextMenu.spec.ts | 10 +- .../__tests__/slickGridMenu.spec.ts | 8 +- .../__tests__/slickHeaderMenu.spec.ts | 8 +- .../common/src/extensions/menuBaseClass.ts | 4 +- .../src/interfaces/menuOption.interface.ts | 6 +- test/cypress/e2e/example40.cy.ts | 360 ++++++++++++++++++ test/cypress/support/commands.ts | 33 ++ 19 files changed, 589 insertions(+), 135 deletions(-) create mode 100644 test/cypress/e2e/example40.cy.ts diff --git a/demos/aurelia/test/cypress/support/commands.ts b/demos/aurelia/test/cypress/support/commands.ts index a35be6228b..3be5a3685b 100644 --- a/demos/aurelia/test/cypress/support/commands.ts +++ b/demos/aurelia/test/cypress/support/commands.ts @@ -47,6 +47,7 @@ declare global { ): Chainable>; saveLocalStorage: () => void; restoreLocalStorage: () => void; + getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; } } } @@ -86,3 +87,35 @@ Cypress.Commands.add('restoreLocalStorage', () => { localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]); }); }); + +Cypress.Commands.add( + 'getTransformValue', + ( + cssTransformMatrix: string, + absoluteValue: boolean, + transformType: 'rotate' | 'scale' = 'rotate' // Default to 'rotate' + ): Cypress.Chainable => { + if (!cssTransformMatrix || cssTransformMatrix === 'none') { + throw new Error('Transform matrix is undefined or none'); + } + + const cssTransformMatrixIndexes = cssTransformMatrix.split('(')[1].split(')')[0].split(','); + + if (transformType === 'rotate') { + const cssTransformScale = Math.sqrt( + +cssTransformMatrixIndexes[0] * +cssTransformMatrixIndexes[0] + +cssTransformMatrixIndexes[1] * +cssTransformMatrixIndexes[1] + ); + + const cssTransformSin = +cssTransformMatrixIndexes[1] / cssTransformScale; + const cssTransformAngle = Math.round(Math.asin(cssTransformSin) * (180 / Math.PI)); + + return cy.wrap(absoluteValue ? Math.abs(cssTransformAngle) : cssTransformAngle); + } else if (transformType === 'scale') { + // Assuming scale is based on the first value in the matrix. + const scaleValue = +cssTransformMatrixIndexes[0]; // First value typically represents scaling in x direction. + return cy.wrap(scaleValue); // Directly return the scale value. + } + + throw new Error('Unsupported transform type'); + } +); diff --git a/demos/react/test/cypress/support/commands.ts b/demos/react/test/cypress/support/commands.ts index a35be6228b..3be5a3685b 100644 --- a/demos/react/test/cypress/support/commands.ts +++ b/demos/react/test/cypress/support/commands.ts @@ -47,6 +47,7 @@ declare global { ): Chainable>; saveLocalStorage: () => void; restoreLocalStorage: () => void; + getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; } } } @@ -86,3 +87,35 @@ Cypress.Commands.add('restoreLocalStorage', () => { localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]); }); }); + +Cypress.Commands.add( + 'getTransformValue', + ( + cssTransformMatrix: string, + absoluteValue: boolean, + transformType: 'rotate' | 'scale' = 'rotate' // Default to 'rotate' + ): Cypress.Chainable => { + if (!cssTransformMatrix || cssTransformMatrix === 'none') { + throw new Error('Transform matrix is undefined or none'); + } + + const cssTransformMatrixIndexes = cssTransformMatrix.split('(')[1].split(')')[0].split(','); + + if (transformType === 'rotate') { + const cssTransformScale = Math.sqrt( + +cssTransformMatrixIndexes[0] * +cssTransformMatrixIndexes[0] + +cssTransformMatrixIndexes[1] * +cssTransformMatrixIndexes[1] + ); + + const cssTransformSin = +cssTransformMatrixIndexes[1] / cssTransformScale; + const cssTransformAngle = Math.round(Math.asin(cssTransformSin) * (180 / Math.PI)); + + return cy.wrap(absoluteValue ? Math.abs(cssTransformAngle) : cssTransformAngle); + } else if (transformType === 'scale') { + // Assuming scale is based on the first value in the matrix. + const scaleValue = +cssTransformMatrixIndexes[0]; // First value typically represents scaling in x direction. + return cy.wrap(scaleValue); // Directly return the scale value. + } + + throw new Error('Unsupported transform type'); + } +); diff --git a/demos/vanilla/src/examples/example40.html b/demos/vanilla/src/examples/example40.html index 31b7f08d46..fa04a7f09b 100644 --- a/demos/vanilla/src/examples/example40.html +++ b/demos/vanilla/src/examples/example40.html @@ -29,7 +29,7 @@

Note: The demo focuses on the custom rendering capability via slotRenderer and - defaultItemRenderer, which work across all menu plugins (SlickHeaderMenu, SlickCellMenu, SlickContextMenu, + defaultMenuItemRenderer, which work across all menu plugins (SlickHeaderMenu, SlickCellMenu, SlickContextMenu, SlickGridMenu). Also note that the keyboard shortcuts displayed in the menus (e.g., Alt+↑, F5) are for demo purposes only and do not actually trigger any actions. diff --git a/demos/vanilla/src/examples/example40.ts b/demos/vanilla/src/examples/example40.ts index 79a36f26d3..9870d53e19 100644 --- a/demos/vanilla/src/examples/example40.ts +++ b/demos/vanilla/src/examples/example40.ts @@ -34,7 +34,7 @@ export default class Example40 { gridOptions: GridOption; dataset: ReportItem[]; sgb: SlickVanillaGridBundle; - subTitleStyle = 'display: none'; + subTitleStyle = 'block'; constructor() { this._bindingEventService = new BindingEventService(); @@ -79,10 +79,10 @@ export default class Example40 { title: 'Sort Ascending', positionOrder: 50, // Slot renderer replaces entire menu item content (can be HTML string or native DOM elements) - slotRenderer: () => ` + slotRenderer: (cmdItem) => `

`, @@ -209,16 +209,17 @@ export default class Example40 { containerDiv.appendChild(kbdElm); // Add native event listeners for hover effects - containerDiv.addEventListener('mouseenter', () => { + containerDiv.addEventListener('mouseover', () => { iconDiv.style.transform = 'scale(1.15)'; iconDiv.style.background = 'linear-gradient(135deg, #d8dcef 0%, #ffffff 100%)'; containerDiv.parentElement!.style.backgroundColor = '#854685'; + // containerDiv.parentElement!.title = `📈 Export timestamp: ${tempoFormat(new Date(), 'YYYY-MM-DD hh:mm:ss a')}`; const div = this.buildChartTooltip(getOffset(containerDiv)); document.body.appendChild(div); containerDiv.style.color = 'white'; containerDiv.querySelector('.key-hint')!.style.color = 'black'; }); - containerDiv.addEventListener('mouseleave', () => { + containerDiv.addEventListener('mouseout', () => { iconDiv.style.transform = 'scale(1)'; iconDiv.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; containerDiv.parentElement!.style.backgroundColor = 'white'; @@ -232,11 +233,10 @@ export default class Example40 { alert('Custom export action triggered!'); }, }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: '' }, { command: 'filter-column', title: 'Filter Column', - positionOrder: 55, // Slot renderer with status indicator and beta badge slotRenderer: () => `