diff --git a/.prettierignore b/.prettierignore index a61667e120..8f569d1b18 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,6 @@ _variables.scss _variables-theme-material.scss _variables-theme-salesforce.scss -**/test/vitest-coverage/*.* \ No newline at end of file +**/test/vitest-coverage/*.* +demos/vue/src/router/index.ts +demos/react/src/examples/slickgrid/App.tsx \ No newline at end of file diff --git a/demos/aurelia/src/examples/slickgrid/custom-aureliaViewModelEditor.ts b/demos/aurelia/src/examples/slickgrid/custom-aureliaViewModelEditor.ts index 008e259c40..1e291b379b 100644 --- a/demos/aurelia/src/examples/slickgrid/custom-aureliaViewModelEditor.ts +++ b/demos/aurelia/src/examples/slickgrid/custom-aureliaViewModelEditor.ts @@ -33,7 +33,7 @@ export class CustomAureliaViewModelEditor implements Editor { elmBindingContext?: IBindingContext; constructor(private args: any) { - this.grid = args && args.grid; + this.grid = args?.grid; this.init(); } diff --git a/demos/aurelia/src/examples/slickgrid/example10.ts b/demos/aurelia/src/examples/slickgrid/example10.ts index 719d787265..8788d246e7 100644 --- a/demos/aurelia/src/examples/slickgrid/example10.ts +++ b/demos/aurelia/src/examples/slickgrid/example10.ts @@ -293,7 +293,7 @@ export class Example10 { } onGrid1SelectedRowsChanged(_e: Event, args: any) { - const grid = args && args.grid; + const grid = args?.grid; if (Array.isArray(args.rows)) { this.selectedTitle = args.rows.map((idx: number) => { const item = grid.getDataItem(idx); diff --git a/demos/aurelia/src/examples/slickgrid/example2.ts b/demos/aurelia/src/examples/slickgrid/example2.ts index 913fc8a181..6030b3f685 100644 --- a/demos/aurelia/src/examples/slickgrid/example2.ts +++ b/demos/aurelia/src/examples/slickgrid/example2.ts @@ -129,7 +129,7 @@ export class Example2 { minWidth: 100, formatter: customEnableButtonFormatter, onCellClick: (_e, args) => { - this.toggleCompletedProperty(args && args.dataContext); + this.toggleCompletedProperty(args?.dataContext); }, }, ]; diff --git a/demos/aurelia/src/examples/slickgrid/example23.ts b/demos/aurelia/src/examples/slickgrid/example23.ts index 399643c719..989b13e618 100644 --- a/demos/aurelia/src/examples/slickgrid/example23.ts +++ b/demos/aurelia/src/examples/slickgrid/example23.ts @@ -259,11 +259,11 @@ export class Example23 { } refreshMetrics(_e: Event, args: any) { - if (args && args.current >= 0) { + if (args?.current >= 0) { setTimeout(() => { this.metrics = { startTime: new Date(), - itemCount: (args && args.current) || 0, + itemCount: args?.current || 0, totalItemCount: this.dataset.length || 0, }; }); diff --git a/demos/aurelia/src/examples/slickgrid/example24.ts b/demos/aurelia/src/examples/slickgrid/example24.ts index 4267b8b91c..a24fd42b0d 100644 --- a/demos/aurelia/src/examples/slickgrid/example24.ts +++ b/demos/aurelia/src/examples/slickgrid/example24.ts @@ -428,7 +428,7 @@ export class Example24 { // optionally and conditionally define when the the menu is usable, // this should be used with a custom formatter to show/hide/disable the menu menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return dataContext.id < 21; // say we want to display the menu only from Task 0 to 20 }, // which column to show the command list? when not defined it will be shown over all columns @@ -459,7 +459,7 @@ export class Example24 { }, // only show command to 'Help' when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -525,7 +525,7 @@ export class Example24 { textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, // you can use the 'action' callback and/or subscribe to the 'onCallback' event, they both have the same arguments @@ -547,7 +547,7 @@ export class Example24 { disabled: true, // only shown when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -578,7 +578,7 @@ export class Example24 { // subscribe to Context Menu onOptionSelected event (or use the action callback on each option) onOptionSelected: (_e: any, args: any) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext?.hasOwnProperty('priority')) { dataContext.priority = args.item.option; this.aureliaGrid.gridService.updateItem(dataContext); diff --git a/demos/aurelia/src/examples/slickgrid/example3.ts b/demos/aurelia/src/examples/slickgrid/example3.ts index 87aae58839..cb556556d1 100644 --- a/demos/aurelia/src/examples/slickgrid/example3.ts +++ b/demos/aurelia/src/examples/slickgrid/example3.ts @@ -28,7 +28,7 @@ const NB_ITEMS = 100; // you can create custom validator to pass to an inline editor const myCustomTitleValidator: EditorValidator = (value: any) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it - // const grid = args && args.grid; + // const grid = args?.grid; // const gridOptions = grid.getOptions() as GridOption; // const i18n = gridOptions.i18n; diff --git a/demos/aurelia/src/examples/slickgrid/example33.ts b/demos/aurelia/src/examples/slickgrid/example33.ts index d54398ca5e..0371f6f92c 100644 --- a/demos/aurelia/src/examples/slickgrid/example33.ts +++ b/demos/aurelia/src/examples/slickgrid/example33.ts @@ -459,7 +459,7 @@ export class Example33 { onCommand: (e, args) => this.executeCommand(e, args), onOptionSelected: (_e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('completed')) { dataContext.completed = args.item.option; this.aureliaGrid.gridService.updateItem(dataContext); diff --git a/demos/aurelia/src/examples/slickgrid/example4.ts b/demos/aurelia/src/examples/slickgrid/example4.ts index 8613c2fbf5..947053c703 100644 --- a/demos/aurelia/src/examples/slickgrid/example4.ts +++ b/demos/aurelia/src/examples/slickgrid/example4.ts @@ -312,12 +312,12 @@ export class Example4 { } refreshMetrics(_e: Event, args: any) { - if (args && args.current >= 0) { + if (args?.current >= 0) { setTimeout(() => { this.metrics = { startTime: new Date(), endTime: new Date(), - itemCount: (args && args.current) || 0, + itemCount: args?.current || 0, totalItemCount: this.dataset.length || 0, }; }); diff --git a/demos/aurelia/src/examples/slickgrid/example51.html b/demos/aurelia/src/examples/slickgrid/example51.html new file mode 100644 index 0000000000..5255b2d467 --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example51.html @@ -0,0 +1,75 @@ +
+

+ Example 51: Menus with Slots + + + code + + + + +

+ +
+
+ + 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): +

+

+ Note: The demo focuses on the custom rendering capability via slotRenderer and + 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/aurelia/src/examples/slickgrid/example51.scss b/demos/aurelia/src/examples/slickgrid/example51.scss new file mode 100644 index 0000000000..539ad2ecd2 --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example51.scss @@ -0,0 +1,144 @@ +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-tooltip-background-color: #4c4c4c; + --slick-tooltip-color: #fff; + --slick-tooltip-font-size: 14px; + .slick-cell-menu, + .slick-context-menu, + .slick-grid-menu, + .slick-header-menu { + .slick-menu-item:hover:not(.slick-menu-item-disabled) { + color: #0a34b5; + } + } + .slick-menu-footer { + padding: 4px 6px; + border-top: 1px solid #c0c0c0; + } +} + +kbd { + background-color: #eee; + color: #202020; +} +.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/aurelia/src/examples/slickgrid/example51.ts b/demos/aurelia/src/examples/slickgrid/example51.ts new file mode 100644 index 0000000000..32e54a15de --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example51.ts @@ -0,0 +1,611 @@ +import { format as tempoFormat } from '@formkit/tempo'; +import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; +import { + Aggregators, + createDomElement, + Filters, + Formatters, + SortComparers, + SortDirectionNumber, + type AureliaGridInstance, + type Column, + type GridOption, + type Grouping, + type MenuCommandItem, +} from 'aurelia-slickgrid'; +import './example51.scss'; + +const NB_ITEMS = 2000; + +interface ReportItem { + id: number; + title: string; + duration: number; + cost: number; + percentComplete: number; + start: Date; + finish: Date; + action?: string; +} + +export class Example51 { + aureliaGrid!: AureliaGridInstance; + columnDefinitions: Column[] = []; + gridOptions!: GridOption; + dataset!: ReportItem[]; + hideSubTitle = false; + + constructor() { + // define the grid options & columns and then create the grid itself + this.defineGrid(); + } + + attached() { + // mock some data (different in each dataset) + this.dataset = this.loadData(NB_ITEMS); + } + + aureliaGridReady(aureliaGrid: AureliaGridInstance) { + this.aureliaGrid = aureliaGrid; + } + + defineGrid() { + 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 (can be HTML string or native DOM elements) + slotRenderer: (cmdItem) => ` + + `, + }, + { + command: 'sort-desc', + title: 'Sort Descending', + positionOrder: 51, + // 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; + }, + }, + ], + }, + }, + }, + { + 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: () => ` + + `, + }, + { 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: () => ` + + `, + }, + ], + }, + }, + }, + { + 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: (cmdItem) => { + // 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: cmdItem.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('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')}`; + containerDiv.style.color = 'white'; + containerDiv.querySelector('.key-hint')!.style.color = 'black'; + }); + containerDiv.addEventListener('mouseout', () => { + 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(); + }); + + return containerDiv; + }, + action: () => { + alert('Custom export action triggered!'); + }, + }, + { divider: true, command: '' }, + { + command: 'filter-column', + title: 'Filter Column', + // Slot renderer with status indicator and beta badge + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + id: 'percentComplete', + name: '% Complete', + field: 'percentComplete', + sortable: true, + filterable: true, + type: 'number', + filter: { model: Filters.slider, operator: '>=' }, + // Demo: Header Menu with Slot - showing interactive element (checkbox) + header: { + menu: { + commandItems: [ + { + command: 'recalc', + title: 'Recalculate', + iconCssClass: 'mdi mdi-refresh', + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + 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 defaultMenuItemRenderer at menu level) + cellMenu: { + hideCloseButton: false, + commandTitle: 'Cell Actions', + // Demo: Menu-level default renderer that applies to all items unless overridden + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + commandItems: [ + { + command: 'copy-cell', + title: 'Copy Cell Value', + 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', + iconCssClass: 'mdi mdi-download', + action: (_e, args) => { + console.log('Export row:', args.dataContext); + alert(`Export row #${args.dataContext.id}`); + }, + }, + { + 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: (_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: '' }, + { + command: 'edit-row', + title: 'Edit Row', + // Individual slotRenderer overrides the defaultMenuItemRenderer + slotRenderer: (_item, args) => ` + + `, + action: (_e, args) => { + console.log('Edit row:', args.dataContext); + alert(`Edit row #${args.dataContext.id}`); + }, + }, + { + command: 'delete-row', + title: 'Delete Row', + 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.aureliaGrid?.gridService.deleteItemById(dataContext.id); + } + }, + }, + ], + }, + }, + ]; + + this.gridOptions = { + autoResize: { + container: '#demo-container', + }, + enableAutoResize: true, + enableCellNavigation: true, + enableFiltering: true, + enableSorting: true, + enableGrouping: true, + + // Header Menu with slots (already configured in columns above) + enableHeaderMenu: true, + headerMenu: { + // hideCommands: ['column-resize-by-content', 'clear-sort'], + + // Demo: Menu-level default renderer for all header menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Cell Menu with slots (configured in the Action column above) + enableCellMenu: true, + + // Context Menu with slot examples + enableContextMenu: true, + contextMenu: { + // hideCommands: ['clear-grouping', 'copy'], + + // build your command items list + // spread built-in commands and optionally filter/sort them however you want + commandListBuilder: (builtInItems) => { + // commandItems.sort((a, b) => (a === 'divider' || b === 'divider' ? 0 : a.title! > b.title! ? -1 : 1)); + return [ + // filter commands if you want + // ...builtInItems.filter((x) => x !== 'divider' && x.command !== 'copy' && x.command !== 'clear-grouping'), + { + command: 'edit-cell', + title: 'Edit Cell', + // Demo: Individual slotRenderer overrides the menu's defaultMenuItemRenderer + slotRenderer: (cmdItem) => { + // 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('mouseover', () => { + iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; + iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; + }); + containerDiv.addEventListener('mouseout', () => { + iconDiv.style.transform = 'rotate(0deg) scale(1)'; + iconDiv.style.boxShadow = 'none'; + }); + + return containerDiv; + }, + action: () => alert('Edit cell'), + }, + ...builtInItems, + { 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'), + }, + ] as Array; + }, + // Demo: Menu-level default renderer for context menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Grid Menu with slot examples (demonstrating defaultMenuItemRenderer at menu level) + enableGridMenu: true, + gridMenu: { + // hideCommands: ['toggle-preheader', 'toggle-filter'], + + // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + 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 defaultMenuItemRenderer for this item + slotRenderer: (cmdItem) => ` + + `, + action: () => alert('Export to CSV'), + }, + { + 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'), + }, + ] as Array; + }, + }, + + // tooltip plugin + externalResources: [new SlickCustomTooltip()], + customTooltip: { + observeAllTooltips: true, + }, + }; + } + + clearGrouping() { + this.aureliaGrid?.dataView?.setGrouping([]); + } + + collapseAllGroups() { + this.aureliaGrid?.dataView?.collapseAllGroups(); + } + + expandAllGroups() { + this.aureliaGrid?.dataView?.expandAllGroups(); + } + + groupByDuration() { + // you need to manually add the sort icon(s) in UI + this.aureliaGrid?.slickGrid?.setSortColumns([{ columnId: 'duration', sortAsc: true }]); + this.aureliaGrid?.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.aureliaGrid?.slickGrid?.invalidate(); // invalidate all rows and re-render + } + + 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; + } + + toggleSubTitle() { + this.hideSubTitle = !this.hideSubTitle; + const action = this.hideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + this.aureliaGrid.resizerService.resizeGrid(0); + } +} diff --git a/demos/aurelia/src/my-app.ts b/demos/aurelia/src/my-app.ts index 933828aa59..b2bd69762c 100644 --- a/demos/aurelia/src/my-app.ts +++ b/demos/aurelia/src/my-app.ts @@ -57,6 +57,7 @@ const myRoutes: Routeable[] = [ { path: 'example48', component: () => import('./examples/slickgrid/example48.js'), title: '48- Hybrid Selection Model' }, { path: 'example49', component: () => import('./examples/slickgrid/example49.js'), title: '49- Spreadsheet Drag-Fill' }, { path: 'example50', component: () => import('./examples/slickgrid/example50.js'), title: '50- Master/Detail Grids' }, + { path: 'example51', component: () => import('./examples/slickgrid/example51.js'), title: '51- Menus with Slots' }, { path: 'home', component: () => import('./home-page.js'), title: 'Home' }, ]; @route({ diff --git a/demos/aurelia/test/cypress/e2e/example51.cy.ts b/demos/aurelia/test/cypress/e2e/example51.cy.ts new file mode 100644 index 0000000000..80e5148c97 --- /dev/null +++ b/demos/aurelia/test/cypress/e2e/example51.cy.ts @@ -0,0 +1,382 @@ +import { format } from '@formkit/tempo'; + +describe('Example 51 - Menus with Slots', () => { + const fullTitles = ['Title', 'Duration', 'Start', 'Finish', 'Cost', '% Complete', 'Action']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example51`); + cy.get('h2').should('contain', 'Example 51: Menus with Slots'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid51') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should open Context Menu hover "Duration" column and expect built-in and custom items listed in specific order', () => { + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + // 1st item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.edit-cell-icon').contains('✎'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Edit Cell'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.edit-cell').contains('F2'); + + // icon should rotate while hovering + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.wait(175); // wait for rotation + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .edit-cell-icon') + .invoke('css', 'transform') // Get the transform property + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'rotate').then((rotationAngle) => { + expect(rotationAngle).to.approximately(13, 15); // 15 degrees rotation + }); + }); + + // 2nd item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('i.mdi-content-copy').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('span.menu-item-label').contains('Copy'); + + // 3rd item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(2)').should('have.class', 'slick-menu-item-divider'); + + // 4th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Clear all Grouping'); + + // 5th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item').find('i.mdi-arrow-collapse').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('span.menu-item-label') + .contains('Collapse all Groups'); + + // 6th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('i.mdi-arrow-expand').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Expand all Groups'); + + // 7th item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(6)').should('have.class', 'slick-menu-item-divider'); + + // 8th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Export'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7)').find('.sub-item-chevron').should('exist'); + + // 9th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(8)').should('have.class', 'slick-menu-item-divider'); + + // 10th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('i.mdi-delete.text-danger') + .should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('span.menu-item-label') + .contains('Delete Row'); + }); + + it('should open Export->Excel context sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', '0'); + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Header Menu from the "Title" column and expect some commands to have keyboard hints on the right side', () => { + cy.get('.slick-header-column:nth(0)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(0)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('kbd.key-hint').contains('Alt+↑'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('kbd.key-hint').contains('Alt+↓'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Duration" column and expect some commands to have tags on the right side', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(1)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('span.key-hint.danger').contains('NEW'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Cost" column and expect first item to have a dynamic tooltip timestamp when hovering', () => { + cy.get('#grid51').find('.slick-header-column:nth(4)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.advanced-export-icon').contains('📊'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').contains('Advanced Export'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.key-hint').contains('Ctrl+E'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgba(0, 0, 0, 0)' + ); + + // icon should scale up + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .advanced-export-icon') + .invoke('css', 'transform') + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'scale').then((scaleValue) => { + expect(scaleValue).to.be.approximately(1.1, 1.15); // Check the scale value if applied + }); + }); + + const today = format(new Date(), 'YYYY-MM-DD'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgb(133, 70, 133)' + ); + cy.get('.slick-custom-tooltip').contains(`📈 Export timestamp: ${today}`); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseout'); + cy.get('.slick-custom-tooltip').should('not.exist'); + cy.get('body').click(); + }); + + it('should open Action Menu from last column "Action" column and expect custom items listed in specific order', () => { + cy.get('[data-row="1"] > .slick-cell:nth(6)').click(); + cy.get('.slick-command-header.with-title.with-close').contains('Cell Actions'); + + // 1st item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.mdi-content-copy').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Copy Cell Value'); + + // 2nd item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Export Row'); + + // 4th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('span.menu-item-label').contains('Export'); + + // 5th item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('.edit-cell-icon').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item .edit-cell-icon').contains('✎'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); + + // 7th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('.mdi-delete.text-danger').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Delete Row'); + }); + + it('should open Export->Excel cell sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export row #1 to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Grid Menu and expect built-in commands first then custom items listed in specific order', () => { + cy.get('.slick-grid-menu-button.mdi-menu').click(); + + // 1st item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('.mdi-filter-remove-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Clear all Filters'); + + // 2nd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item') + .find('.mdi-sort-variant-off.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item span').contains('Clear all Sorting'); + + // 3rd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('.mdi-flip-vertical.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Toggle Filter Row'); + + // 4th item - divider + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-divider'); + + // 5th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('.mdi-file-excel-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item span').contains('Export to Excel'); + + // 6th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('.mdi-download.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item span').contains('Export to CSV'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('span.key-hint.warn').contains('CUSTOM'); + + // 7th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('.mdi-refresh.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Refresh Data'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('kbd.key-hint').contains('F5'); + }); + + it('should sort ascending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').should('contain', 'Sort Ascending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '0'); + }); + + it('should sort descending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').should('contain', 'Sort Descending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '100'); + }); +}); 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/src/examples/slickgrid/App.tsx b/demos/react/src/examples/slickgrid/App.tsx index ac5a4381e0..b7fb17f03d 100644 --- a/demos/react/src/examples/slickgrid/App.tsx +++ b/demos/react/src/examples/slickgrid/App.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { Routes as BaseRoutes, Link, Navigate, Route, useLocation } from 'react-router'; import { NavBar } from '../../NavBar.js'; + import Example1 from './Example1.js'; import Example2 from './Example2.js'; import Example3 from './Example3.js'; @@ -50,6 +51,7 @@ import Example47 from './Example47.js'; import Example48 from './Example48.js'; import Example49 from './Example49.js'; import Example50 from './Example50.js'; +import Example51 from './Example51.js'; const routes: Array<{ path: string; route: string; component: any; title: string }> = [ { path: 'example1', route: '/example1', component: , title: '1- Basic Grid / 2 Grids' }, @@ -101,6 +103,7 @@ const routes: Array<{ path: string; route: string; component: any; title: string { path: 'example48', route: '/example48', component: , title: '48- Hybrid Selection Model' }, { path: 'example49', route: '/example49', component: , title: '49- Spreadsheet Drag-Fill' }, { path: 'example50', route: '/example50', component: , title: '50- Master/Detail Grids' }, + { path: 'example51', route: '/example51', component: , title: '51- Menus with Slots' }, ]; export default function Routes() { diff --git a/demos/react/src/examples/slickgrid/Example10.tsx b/demos/react/src/examples/slickgrid/Example10.tsx index 46a2788e01..4a0f23deaf 100644 --- a/demos/react/src/examples/slickgrid/Example10.tsx +++ b/demos/react/src/examples/slickgrid/Example10.tsx @@ -303,7 +303,7 @@ const Example10: React.FC = () => { } function onGrid1SelectedRowsChanged(_e: Event, args: any) { - const grid = args && args.grid; + const grid = args?.grid; if (Array.isArray(args.rows)) { const selectedTitles = args.rows.map((idx: number) => { const item = grid.getDataItem(idx); diff --git a/demos/react/src/examples/slickgrid/Example24.tsx b/demos/react/src/examples/slickgrid/Example24.tsx index 57a520c5ed..839f631e9f 100644 --- a/demos/react/src/examples/slickgrid/Example24.tsx +++ b/demos/react/src/examples/slickgrid/Example24.tsx @@ -347,7 +347,7 @@ const Example24: React.FC = () => { onCommand: (_e, args) => executeCommand(_e, args), onOptionSelected: (_e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('completed')) { dataContext.completed = args.item.option; reactGridRef.current?.gridService.updateItem(dataContext); @@ -428,7 +428,7 @@ const Example24: React.FC = () => { // optionally and conditionally define when the the menu is usable, // this should be used with a custom formatter to show/hide/disable the menu menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return dataContext.id < 21; // say we want to display the menu only from Task 0 to 20 }, // which column to show the command list? when not defined it will be shown over all columns @@ -459,7 +459,7 @@ const Example24: React.FC = () => { }, // only show command to 'Help' when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -525,7 +525,7 @@ const Example24: React.FC = () => { textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, // you can use the 'action' callback and/or subscribe to the 'onCallback' event, they both have the same arguments @@ -547,7 +547,7 @@ const Example24: React.FC = () => { disabled: true, // only shown when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -578,7 +578,7 @@ const Example24: React.FC = () => { // subscribe to Context Menu onOptionSelected event (or use the action callback on each option) onOptionSelected: (_e, args) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('priority')) { dataContext.priority = args.item.option; reactGridRef.current?.gridService.updateItem(dataContext); diff --git a/demos/react/src/examples/slickgrid/Example3.tsx b/demos/react/src/examples/slickgrid/Example3.tsx index 5f833fbc82..9be182fa8e 100644 --- a/demos/react/src/examples/slickgrid/Example3.tsx +++ b/demos/react/src/examples/slickgrid/Example3.tsx @@ -30,7 +30,7 @@ const NB_ITEMS = 100; // you can create custom validator to pass to an inline editor const myCustomTitleValidator: EditorValidator = (value: any) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it - // const grid = args && args.grid; + // const grid = args?.grid; // const gridOptions = grid.getOptions() as GridOption; // const i18n = gridOptions.i18n; diff --git a/demos/react/src/examples/slickgrid/Example33.tsx b/demos/react/src/examples/slickgrid/Example33.tsx index 4486e9f4d3..fd4523d19e 100644 --- a/demos/react/src/examples/slickgrid/Example33.tsx +++ b/demos/react/src/examples/slickgrid/Example33.tsx @@ -457,7 +457,7 @@ const Example33: React.FC = () => { onCommand: (e, args) => executeCommand(e, args), onOptionSelected: (_e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('completed')) { dataContext.completed = args.item.option; reactGridRef.current?.gridService.updateItem(dataContext); diff --git a/demos/react/src/examples/slickgrid/Example51.tsx b/demos/react/src/examples/slickgrid/Example51.tsx new file mode 100644 index 0000000000..28c4504a7e --- /dev/null +++ b/demos/react/src/examples/slickgrid/Example51.tsx @@ -0,0 +1,694 @@ +import { format as tempoFormat } from '@formkit/tempo'; +import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; +import React, { useEffect, useRef, useState } from 'react'; +import { + Aggregators, + createDomElement, + Filters, + Formatters, + SlickgridReact, + SortComparers, + SortDirectionNumber, + type Column, + type GridOption, + type Grouping, + type MenuCommandItem, + type SlickgridReactInstance, +} from 'slickgrid-react'; +import './example51.scss'; // provide custom CSS/SASS styling + +const NB_ITEMS = 2000; + +interface ReportItem { + id: number; + title: string; + duration: number; + cost: number; + percentComplete: number; + start: Date; + finish: Date; + action?: string; +} + +const Example51: React.FC = () => { + const [columnDefinitions, setColumnDefinitions] = useState[]>([]); + const [gridOptions, setGridOptions] = useState(); + const [dataset] = useState(loadData(NB_ITEMS)); + const [hideSubTitle, setHideSubTitle] = useState(false); + + const reactGridRef = useRef(null); + + useEffect(() => { + defineGrid(); + }, []); + + function reactGridReady(reactGrid: SlickgridReactInstance) { + reactGridRef.current = reactGrid; + } + + /* Define grid Options and Columns */ + function defineGrid() { + const columnDefinitions: Column[] = [ + { + 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 (can be HTML string or native DOM elements) + slotRenderer: (cmdItem) => ` + + `, + }, + { + command: 'sort-desc', + title: 'Sort Descending', + positionOrder: 51, + // 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; + }, + }, + ], + }, + }, + }, + { + 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: () => ` + + `, + }, + { 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: () => ` + + `, + }, + ], + }, + }, + }, + { + 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: (cmdItem) => { + // 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: cmdItem.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('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')}`; + containerDiv.style.color = 'white'; + containerDiv.querySelector('.key-hint')!.style.color = 'black'; + }); + containerDiv.addEventListener('mouseout', () => { + 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(); + }); + + return containerDiv; + }, + action: () => { + alert('Custom export action triggered!'); + }, + }, + { divider: true, command: '' }, + { + command: 'filter-column', + title: 'Filter Column', + // Slot renderer with status indicator and beta badge + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + id: 'percentComplete', + name: '% Complete', + field: 'percentComplete', + sortable: true, + filterable: true, + type: 'number', + filter: { model: Filters.slider, operator: '>=' }, + // Demo: Header Menu with Slot - showing interactive element (checkbox) + header: { + menu: { + commandItems: [ + { + command: 'recalc', + title: 'Recalculate', + iconCssClass: 'mdi mdi-refresh', + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + 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 defaultMenuItemRenderer at menu level) + cellMenu: { + hideCloseButton: false, + commandTitle: 'Cell Actions', + // Demo: Menu-level default renderer that applies to all items unless overridden + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + commandItems: [ + { + command: 'copy-cell', + title: 'Copy Cell Value', + 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', + iconCssClass: 'mdi mdi-download', + action: (_e, args) => { + console.log('Export row:', args.dataContext); + alert(`Export row #${args.dataContext.id}`); + }, + }, + { + 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: (_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: '' }, + { + command: 'edit-row', + title: 'Edit Row', + // Individual slotRenderer overrides the defaultMenuItemRenderer + slotRenderer: (_item, args) => ` + + `, + action: (_e, args) => { + console.log('Edit row:', args.dataContext); + alert(`Edit row #${args.dataContext.id}`); + }, + }, + { + command: 'delete-row', + title: 'Delete Row', + 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}"`)) { + reactGridRef.current?.gridService.deleteItemById(dataContext.id); + } + }, + }, + ], + }, + }, + ]; + + const gridOptions: GridOption = { + autoResize: { + container: '#demo-container', + }, + enableAutoResize: true, + enableCellNavigation: true, + enableFiltering: true, + enableSorting: true, + enableGrouping: true, + + // Header Menu with slots (already configured in columns above) + enableHeaderMenu: true, + headerMenu: { + // hideCommands: ['column-resize-by-content', 'clear-sort'], + + // Demo: Menu-level default renderer for all header menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Cell Menu with slots (configured in the Action column above) + enableCellMenu: true, + + // Context Menu with slot examples + enableContextMenu: true, + contextMenu: { + // hideCommands: ['clear-grouping', 'copy'], + + // build your command items list + // spread built-in commands and optionally filter/sort them however you want + commandListBuilder: (builtInItems) => { + // commandItems.sort((a, b) => (a === 'divider' || b === 'divider' ? 0 : a.title! > b.title! ? -1 : 1)); + return [ + // filter commands if you want + // ...builtInItems.filter((x) => x !== 'divider' && x.command !== 'copy' && x.command !== 'clear-grouping'), + { + command: 'edit-cell', + title: 'Edit Cell', + // Demo: Individual slotRenderer overrides the menu's defaultMenuItemRenderer + slotRenderer: (cmdItem) => { + // 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('mouseover', () => { + iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; + iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; + }); + containerDiv.addEventListener('mouseout', () => { + iconDiv.style.transform = 'rotate(0deg) scale(1)'; + iconDiv.style.boxShadow = 'none'; + }); + + return containerDiv; + }, + action: () => alert('Edit cell'), + }, + ...builtInItems, + { 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'), + }, + ] as Array; + }, + // Demo: Menu-level default renderer for context menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Grid Menu with slot examples (demonstrating defaultMenuItemRenderer at menu level) + enableGridMenu: true, + gridMenu: { + // hideCommands: ['toggle-preheader', 'toggle-filter'], + + // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + 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 defaultMenuItemRenderer for this item + slotRenderer: (cmdItem) => ` + + `, + action: () => alert('Export to CSV'), + }, + { + 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'), + }, + ] as Array; + }, + }, + + // tooltip plugin + externalResources: [new SlickCustomTooltip()], + customTooltip: { + observeAllTooltips: true, + }, + }; + + setColumnDefinitions(columnDefinitions); + setGridOptions(gridOptions); + } + + function clearGrouping() { + reactGridRef.current?.dataView?.setGrouping([]); + } + + function collapseAllGroups() { + reactGridRef.current?.dataView?.collapseAllGroups(); + } + + function expandAllGroups() { + reactGridRef.current?.dataView?.expandAllGroups(); + } + + function groupByDuration() { + // you need to manually add the sort icon(s) in UI + reactGridRef.current?.slickGrid?.setSortColumns([{ columnId: 'duration', sortAsc: true }]); + reactGridRef.current?.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); + reactGridRef.current?.slickGrid?.invalidate(); // invalidate all rows and re-render + } + + function 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; + } + + function toggleSubTitle() { + const newHideSubTitle = !hideSubTitle; + setHideSubTitle(newHideSubTitle); + const action = newHideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + reactGridRef.current?.resizerService.resizeGrid(0); + } + + return !gridOptions ? ( + '' + ) : ( +
+

+ Example 51: Menus with Slots + + see  + + code + + + +

+ +
+
+ + 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): +

+

+ + Note: The demo focuses on the custom rendering capability via slotRenderer and + 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. + +

+
+ +
+
+ + + + +
+
+ +
+ reactGridReady($event.detail)} + /> +
+
+ ); +}; + +export default Example51; diff --git a/demos/react/src/examples/slickgrid/example51.scss b/demos/react/src/examples/slickgrid/example51.scss new file mode 100644 index 0000000000..539ad2ecd2 --- /dev/null +++ b/demos/react/src/examples/slickgrid/example51.scss @@ -0,0 +1,144 @@ +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-tooltip-background-color: #4c4c4c; + --slick-tooltip-color: #fff; + --slick-tooltip-font-size: 14px; + .slick-cell-menu, + .slick-context-menu, + .slick-grid-menu, + .slick-header-menu { + .slick-menu-item:hover:not(.slick-menu-item-disabled) { + color: #0a34b5; + } + } + .slick-menu-footer { + padding: 4px 6px; + border-top: 1px solid #c0c0c0; + } +} + +kbd { + background-color: #eee; + color: #202020; +} +.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/react/test/cypress/e2e/example51.cy.ts b/demos/react/test/cypress/e2e/example51.cy.ts new file mode 100644 index 0000000000..80e5148c97 --- /dev/null +++ b/demos/react/test/cypress/e2e/example51.cy.ts @@ -0,0 +1,382 @@ +import { format } from '@formkit/tempo'; + +describe('Example 51 - Menus with Slots', () => { + const fullTitles = ['Title', 'Duration', 'Start', 'Finish', 'Cost', '% Complete', 'Action']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example51`); + cy.get('h2').should('contain', 'Example 51: Menus with Slots'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid51') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should open Context Menu hover "Duration" column and expect built-in and custom items listed in specific order', () => { + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + // 1st item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.edit-cell-icon').contains('✎'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Edit Cell'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.edit-cell').contains('F2'); + + // icon should rotate while hovering + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.wait(175); // wait for rotation + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .edit-cell-icon') + .invoke('css', 'transform') // Get the transform property + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'rotate').then((rotationAngle) => { + expect(rotationAngle).to.approximately(13, 15); // 15 degrees rotation + }); + }); + + // 2nd item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('i.mdi-content-copy').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('span.menu-item-label').contains('Copy'); + + // 3rd item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(2)').should('have.class', 'slick-menu-item-divider'); + + // 4th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Clear all Grouping'); + + // 5th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item').find('i.mdi-arrow-collapse').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('span.menu-item-label') + .contains('Collapse all Groups'); + + // 6th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('i.mdi-arrow-expand').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Expand all Groups'); + + // 7th item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(6)').should('have.class', 'slick-menu-item-divider'); + + // 8th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Export'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7)').find('.sub-item-chevron').should('exist'); + + // 9th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(8)').should('have.class', 'slick-menu-item-divider'); + + // 10th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('i.mdi-delete.text-danger') + .should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('span.menu-item-label') + .contains('Delete Row'); + }); + + it('should open Export->Excel context sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', '0'); + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Header Menu from the "Title" column and expect some commands to have keyboard hints on the right side', () => { + cy.get('.slick-header-column:nth(0)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(0)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('kbd.key-hint').contains('Alt+↑'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('kbd.key-hint').contains('Alt+↓'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Duration" column and expect some commands to have tags on the right side', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(1)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('span.key-hint.danger').contains('NEW'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Cost" column and expect first item to have a dynamic tooltip timestamp when hovering', () => { + cy.get('#grid51').find('.slick-header-column:nth(4)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.advanced-export-icon').contains('📊'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').contains('Advanced Export'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.key-hint').contains('Ctrl+E'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgba(0, 0, 0, 0)' + ); + + // icon should scale up + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .advanced-export-icon') + .invoke('css', 'transform') + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'scale').then((scaleValue) => { + expect(scaleValue).to.be.approximately(1.1, 1.15); // Check the scale value if applied + }); + }); + + const today = format(new Date(), 'YYYY-MM-DD'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgb(133, 70, 133)' + ); + cy.get('.slick-custom-tooltip').contains(`📈 Export timestamp: ${today}`); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseout'); + cy.get('.slick-custom-tooltip').should('not.exist'); + cy.get('body').click(); + }); + + it('should open Action Menu from last column "Action" column and expect custom items listed in specific order', () => { + cy.get('[data-row="1"] > .slick-cell:nth(6)').click(); + cy.get('.slick-command-header.with-title.with-close').contains('Cell Actions'); + + // 1st item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.mdi-content-copy').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Copy Cell Value'); + + // 2nd item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Export Row'); + + // 4th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('span.menu-item-label').contains('Export'); + + // 5th item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('.edit-cell-icon').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item .edit-cell-icon').contains('✎'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); + + // 7th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('.mdi-delete.text-danger').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Delete Row'); + }); + + it('should open Export->Excel cell sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export row #1 to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Grid Menu and expect built-in commands first then custom items listed in specific order', () => { + cy.get('.slick-grid-menu-button.mdi-menu').click(); + + // 1st item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('.mdi-filter-remove-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Clear all Filters'); + + // 2nd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item') + .find('.mdi-sort-variant-off.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item span').contains('Clear all Sorting'); + + // 3rd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('.mdi-flip-vertical.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Toggle Filter Row'); + + // 4th item - divider + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-divider'); + + // 5th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('.mdi-file-excel-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item span').contains('Export to Excel'); + + // 6th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('.mdi-download.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item span').contains('Export to CSV'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('span.key-hint.warn').contains('CUSTOM'); + + // 7th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('.mdi-refresh.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Refresh Data'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('kbd.key-hint').contains('F5'); + }); + + it('should sort ascending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').should('contain', 'Sort Ascending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '0'); + }); + + it('should sort descending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').should('contain', 'Sort Descending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '100'); + }); +}); 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/react/tsconfig.json b/demos/react/tsconfig.json index 68ce43f7d8..669403216e 100644 --- a/demos/react/tsconfig.json +++ b/demos/react/tsconfig.json @@ -4,7 +4,6 @@ "target": "es2022", "module": "esnext", "sourceMap": true, - "downlevelIteration": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "esModuleInterop": true, 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/example03.ts b/demos/vanilla/src/examples/example03.ts index 75d125ae60..c2a023b836 100644 --- a/demos/vanilla/src/examples/example03.ts +++ b/demos/vanilla/src/examples/example03.ts @@ -424,7 +424,7 @@ export default class Example03 { onCommand: (e, args) => this.executeCommand(e, args), onOptionSelected: (_e, args) => { // change "Effort-Driven" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('effortDriven')) { dataContext.effortDriven = args.item.option; this.sgb.gridService.updateItem(dataContext); diff --git a/demos/vanilla/src/examples/example16.ts b/demos/vanilla/src/examples/example16.ts index dd404b25d9..7141123731 100644 --- a/demos/vanilla/src/examples/example16.ts +++ b/demos/vanilla/src/examples/example16.ts @@ -503,7 +503,7 @@ export default class Example16 { onCommand: (e, args) => this.executeCommand(e, args), onOptionSelected: (_e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('completed')) { dataContext.completed = args.item.option; this.sgb.gridService.updateItem(dataContext); diff --git a/demos/vanilla/src/examples/example40.html b/demos/vanilla/src/examples/example40.html new file mode 100644 index 0000000000..fa04a7f09b --- /dev/null +++ b/demos/vanilla/src/examples/example40.html @@ -0,0 +1,57 @@ +

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

+ +
+

+ + 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): +

+

+ Note: The demo focuses on the custom rendering capability via slotRenderer and + 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.scss b/demos/vanilla/src/examples/example40.scss new file mode 100644 index 0000000000..54a81b8298 --- /dev/null +++ b/demos/vanilla/src/examples/example40.scss @@ -0,0 +1,140 @@ +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-tooltip-background-color: #4c4c4c; + --slick-tooltip-color: #fff; + --slick-tooltip-font-size: 14px; + .slick-cell-menu, + .slick-context-menu, + .slick-grid-menu, + .slick-header-menu { + .slick-menu-item:hover:not(.slick-menu-item-disabled) { + 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 new file mode 100644 index 0000000000..4106ba23d2 --- /dev/null +++ b/demos/vanilla/src/examples/example40.ts @@ -0,0 +1,620 @@ +import { format as tempoFormat } from '@formkit/tempo'; +import { BindingEventService } from '@slickgrid-universal/binding'; +import { + Aggregators, + createDomElement, + Filters, + Formatters, + SortComparers, + SortDirectionNumber, + type Column, + type GridOption, + type Grouping, + type MenuCommandItem, +} 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; + 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; + subTitleStyle = 'display: block'; + + constructor() { + this._bindingEventService = new BindingEventService(); + } + + attached() { + this.initializeGrid(); + this.dataset = this.loadData(2000); + 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, SlickCellMenu, SlickContextMenu, SlickGridMenu + + 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 (can be HTML string or native DOM elements) + slotRenderer: (cmdItem) => ` + + `, + }, + { + command: 'sort-desc', + title: 'Sort Descending', + positionOrder: 51, + // 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; + }, + }, + ], + }, + }, + }, + { + 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: () => ` + + `, + }, + { 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: () => ` + + `, + }, + ], + }, + }, + }, + { + 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: (cmdItem) => { + // 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: cmdItem.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('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')}`; + containerDiv.style.color = 'white'; + containerDiv.querySelector('.key-hint')!.style.color = 'black'; + }); + containerDiv.addEventListener('mouseout', () => { + 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(); + }); + + return containerDiv; + }, + action: () => { + alert('Custom export action triggered!'); + }, + }, + { divider: true, command: '' }, + { + command: 'filter-column', + title: 'Filter Column', + // Slot renderer with status indicator and beta badge + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + id: 'percentComplete', + name: '% Complete', + field: 'percentComplete', + sortable: true, + filterable: true, + type: 'number', + filter: { model: Filters.slider, operator: '>=' }, + // Demo: Header Menu with Slot - showing interactive element (checkbox) + header: { + menu: { + commandItems: [ + { + command: 'recalc', + title: 'Recalculate', + iconCssClass: 'mdi mdi-refresh', + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + 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 defaultMenuItemRenderer at menu level) + cellMenu: { + hideCloseButton: false, + commandTitle: 'Cell Actions', + // Demo: Menu-level default renderer that applies to all items unless overridden + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + commandItems: [ + { + command: 'copy-cell', + title: 'Copy Cell Value', + 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', + iconCssClass: 'mdi mdi-download', + action: (_e, args) => { + console.log('Export row:', args.dataContext); + alert(`Export row #${args.dataContext.id}`); + }, + }, + { + 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: (_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: '' }, + { + command: 'edit-row', + title: 'Edit Row', + // Individual slotRenderer overrides the defaultMenuItemRenderer + slotRenderer: (_item, args) => ` + + `, + action: (_e, args) => { + console.log('Edit row:', args.dataContext); + alert(`Edit row #${args.dataContext.id}`); + }, + }, + { + command: 'delete-row', + title: 'Delete Row', + 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); + } + }, + }, + ], + }, + }, + ]; + + this.gridOptions = { + autoResize: { + container: '.demo-container', + }, + enableAutoResize: true, + enableCellNavigation: true, + enableFiltering: true, + enableSorting: true, + enableGrouping: true, + + // Header Menu with slots (already configured in columns above) + enableHeaderMenu: true, + headerMenu: { + // hideCommands: ['column-resize-by-content', 'clear-sort'], + + // Demo: Menu-level default renderer for all header menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Cell Menu with slots (configured in the Action column above) + enableCellMenu: true, + + // Context Menu with slot examples + enableContextMenu: true, + contextMenu: { + // hideCommands: ['clear-grouping', 'copy'], + + // build your command items list + // spread built-in commands and optionally filter/sort them however you want + commandListBuilder: (builtInItems) => { + // commandItems.sort((a, b) => (a === 'divider' || b === 'divider' ? 0 : a.title! > b.title! ? -1 : 1)); + return [ + // filter commands if you want + // ...builtInItems.filter((x) => x !== 'divider' && x.command !== 'copy' && x.command !== 'clear-grouping'), + { + command: 'edit-cell', + title: 'Edit Cell', + // Demo: Individual slotRenderer overrides the menu's defaultMenuItemRenderer + slotRenderer: (cmdItem) => { + // 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('mouseover', () => { + iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; + iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; + }); + containerDiv.addEventListener('mouseout', () => { + iconDiv.style.transform = 'rotate(0deg) scale(1)'; + iconDiv.style.boxShadow = 'none'; + }); + + return containerDiv; + }, + action: () => alert('Edit cell'), + }, + ...builtInItems, + { 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'), + }, + ] as Array; + }, + // Demo: Menu-level default renderer for context menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Grid Menu with slot examples (demonstrating defaultMenuItemRenderer at menu level) + enableGridMenu: true, + gridMenu: { + // hideCommands: ['toggle-preheader', 'toggle-filter'], + + // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + 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 defaultMenuItemRenderer for this item + slotRenderer: (cmdItem) => ` + + `, + action: () => alert('Export to CSV'), + }, + { + 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'), + }, + ] as Array; + }, + }, + + // tooltip plugin + externalResources: [new SlickCustomTooltip()], + customTooltip: { + observeAllTooltips: true, + }, + }; + } + + 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++) { + 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; + } + + toggleSubTitle() { + this.subTitleStyle = this.subTitleStyle === 'display: block' ? 'display: none' : 'display: block'; + this.sgb.resizerService.resizeGrid(); + } +} diff --git a/demos/vue/src/components/Example03.vue b/demos/vue/src/components/Example03.vue index 7873927bbe..1b6472f73c 100644 --- a/demos/vue/src/components/Example03.vue +++ b/demos/vue/src/components/Example03.vue @@ -41,7 +41,7 @@ let vueGrid!: SlickgridVueInstance; // you can create custom validator to pass to an inline editor const myCustomTitleValidator: EditorValidator = (value: any) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it - // const grid = args && args.grid; + // const grid = args?.grid; // const gridOptions = grid.getOptions() as GridOption; // const i18n = gridOptions.i18n; diff --git a/demos/vue/src/components/Example04.vue b/demos/vue/src/components/Example04.vue index ed74630b1a..7cd39768d3 100644 --- a/demos/vue/src/components/Example04.vue +++ b/demos/vue/src/components/Example04.vue @@ -308,12 +308,12 @@ function setSortingDynamically() { } function refreshMetrics(_e: Event, args: any) { - if (args && args.current >= 0) { + if (args?.current >= 0) { setTimeout(() => { metrics.value = { startTime: new Date(), endTime: new Date(), - itemCount: (args && args.current) || 0, + itemCount: args?.current || 0, totalItemCount: dataset.value.length || 0, }; }); diff --git a/demos/vue/src/components/Example24.vue b/demos/vue/src/components/Example24.vue index f383cdeafa..05ff6fc8e1 100644 --- a/demos/vue/src/components/Example24.vue +++ b/demos/vue/src/components/Example24.vue @@ -424,7 +424,7 @@ function getContextMenuOptions(): ContextMenu { // optionally and conditionally define when the the menu is usable, // this should be used with a custom formatter to show/hide/disable the menu menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return dataContext.id < 21; // say we want to display the menu only from Task 0 to 20 }, // which column to show the command list? when not defined it will be shown over all columns @@ -455,7 +455,7 @@ function getContextMenuOptions(): ContextMenu { }, // only show command to 'Help' when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -521,7 +521,7 @@ function getContextMenuOptions(): ContextMenu { textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, // you can use the 'action' callback and/or subscribe to the 'onCallback' event, they both have the same arguments @@ -543,7 +543,7 @@ function getContextMenuOptions(): ContextMenu { disabled: true, // only shown when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -574,7 +574,7 @@ function getContextMenuOptions(): ContextMenu { // subscribe to Context Menu onOptionSelected event (or use the action callback on each option) onOptionSelected: (_e: any, args: any) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && 'priority' in dataContext) { dataContext.priority = args.item.option; vueGrid.gridService.updateItem(dataContext); diff --git a/demos/vue/src/components/Example33.vue b/demos/vue/src/components/Example33.vue index 89b8f0d5c5..0eae92d27a 100644 --- a/demos/vue/src/components/Example33.vue +++ b/demos/vue/src/components/Example33.vue @@ -497,7 +497,7 @@ function defineGrid() { onCommand: (e, args) => executeCommand(e, args), onOptionSelected: (_e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && 'completed' in dataContext) { dataContext.completed = args.item.option; vueGrid.gridService.updateItem(dataContext); diff --git a/demos/vue/src/components/Example51.vue b/demos/vue/src/components/Example51.vue new file mode 100644 index 0000000000..c1a5389ea2 --- /dev/null +++ b/demos/vue/src/components/Example51.vue @@ -0,0 +1,818 @@ + + + + diff --git a/demos/vue/src/router/index.ts b/demos/vue/src/router/index.ts index 9b49903f2c..c699a3ecd2 100644 --- a/demos/vue/src/router/index.ts +++ b/demos/vue/src/router/index.ts @@ -1,5 +1,5 @@ -import type { RouteRecordRaw } from 'vue-router'; -import { createRouter, createWebHashHistory } from 'vue-router'; +import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'; + import Example1 from '../components/Example01.vue'; import Example2 from '../components/Example02.vue'; import Example3 from '../components/Example03.vue'; @@ -50,6 +50,7 @@ import Example47 from '../components/Example47.vue'; import Example48 from '../components/Example48.vue'; import Example49 from '../components/Example49.vue'; import Example50 from '../components/Example50.vue'; +import Example51 from '../components/Example51.vue'; import Home from '../Home.vue'; export const routes: RouteRecordRaw[] = [ @@ -105,6 +106,7 @@ export const routes: RouteRecordRaw[] = [ { path: '/example48', name: '48- Hybrid Selection Model', component: Example48 }, { path: '/example49', name: '49- Spreadsheet Drag-Fill', component: Example49 }, { path: '/example50', name: '50- Master/Detail Grids', component: Example50 }, + { path: '/example51', name: '51- Menus with Slots', component: Example51 }, ]; export const router = createRouter({ diff --git a/demos/vue/test/cypress/e2e/example51.cy.ts b/demos/vue/test/cypress/e2e/example51.cy.ts new file mode 100644 index 0000000000..80e5148c97 --- /dev/null +++ b/demos/vue/test/cypress/e2e/example51.cy.ts @@ -0,0 +1,382 @@ +import { format } from '@formkit/tempo'; + +describe('Example 51 - Menus with Slots', () => { + const fullTitles = ['Title', 'Duration', 'Start', 'Finish', 'Cost', '% Complete', 'Action']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example51`); + cy.get('h2').should('contain', 'Example 51: Menus with Slots'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid51') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should open Context Menu hover "Duration" column and expect built-in and custom items listed in specific order', () => { + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + // 1st item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.edit-cell-icon').contains('✎'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Edit Cell'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.edit-cell').contains('F2'); + + // icon should rotate while hovering + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.wait(175); // wait for rotation + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .edit-cell-icon') + .invoke('css', 'transform') // Get the transform property + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'rotate').then((rotationAngle) => { + expect(rotationAngle).to.approximately(13, 15); // 15 degrees rotation + }); + }); + + // 2nd item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('i.mdi-content-copy').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('span.menu-item-label').contains('Copy'); + + // 3rd item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(2)').should('have.class', 'slick-menu-item-divider'); + + // 4th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Clear all Grouping'); + + // 5th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item').find('i.mdi-arrow-collapse').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('span.menu-item-label') + .contains('Collapse all Groups'); + + // 6th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('i.mdi-arrow-expand').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Expand all Groups'); + + // 7th item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(6)').should('have.class', 'slick-menu-item-divider'); + + // 8th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Export'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7)').find('.sub-item-chevron').should('exist'); + + // 9th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(8)').should('have.class', 'slick-menu-item-divider'); + + // 10th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('i.mdi-delete.text-danger') + .should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('span.menu-item-label') + .contains('Delete Row'); + }); + + it('should open Export->Excel context sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', '0'); + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Header Menu from the "Title" column and expect some commands to have keyboard hints on the right side', () => { + cy.get('.slick-header-column:nth(0)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(0)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('kbd.key-hint').contains('Alt+↑'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('kbd.key-hint').contains('Alt+↓'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Duration" column and expect some commands to have tags on the right side', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(1)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('span.key-hint.danger').contains('NEW'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Cost" column and expect first item to have a dynamic tooltip timestamp when hovering', () => { + cy.get('#grid51').find('.slick-header-column:nth(4)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.advanced-export-icon').contains('📊'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').contains('Advanced Export'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.key-hint').contains('Ctrl+E'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgba(0, 0, 0, 0)' + ); + + // icon should scale up + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .advanced-export-icon') + .invoke('css', 'transform') + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'scale').then((scaleValue) => { + expect(scaleValue).to.be.approximately(1.1, 1.15); // Check the scale value if applied + }); + }); + + const today = format(new Date(), 'YYYY-MM-DD'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgb(133, 70, 133)' + ); + cy.get('.slick-custom-tooltip').contains(`📈 Export timestamp: ${today}`); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseout'); + cy.get('.slick-custom-tooltip').should('not.exist'); + cy.get('body').click(); + }); + + it('should open Action Menu from last column "Action" column and expect custom items listed in specific order', () => { + cy.get('[data-row="1"] > .slick-cell:nth(6)').click(); + cy.get('.slick-command-header.with-title.with-close').contains('Cell Actions'); + + // 1st item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.mdi-content-copy').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Copy Cell Value'); + + // 2nd item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Export Row'); + + // 4th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('span.menu-item-label').contains('Export'); + + // 5th item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('.edit-cell-icon').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item .edit-cell-icon').contains('✎'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); + + // 7th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('.mdi-delete.text-danger').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Delete Row'); + }); + + it('should open Export->Excel cell sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export row #1 to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Grid Menu and expect built-in commands first then custom items listed in specific order', () => { + cy.get('.slick-grid-menu-button.mdi-menu').click(); + + // 1st item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('.mdi-filter-remove-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Clear all Filters'); + + // 2nd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item') + .find('.mdi-sort-variant-off.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item span').contains('Clear all Sorting'); + + // 3rd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('.mdi-flip-vertical.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Toggle Filter Row'); + + // 4th item - divider + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-divider'); + + // 5th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('.mdi-file-excel-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item span').contains('Export to Excel'); + + // 6th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('.mdi-download.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item span').contains('Export to CSV'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('span.key-hint.warn').contains('CUSTOM'); + + // 7th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('.mdi-refresh.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Refresh Data'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('kbd.key-hint').contains('F5'); + }); + + it('should sort ascending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').should('contain', 'Sort Ascending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '0'); + }); + + it('should sort descending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').should('contain', 'Sort Descending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '100'); + }); +}); diff --git a/demos/vue/test/cypress/support/commands.ts b/demos/vue/test/cypress/support/commands.ts index a35be6228b..3be5a3685b 100644 --- a/demos/vue/test/cypress/support/commands.ts +++ b/demos/vue/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/docs/TOC.md b/docs/TOC.md index 492f4797d4..4322fd7f46 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -46,6 +46,7 @@ * [Resize by Cell Content](grid-functionalities/resize-by-cell-content.md) * [Column Picker](grid-functionalities/column-picker.md) * [Composite Editor Modal](grid-functionalities/composite-editor-modal.md) +* [Custom Menu Slots](menu-slots.md) * [Custom Tooltip](grid-functionalities/custom-tooltip.md) * [Column & Row Spanning](grid-functionalities/column-row-spanning.md) * [Context Menu](grid-functionalities/context-menu.md) diff --git a/docs/column-functionalities/cell-menu.md b/docs/column-functionalities/cell-menu.md index 8dbbb14c5c..533230cb14 100644 --- a/docs/column-functionalities/cell-menu.md +++ b/docs/column-functionalities/cell-menu.md @@ -94,7 +94,8 @@ So if you decide to use the `action` callback, then your code would look like th ##### with `action` callback ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { commandItems: [ { command: 'command1', title: 'Command 1', action: (e, args) => console.log(args) }, @@ -111,7 +112,8 @@ The `onCommand` (or `onOptionSelected`) **must** be defined in the Grid Options ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { commandItems: [ { command: 'command1', title: 'Command 1' }, @@ -140,6 +142,11 @@ this.gridOptions = { }; ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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 @@ -150,12 +157,10 @@ What if you want to dynamically disable or hide a Command/Option or even disable For example, say we want the Cell Menu to only be available on the first 20 rows of the grid, we could use the override this way ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { - menuUsabilityOverride: (args) => { - const dataContext = args?.dataContext; - return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 - }, + menuUsabilityOverride: (args) => args?.dataContext.id < 21, // say we want to display the menu only from Task 0 to 20 } } ]; @@ -163,29 +168,34 @@ this.columnDefinitions = [ To give another example, with Options this time, we could say that we enable the `n/a` option only when the row is Completed. So we could do it this way ```ts -this.columnDefinitions = [{ - id: 'action', field: 'action', name: 'Action', - cellMenu: { - optionItems: [{ - option: 0, title: 'n/a', textCssClass: 'italic', - // only enable this option when the task is Not Completed - itemUsabilityOverride: (args) => { - const dataContext = args?.dataContext; - return !dataContext.completed; - }, - { option: 1, iconCssClass: 'mdi mdi-star-outline yellow', title: 'Low' }, - { option: 2, iconCssClass: 'mdi mdi-star orange', title: 'Medium' }, - { option: 3, iconCssClass: 'mdi mdi-star red', title: 'High' }, - }] - } -}]; +this.columnDefinitions = [ + { + id: 'action', field: 'action', name: 'Action', + cellMenu: { + optionItems: [ + { + option: 0, title: 'n/a', textCssClass: 'italic', + // only enable this option when the task is Not Completed + itemUsabilityOverride: (args) => { + const dataContext = args?.dataContext; + return !dataContext.completed; + }, + }, + { option: 1, iconCssClass: 'mdi mdi-star-outline yellow', title: 'Low' }, + { option: 2, iconCssClass: 'mdi mdi-star orange', title: 'Medium' }, + { option: 3, iconCssClass: 'mdi mdi-star red', title: 'High' }, + ] + } + }, +]; ``` ### How to add Translations? It works exactly like the rest of the library when `enableTranslate` is set, all we have to do is to provide translations with the `Key` suffix, so for example without translations, we would use `title` and that would become `titleKey` with translations, that;'s easy enough. So for example, a list of Options could be defined as follow: ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { optionTitleKey: 'OPTIONS', // optionally pass a title to show over the Options optionItems: [ diff --git a/docs/column-functionalities/editors.md b/docs/column-functionalities/editors.md index 27d9c8db27..21f176002b 100644 --- a/docs/column-functionalities/editors.md +++ b/docs/column-functionalities/editors.md @@ -270,7 +270,7 @@ So if we take all of these informations and we want to create our own Custom Edi const myCustomTitleValidator: EditorValidator = (value: any, args: EditorArgs) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it const grid = args?.grid; - const gridOptions = (grid && grid.getOptions) ? grid.getOptions() : {}; + const gridOptions = grid.getOptions() : {}; const i18n = gridOptions.i18n; if (value == null || value === undefined || !value.length) { diff --git a/docs/column-functionalities/editors/select-dropdown-editor.md b/docs/column-functionalities/editors/select-dropdown-editor.md index defd751b7a..eef11e25c5 100644 --- a/docs/column-functionalities/editors/select-dropdown-editor.md +++ b/docs/column-functionalities/editors/select-dropdown-editor.md @@ -6,11 +6,11 @@ - [Collection Label Prefix/Suffix](#collection-label-prefixsuffix) - [Collection Label Render HTML](#collection-label-render-html) - [Collection Change Watch](#collection-watch) - - [`multiple-select.js` Options](#multiple-selectjs-options) + - [`multiple-select-vanilla` Options](#multiple-selectjs-options) - See the [Editors - Docs](../Editors.md) for more general info about Editors (validators, event handlers, ...) ## Select Editors -The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select](https://github.com/ghiscoding/multiple-select-adapted/blob/master/src/multiple-select.js) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). +The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). @@ -199,8 +199,8 @@ this.columnDefinitions = [ ]; ``` -### `multiple-select.js` Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### `multiple-select-vanilla` Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/lib` folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -226,7 +226,7 @@ this.columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } diff --git a/docs/column-functionalities/filters/select-filter.md b/docs/column-functionalities/filters/select-filter.md index 5dcd583ac5..f0cecfd56a 100644 --- a/docs/column-functionalities/filters/select-filter.md +++ b/docs/column-functionalities/filters/select-filter.md @@ -16,7 +16,7 @@ - [Collection Async Load](#collection-async-load) - [Collection Lazy Load](#collection-lazy-load) - [Collection Watch](#collection-watch) -- [`multiple-select.js` Options](#multiple-selectjs-options) +- [`multiple-select-vanilla` Options](#multiple-selectjs-options) - [Filter Options (`MultipleSelectOption` interface)](#filter-options-multipleselectoption-interface) - [Display shorter selected label text](#display-shorter-selected-label-text) - [Query against a different field](#query-against-another-field-property) @@ -33,7 +33,7 @@ Multiple Select (dropdown) filter is useful when we want to filter the grid 1 or We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). #### Note -For this filter to work you will need to add [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) to your project. This is a customized version of the original (thought all the original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) +For this filter to work you will need to add [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) to your project. This is a customized version of the original (thought all the original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. - `okButtonText` was also added for locale (i18n) - `offsetLeft` option was added to make it possible to offset the dropdown. By default it is set to 0 and is aligned to the left of the select element. This option is particularly helpful when used as the last right column, not to fall off the screen. @@ -608,8 +608,8 @@ this.gridOptions = { } ``` -### Multiple-select.js Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### multiple-select-vanilla Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why we are using a fork [ghiscoding/multiple-select-modified](https://github.com/ghiscoding/multiple-select-modified) folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -641,7 +641,7 @@ prepareGrid() { model: Filters.singleSelect, // previously known as `filterOptions` for < 9.0 options: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } as MultipleSelectOption diff --git a/docs/grid-functionalities/composite-editor-modal.md b/docs/grid-functionalities/composite-editor-modal.md index 1619e6e7ee..dcf33b1f8b 100644 --- a/docs/grid-functionalities/composite-editor-modal.md +++ b/docs/grid-functionalities/composite-editor-modal.md @@ -535,7 +535,7 @@ export class GridExample { // you can also change some editor options // not all Editors supports this functionality, so far only these Editors are supported: AutoComplete, Date, Single/Multiple Select if (columnDef.id === 'completed') { - this.compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select.js, show filter in dropdown + this.compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select-vanilla, show filter in dropdown this.compositeEditorInstance.changeFormEditorOption('product', 'minLength', 3); // autocomplete, change minLength char to type this.compositeEditorInstance.changeFormEditorOption('finish', 'displayDateMin', 'today'); // vanilla calendar date picker, change minDate to today } diff --git a/docs/grid-functionalities/context-menu.md b/docs/grid-functionalities/context-menu.md index 4b9ecd1b42..fc797f97de 100644 --- a/docs/grid-functionalities/context-menu.md +++ b/docs/grid-functionalities/context-menu.md @@ -21,7 +21,7 @@ This extensions is wrapped around the new SlickGrid Plugin **SlickContextMenu** ### Default Usage Technically, the Context Menu is enabled by default (copy, export) and so you don't have anything to do to enjoy it (you could disable it at any time). However, if you want to customize the content of the Context Menu, then continue reading. You can customize the menu with 2 different lists, Commands and/or Options, they can be used separately or at the same time. Also note that even though the code shown below makes a separation between the Commands and Options, you can mix them in the same Context Menu. -#### with Commands +#### with Commands (Static) ```ts this.gridOptions = { enableFiltering: true, @@ -60,6 +60,98 @@ this.gridOptions = { }; ``` +#### with Commands (Dynamic Builder) +For advanced scenarios where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +```ts +this.gridOptions = { + enableContextMenu: true, + contextMenu: { + commandListBuilder: (builtInItems) => { + // Example: Add custom commands after built-in ones + return [ + ...builtInItems, + 'divider', + { + command: 'delete-row', + title: 'Delete Row', + iconCssClass: 'mdi mdi-delete text-danger', + action: (e, args) => { + if (confirm(`Delete row ${args.dataContext.id}?`)) { + this.gridService.deleteItem(args.dataContext); + } + } + }, + { + command: 'duplicate-row', + title: 'Duplicate Row', + iconCssClass: 'mdi mdi-content-duplicate', + action: (e, args) => { + const newItem = { ...args.dataContext, id: this.generateNewId() }; + this.gridService.addItem(newItem); + } + } + ]; + }, + onCommand: (e, args) => { + // Handle commands here if not using action callbacks + console.log('Command:', args.command); + } + } +}; +``` + +**Example: Filter commands based on row data** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + // You can't access row data here, but you can filter/modify built-in items + // Use itemUsabilityOverride or itemVisibilityOverride for row-specific logic + + // Only show export commands + return builtInItems.filter(item => + item === 'divider' || item.command?.includes('export') + ); + } +} +``` + +**Example: Sort and reorganize commands** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + const customFirst = [ + { + command: 'edit', + title: 'Edit Row', + iconCssClass: 'mdi mdi-pencil', + positionOrder: 0 + } + ]; + + // Sort built-in commands by positionOrder + const sorted = [...builtInItems].sort((a, b) => { + if (a === 'divider' || b === 'divider') return 0; + return (a.positionOrder || 50) - (b.positionOrder || 50); + }); + + return [...customFirst, 'divider', ...sorted]; + } +} +``` + +**When to use `commandListBuilder` vs `commandItems`:** +- Use `commandItems` for static command lists +- Use `commandListBuilder` when you need to: + - Append/prepend to built-in commands + - Filter or modify commands dynamically + - Sort or reorder the final command list + - Have full control over what gets rendered + +**Note:** Typically use `commandListBuilder` **instead of** `commandItems`, not both together. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### with Options That is when you want to define a list of Options (only 1 list) that the user can choose from and once is selected we would do something (for example change the value of a cell in the grid). ```ts @@ -122,6 +214,11 @@ contextMenu: { } ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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 @@ -141,17 +238,19 @@ contextMenu: { To give another example, with Options this time, we could say that we enable the `n/a` option only when the row is Completed. So we could do it this way ```ts contextMenu: { - optionItems: [{ - option: 0, title: 'n/a', textCssClass: 'italic', - // only enable this option when the task is Not Completed - itemUsabilityOverride: (args) => { - const dataContext = args?.dataContext; - return !dataContext.completed; + optionItems: [ + { + option: 0, title: 'n/a', textCssClass: 'italic', + // only enable this option when the task is Not Completed + itemUsabilityOverride: (args) => { + const dataContext = args?.dataContext; + return !dataContext.completed; + }, }, { option: 1, iconCssClass: 'mdi mdi-star-outline yellow', title: 'Low' }, { option: 2, iconCssClass: 'mdi mdi-star orange', title: 'Medium' }, { option: 3, iconCssClass: 'mdi mdi-star red', title: 'High' }, - }] + ] } ``` @@ -160,11 +259,11 @@ It works exactly like the rest of the library when `enableTranslate` is set, all ```ts contextMenu: { optionTitleKey: 'OPTIONS', // optionally pass a title to show over the Options - optionItems: [{ + optionItems: [ { option: 1, titleKey: 'LOW', iconCssClass: 'mdi mdi-star-outline yellow' }, { option: 2, titleKey: 'MEDIUM', iconCssClass: 'mdi mdi-star orange' }, { option: 3, titleKey: 'HIGH', iconCssClass: 'mdi mdi-star red' }, - }] + ] } ``` @@ -184,25 +283,25 @@ Another set of possible Commands would be related to Grouping, so if you are usi All of these internal commands, you can choose to hide them and/or change their icons, the default global options are the following and you can change any of them. ```ts contextMenu: { - autoAdjustDrop: true, - autoAlignSide: true, - hideCloseButton: true, - hideClearAllGrouping: false, - hideCollapseAllGroups: false, - hideCommandSection: false, - hideCopyCellValueCommand: false, - hideExpandAllGroups: false, - hideExportCsvCommand: false, - hideExportExcelCommand: false, - hideExportTextDelimitedCommand: true, - hideMenuOnScroll: true, - hideOptionSection: false, - iconCopyCellValueCommand: 'mdi mdi-content-copy', - iconExportCsvCommand: 'mdi mdi-download', - iconExportExcelCommand: 'mdi mdi-file-excel-outline text-success', - iconExportTextDelimitedCommand: 'mdi mdi-download', - width: 200, - }, + autoAdjustDrop: true, + autoAlignSide: true, + hideCloseButton: true, + hideClearAllGrouping: false, + hideCollapseAllGroups: false, + hideCommandSection: false, + hideCopyCellValueCommand: false, + hideExpandAllGroups: false, + hideExportCsvCommand: false, + hideExportExcelCommand: false, + hideExportTextDelimitedCommand: true, + hideMenuOnScroll: true, + hideOptionSection: false, + iconCopyCellValueCommand: 'mdi mdi-content-copy', + iconExportCsvCommand: 'mdi mdi-download', + iconExportExcelCommand: 'mdi mdi-file-excel-outline text-success', + iconExportTextDelimitedCommand: 'mdi mdi-download', + width: 200, +}, ``` ### How to Disable the Context Menu? diff --git a/docs/grid-functionalities/grid-menu.md b/docs/grid-functionalities/grid-menu.md index e69d0c03ee..2e1befef10 100644 --- a/docs/grid-functionalities/grid-menu.md +++ b/docs/grid-functionalities/grid-menu.md @@ -21,6 +21,8 @@ The Grid Menu also comes, by default, with a list of built-in custom commands (a - _Refresh Dataset_, only shown when using Backend Service API (you can hide it with `hideRefreshDatasetCommand: true`) This section is called Custom Commands because you can also customize this section with your own commands. To do that, you need to fill in 2 properties (an array of `commandItems` and define `onGridMenuCommand` callback) in your Grid Options. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): + +#### Using Static Command Items ```ts this.gridOptions = { enableAutoResize: true, @@ -73,6 +75,89 @@ this.gridOptions = { }; ``` +#### Advanced: Dynamic Command List Builder +For more advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. This function is executed **after** `commandItems` is processed and is the **last call before rendering** the menu in the DOM. + +**When to use `commandListBuilder`:** +- You want to append/prepend items to the built-in commands +- You need to filter commands based on runtime conditions +- You want to sort or reorder commands dynamically +- You need access to both built-in and custom commands to manipulate the final list + +**Note:** You would typically use `commandListBuilder` **instead of** `commandItems` (not both), since the builder gives you full control over the final command list. + +```ts +gridOptions: { + gridMenu: { + // Build the command list dynamically + commandListBuilder: (builtInItems) => { + // Example 1: Append custom commands to built-in ones + return [ + ...builtInItems, + 'divider', + { + command: 'help', + title: 'Help', + iconCssClass: 'mdi mdi-help-circle', + positionOrder: 99, + action: () => window.open('https://example.com/help', '_blank') + }, + ]; + }, + onCommand: (e, args) => { + if (args.command === 'help') { + // command handled via action callback above + } + } + } +} +``` + +**Example: Filter commands based on user permissions** +```ts +gridOptions: { + gridMenu: { + commandListBuilder: (builtInItems) => { + // Remove export commands if user doesn't have export permission + if (!this.userHasExportPermission) { + return builtInItems.filter(item => + item !== 'divider' && + !item.command?.includes('export') + ); + } + return builtInItems; + } + } +} +``` + +**Example: Reorder and customize the command list** +```ts +gridOptions: { + gridMenu: { + commandListBuilder: (builtInItems) => { + // Add custom commands at the beginning + const customCommands = [ + { + command: 'refresh-cache', + title: 'Refresh Cache', + iconCssClass: 'mdi mdi-cached', + action: () => this.refreshCache() + }, + 'divider' + ]; + + // Sort built-in items by title + const sortedBuiltIn = builtInItems + .filter(item => item !== 'divider') + .sort((a, b) => (a.title || '').localeCompare(b.title || '')); + + return [...customCommands, ...sortedBuiltIn]; + } + } +} +``` + #### Events There are multiple events/callback hooks which are accessible from the Grid Options - `onBeforeMenuShow` @@ -105,6 +190,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 `defaultMenuItemRenderer` 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..861f69b24e 100644 --- a/docs/grid-functionalities/header-menu-header-buttons.md +++ b/docs/grid-functionalities/header-menu-header-buttons.md @@ -20,7 +20,10 @@ The Header Menu also comes, by default, with a list of built-in custom commands - Sort Descending (you can hide it with `hideSortCommands: true`) - Hide Column (you can hide it with `hideColumnHideCommand: true`) -This section is called Custom Commands because you can also customize this section with your own commands. To do that, you need to fill in 2 properties (an array of `headerMenuItems` that will go under each column definition and define `onCommand` callbacks) in your Grid Options. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): +This section is called Custom Commands because you can also customize this section with your own commands. You can do this in two ways: using static command items or using a dynamic command list builder. + +#### Static Command Items +To add static commands, fill in an array of items in your column definition's `header.menu.commandItems`. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): ```ts this.gridOptions = { enableAutoResize: true, @@ -51,6 +54,99 @@ this.gridOptions = { } }; ``` +#### Dynamic Command List Builder +For advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. This is executed **after** `commandItems` and is the **last call before rendering**. + +```ts +this.columnDefinitions = [ + { + id: 'title', + name: 'Title', + field: 'title', + header: { + menu: { + commandListBuilder: (builtInItems) => { + // Append custom commands to the built-in sort/hide commands + return [ + ...builtInItems, + 'divider', + { + command: 'freeze-column', + title: 'Freeze Column', + iconCssClass: 'mdi mdi-pin', + action: (e, args) => { + // Implement column freezing + console.log('Freeze column:', args.column.name); + } + } + ]; + } + } + } + } +]; +``` + +**Example: Conditional commands based on column type** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + const column = this; // column context + + // Add filtering option only for filterable columns + const extraCommands = []; + if (column.filterable !== false) { + extraCommands.push({ + command: 'clear-filter', + title: 'Clear Filter', + iconCssClass: 'mdi mdi-filter-remove', + action: (e, args) => { + this.filterService.clearFilterByColumnId(args.column.id); + } + }); + } + + return [...builtInItems, ...extraCommands]; + } + } +} +``` + +**Example: Remove sort commands, keep only custom ones** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + // Filter out sort commands + const filtered = builtInItems.filter(item => + item !== 'divider' && + !item.command?.includes('sort') + ); + + // Add custom commands + return [ + ...filtered, + { + command: 'custom-action', + title: 'Custom Action', + iconCssClass: 'mdi mdi-star', + action: (e, args) => alert('Custom: ' + args.column.name) + } + ]; + } + } +} +``` + +**When to use `commandListBuilder`:** +- You want to append/prepend items to built-in commands +- You need to filter or modify commands based on column properties +- You want to customize the command list per column dynamically +- You need full control over the final command list + +**Note:** Use `commandListBuilder` **instead of** `commandItems`, not both together. + #### Callback Hooks There are 2 callback hooks which are accessible in the Grid Options - `onBeforeMenuShow` @@ -58,6 +154,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 `defaultMenuItemRenderer` 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..14b9f9bb47 --- /dev/null +++ b/docs/menu-slots.md @@ -0,0 +1,524 @@ +## 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 `defaultMenuItemRenderer` at the menu level. + +> **Note:** This documentation covers **how menu items are rendered** (visual presentation). If you need to **dynamically modify which commands appear** in the menu (filtering, sorting, adding/removing items), see the `commandListBuilder` callback documented in [Grid Menu](grid-functionalities/grid-menu.md), [Context Menu](grid-functionalities/context-menu.md), or [Header Menu](grid-functionalities/header-menu-header-buttons.md). + +### TypeScript Tip: Type Inference with commandListBuilder + +When using `commandListBuilder` to add custom menu items with slotRenderer callbacks, **cast the return value to the appropriate type** to enable proper type parameters in callbacks: + +```typescript +contextMenu: { + commandListBuilder: (builtInItems) => { + return [ + ...builtInItems, + { + command: 'custom-action', + title: 'My Action', + slotRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + } + ] as Array; + } +} +``` + +Alternatively, if you only have built-in items and dividers, you can use the simpler cast: + +```typescript +return [...] as Array; +``` + +### 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?: (cmdItem: MenuItem, args: MenuCallbackArgs, event?: Event) => string | HTMLElement +``` + +- **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()`) + +### 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: (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 = 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; + } +}; +``` + +### Default Menu-Level Renderer + +Set a `defaultMenuItemRenderer` 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) + defaultMenuItemRenderer: (cmdItem, args) => { + return ` +
+ ${cmdItem.iconCssClass ? `` : ''} + ${cmdItem.title} +
+ `; + }, + commandItems: [ + { + command: 'action-1', + title: 'Action One', + iconCssClass: 'mdi mdi-check', + // This item uses defaultMenuItemRenderer + }, + { + command: 'custom', + title: 'Custom Item', + // This item overrides defaultMenuItemRenderer with its own slotRenderer + slotRenderer: () => ` +
+ Custom rendering overrides default +
+ ` + } + ] +}; +``` + +### Menu Types & Configuration + +The `slotRenderer` and `defaultMenuItemRenderer` work identically across all menu plugins: + +#### Header Menu +```typescript +const columnDef = { + id: 'name', + header: { + menu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'sort', + title: 'Sort', + slotRenderer: () => '
Custom sort
' + } + ] + } + } +}; +``` + +#### Cell Menu +```typescript +const columnDef = { + id: 'action', + cellMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'edit', + title: 'Edit', + slotRenderer: (cmdItem, args) => `
Edit row ${args.dataContext.id}
` + } + ] + } +}; +``` + +#### Context Menu +```typescript +const gridOptions = { + enableContextMenu: true, + contextMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'export', + title: 'Export', + slotRenderer: () => '
📊 Export data
' + } + ] + } +}; +``` + +#### Grid Menu +```typescript +const gridOptions = { + enableGridMenu: true, + gridMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.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: (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); + 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: (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); + 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: (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); + 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: (cmdItem, args) => ` +
+ + Edit Row #${args.dataContext?.id || 'N/A'} +
+ ` +} +``` + +#### 4. Add Interactive Elements +```typescript +{ + command: 'toggle-setting', + title: 'Auto Refresh', + 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) => { + // Prevent menu item click from firing when toggling checkbox + event?.stopPropagation?.(); + console.log('Auto refresh:', checkbox.checked); + }); + + const label = document.createElement('span'); + label.textContent = cmdItem.title; + + container.appendChild(label); + container.appendChild(checkbox); + return container; + } +} +``` + +#### 5. Add Badges and Status Labels +```typescript +{ + command: 'export-excel', + title: 'Export as Excel', + slotRenderer: (cmdItem, args) => ` +
+ + ${cmdItem.title} + RECOMMENDED +
+ ` +} +``` + +#### 6. Gradient and Styled Icons +```typescript +{ + command: 'advanced-export', + title: 'Advanced Export', + 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'; + 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 = cmdItem.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 `defaultMenuItemRenderer` +- **Built-in command preservation** - When overriding a built-in command (e.g., `sort-asc`, `sort-desc`, `hide`, etc.) with custom properties like `slotRenderer` or `iconCssClass`, if you don't provide an `action` callback, the library will automatically preserve and use the built-in action for that command. This means you can safely customize the appearance of built-in commands without losing their functionality. +- **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 `defaultMenuItemRenderer` 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: (cmdItem, 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/docs/migrations/migration-to-10.x.md b/docs/migrations/migration-to-10.x.md index 91b8aa2655..0497ce0e33 100644 --- a/docs/migrations/migration-to-10.x.md +++ b/docs/migrations/migration-to-10.x.md @@ -1,4 +1,4 @@ -## Cleaner Code / Smaller Code ⚡ +## Simplification and Modernization ⚡ One of the biggest change of this release is to hide columns by using the `hidden` column property (now used by Column Picker, Grid Menu, etc...). Previously we were removing columns from the original columns array and we then called `setColumns()` to update the grid, but this meant that we had to keep references for all visible and non-visible columns. With this new release we now keep the full columns array at all time and instead we just change column(s) visibility via their `hidden` column properties by using `grid.updateColumnById('id', { hidden: true })` and finally we update the grid via `grid.updateColumns()`. What I'm trying to emphasis is that you should really stop using `grid.setColumns()` in v10+, and if you want to hide some columns when declaring the columns, then just update their `hidden` properties, see more details below... @@ -64,11 +64,30 @@ gridOptions = { }; ``` -### External Resources are now auto-enabled - This change does not require any code update from the end user, but it is a change that you should probably be aware of nonetheless. The reason I decided to implement this is because I often forget myself to enable the associated flag and typically if you wanted to load the resource, then it's most probably because you also want it enabled. So for example, if your register `ExcelExportService` then the library will now auto-enable the resource with its associated flag (which in this case is `enableExcelExport:true`)... unless you already disabled the flag (or enabled) yourself, if so then the internal assignment will simply be skipped and yours will prevail. Also just to be clear, the list of auto-enabled external resources is rather small, it will auto-enable the following resources: (ExcelExportService, PdfExportService, TextExportService, CompositeEditorComponent and RowDetailView). +### Menu with Commands + +All menu plugins (Cell Menu, Context Menu, Header Menu and Grid Menu) now have a new `commandListBuilder: (items) => items` which now allow you to filter/sort and maybe override built-in commands. With this new feature in place, I'm deprecating all `hide...` properties and also `positionOrder` since you can now do that with the builder. You could also use the `hideCommands` which accepts an array of built-in command names. This well remove huge amount of `hide...` properties (over 30) that keeps increasing anytime a new built-in command gets added (in other words, this will simplify maintenance for both you and me).This well remove huge amount of `hide...` properties (over 30) that keeps increasing anytime a new built-in command gets added (in other words, this will simplify maintenance for both you and me). + +These are currently just deprecations in v10.x but it's strongly recommended to start using the `commandListBuilder` and/or `hideCommands` and move away from the deprecated properties which will be removed in v11.x. For example if we want to hide some built-in commands: + +```diff +gridOptions = { + gridMenu: { +- hideExportCsvCommand: true, +- hideTogglePreHeaderCommand: true, + +// via command name(s) ++ hideCommands: ['export-csv', 'toggle-preheader'], + +// or via builder ++ commandListBuilder: (cmdItems) => [...cmdItems.filter(x => x !== 'divider' && x.command !== 'export-csv' && x.command !== 'toggle-preheader')] + } +} +``` + --- {% hint style="note" %} 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/frameworks-plugins/angular-row-detail-plugin/tsconfig.json b/frameworks-plugins/angular-row-detail-plugin/tsconfig.json index 84056217cc..5b4bd341dd 100644 --- a/frameworks-plugins/angular-row-detail-plugin/tsconfig.json +++ b/frameworks-plugins/angular-row-detail-plugin/tsconfig.json @@ -21,7 +21,6 @@ "skipLibCheck": true, "sourceMap": true, "newLine": "lf", - "downlevelIteration": true, "outDir": "dist" }, "include": ["src"], diff --git a/frameworks-plugins/aurelia-row-detail-plugin/tsconfig.json b/frameworks-plugins/aurelia-row-detail-plugin/tsconfig.json index 82191a3a37..201c1b1dc0 100644 --- a/frameworks-plugins/aurelia-row-detail-plugin/tsconfig.json +++ b/frameworks-plugins/aurelia-row-detail-plugin/tsconfig.json @@ -21,7 +21,6 @@ "skipLibCheck": true, "sourceMap": true, "newLine": "lf", - "downlevelIteration": true, "outDir": "dist" }, "include": ["src"], diff --git a/frameworks-plugins/react-row-detail-plugin/tsconfig.json b/frameworks-plugins/react-row-detail-plugin/tsconfig.json index 82191a3a37..201c1b1dc0 100644 --- a/frameworks-plugins/react-row-detail-plugin/tsconfig.json +++ b/frameworks-plugins/react-row-detail-plugin/tsconfig.json @@ -21,7 +21,6 @@ "skipLibCheck": true, "sourceMap": true, "newLine": "lf", - "downlevelIteration": true, "outDir": "dist" }, "include": ["src"], diff --git a/frameworks-plugins/vue-row-detail-plugin/tsconfig.json b/frameworks-plugins/vue-row-detail-plugin/tsconfig.json index 82191a3a37..201c1b1dc0 100644 --- a/frameworks-plugins/vue-row-detail-plugin/tsconfig.json +++ b/frameworks-plugins/vue-row-detail-plugin/tsconfig.json @@ -21,7 +21,6 @@ "skipLibCheck": true, "sourceMap": true, "newLine": "lf", - "downlevelIteration": true, "outDir": "dist" }, "include": ["src"], diff --git a/frameworks/angular-slickgrid/docs/TOC.md b/frameworks/angular-slickgrid/docs/TOC.md index f92a407057..b89496d92c 100644 --- a/frameworks/angular-slickgrid/docs/TOC.md +++ b/frameworks/angular-slickgrid/docs/TOC.md @@ -53,8 +53,8 @@ * [Add, Update or Highlight a Datagrid Item](grid-functionalities/add-update-highlight.md) * [Dynamically Add CSS Classes to Item Rows](grid-functionalities/dynamic-item-metadata.md) * [Column & Row Spanning](grid-functionalities/column-row-spanning.md) -* [Context Menu](grid-functionalities/Context-Menu.md) -* [Custom Footer](grid-functionalities/Custom-Footer.md) +* [Context Menu](grid-functionalities/context-menu.md) +* [Custom Footer](grid-functionalities/custom-footer.md) * [Excel Copy Buffer Plugin](grid-functionalities/excel-copy-buffer.md) * [Export to Excel](grid-functionalities/Export-to-Excel.md) * [Export to PDF](grid-functionalities/Export-to-PDF.md) diff --git a/frameworks/angular-slickgrid/docs/column-functionalities/cell-menu.md b/frameworks/angular-slickgrid/docs/column-functionalities/cell-menu.md index ded4c4aada..cd8417ff8e 100644 --- a/frameworks/angular-slickgrid/docs/column-functionalities/cell-menu.md +++ b/frameworks/angular-slickgrid/docs/column-functionalities/cell-menu.md @@ -94,7 +94,8 @@ So if you decide to use the `action` callback, then your code would look like th ##### with `action` callback ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { commandItems: [ { command: 'command1', title: 'Command 1', action: (e, args) => console.log(args) }, @@ -111,7 +112,8 @@ The `onCommand` (or `onOptionSelected`) **must** be defined in the Grid Options ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { commandItems: [ { command: 'command1', title: 'Command 1' }, @@ -140,6 +142,11 @@ this.gridOptions = { }; ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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 @@ -150,12 +157,10 @@ What if you want to dynamically disable or hide a Command/Option or even disable For example, say we want the Cell Menu to only be available on the first 20 rows of the grid, we could use the override this way ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { - menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; - return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 - }, + menuUsabilityOverride: (args) => args?.dataContext.id < 21, // say we want to display the menu only from Task 0 to 20 } } ]; @@ -164,15 +169,17 @@ this.columnDefinitions = [ To give another example, with Options this time, we could say that we enable the `n/a` option only when the row is Completed. So we could do it this way ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { optionItems: [ - { - option: 0, title: 'n/a', textCssClass: 'italic', - // only enable this option when the task is Not Completed - itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; - return !dataContext.completed; + { + option: 0, title: 'n/a', textCssClass: 'italic', + // only enable this option when the task is Not Completed + itemUsabilityOverride: (args) => { + const dataContext = args?.dataContext; + return !dataContext.completed; + }, }, { option: 1, iconCssClass: 'mdi mdi-star-outline yellow', title: 'Low' }, { option: 2, iconCssClass: 'mdi mdi-star orange', title: 'Medium' }, @@ -187,7 +194,8 @@ this.columnDefinitions = [ It works exactly like the rest of the library when `enableTranslate` is set, all we have to do is to provide translations with the `Key` suffix, so for example without translations, we would use `title` and that would become `titleKey` with translations, that;'s easy enough. So for example, a list of Options could be defined as follow: ```ts this.columnDefinitions = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { optionTitleKey: 'OPTIONS', // optionally pass a title to show over the Options optionItems: [ diff --git a/frameworks/angular-slickgrid/docs/column-functionalities/editors.md b/frameworks/angular-slickgrid/docs/column-functionalities/editors.md index 5ab1f98c16..24f2903504 100644 --- a/frameworks/angular-slickgrid/docs/column-functionalities/editors.md +++ b/frameworks/angular-slickgrid/docs/column-functionalities/editors.md @@ -282,7 +282,7 @@ this.columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } @@ -501,10 +501,10 @@ So if we take all of these informations and we want to create our own Custom Edi ```ts const myCustomTitleValidator: EditorValidator = (value: any, args: EditorArgs) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it - const grid = args && args.grid; + const grid = args.grid; const columnDef = args.column; const dataContext = args.item; - const gridOptions = (grid && grid.getOptions) ? grid.getOptions() : {}; + const gridOptions = grid.getOptions() : {}; const i18n = gridOptions.i18n; if (value == null || value === undefined || !value.length) { diff --git a/frameworks/angular-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md b/frameworks/angular-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md index 9e791eaad1..911884149c 100644 --- a/frameworks/angular-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md +++ b/frameworks/angular-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md @@ -6,11 +6,11 @@ - [Collection Label Prefix/Suffix](#collection-label-prefixsuffix) - [Collection Label Render HTML](#collection-label-render-html) - [Collection Change Watch](#collection-watch) - - [`multiple-select.js` Options](#multiple-selectjs-options) + - [`multiple-select-vanilla` Options](#multiple-selectjs-options) - See the [Editors - Wiki](../Editors.md) for more general info about Editors (validators, event handlers, ...) ## Select Editors -The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select](https://github.com/ghiscoding/multiple-select-adapted/blob/master/src/multiple-select.js) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). +The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). @@ -37,7 +37,7 @@ this.columnDefinitions = [ ``` ### Editor Options (`MultipleSelectOption` interface) -All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts editor: { @@ -190,8 +190,8 @@ this.columnDefinitions = [ ]; ``` -### `multiple-select.js` Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### `multiple-select-vanilla` Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/lib` folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -217,7 +217,7 @@ this.columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } diff --git a/frameworks/angular-slickgrid/docs/column-functionalities/filters/select-filter.md b/frameworks/angular-slickgrid/docs/column-functionalities/filters/select-filter.md index fe7b55f7d7..f135a24310 100644 --- a/frameworks/angular-slickgrid/docs/column-functionalities/filters/select-filter.md +++ b/frameworks/angular-slickgrid/docs/column-functionalities/filters/select-filter.md @@ -16,7 +16,7 @@ - [Collection Async Load](#collection-async-load) - [Collection Lazy Load](#collection-lazy-load) - [Collection Watch](#collection-watch) -- [`multiple-select.js` Options](#multiple-selectjs-options) +- [`multiple-select-vanilla` Options](#multiple-selectjs-options) - [Filter Options (`MultipleSelectOption` interface)](#filter-options-multipleselectoption-interface) - [Display shorter selected label text](#display-shorter-selected-label-text) - [Query against a different field](#query-against-another-field-property) @@ -33,7 +33,7 @@ Multiple Select (dropdown) filter is useful when we want to filter the grid 1 or We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). #### Note -For this filter to work you will need to add [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) to your project. This is a customized version of the original (thought all the original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) +For this filter to work you will need to add [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) to your project. This is a customized version of the original (thought all the original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. - `okButtonText` was also added for locale (i18n) - `offsetLeft` option was added to make it possible to offset the dropdown. By default it is set to 0 and is aligned to the left of the select element. This option is particularly helpful when used as the last right column, not to fall off the screen. @@ -576,7 +576,7 @@ this.columnDefinitions = [ ``` ### Filter Options (`MultipleSelectOption` interface) -All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts filter: { @@ -599,8 +599,8 @@ this.gridOptions = { } ``` -### Multiple-select.js Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### multiple-select-vanilla Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why we are using a fork [ghiscoding/multiple-select-modified](https://github.com/ghiscoding/multiple-select-modified) folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -629,7 +629,7 @@ this.columnDefinitions = [ model: Filters.singleSelect, // previously known as `filterOptions` for < 9.0 options: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } as MultipleSelectOption diff --git a/frameworks/angular-slickgrid/docs/grid-functionalities/Header-Menu-&-Header-Buttons.md b/frameworks/angular-slickgrid/docs/grid-functionalities/Header-Menu-&-Header-Buttons.md index 7742b7653e..9129356b5f 100644 --- a/frameworks/angular-slickgrid/docs/grid-functionalities/Header-Menu-&-Header-Buttons.md +++ b/frameworks/angular-slickgrid/docs/grid-functionalities/Header-Menu-&-Header-Buttons.md @@ -20,7 +20,10 @@ The Header Menu also comes, by default, with a list of built-in custom commands - Sort Descending (you can hide it with `hideSortCommands: true`) - Hide Column (you can hide it with `hideColumnHideCommand: true`) -This section is called Custom Commands because you can also customize this section with your own commands. To do that, you need to fill in 2 properties (an array of `headerMenuItems` that will go under each column definition and define `onCommand` callbacks) in your Grid Options. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): +This section is called Custom Commands because you can also customize this section with your own commands. You can do this in two ways: using static command items or using a dynamic command list builder. + +#### Static Command Items +To add static commands, fill in an array of items in your column definition's `header.menu.commandItems`. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): ```ts this.gridOptions = { enableAutoResize: true, @@ -51,6 +54,100 @@ this.gridOptions = { } }; ``` + +#### Dynamic Command List Builder +For advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. This is executed **after** `commandItems` and is the **last call before rendering**. + +```ts +this.columnDefinitions = [ + { + id: 'title', + name: 'Title', + field: 'title', + header: { + menu: { + commandListBuilder: (builtInItems) => { + // Append custom commands to the built-in sort/hide commands + return [ + ...builtInItems, + 'divider', + { + command: 'freeze-column', + title: 'Freeze Column', + iconCssClass: 'mdi mdi-pin', + action: (e, args) => { + // Implement column freezing + console.log('Freeze column:', args.column.name); + } + } + ]; + } + } + } + } +]; +``` + +**Example: Conditional commands based on column type** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + const column = this; // column context + + // Add filtering option only for filterable columns + const extraCommands = []; + if (column.filterable !== false) { + extraCommands.push({ + command: 'clear-filter', + title: 'Clear Filter', + iconCssClass: 'mdi mdi-filter-remove', + action: (e, args) => { + this.filterService.clearFilterByColumnId(args.column.id); + } + }); + } + + return [...builtInItems, ...extraCommands]; + } + } +} +``` + +**Example: Remove sort commands, keep only custom ones** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + // Filter out sort commands + const filtered = builtInItems.filter(item => + item !== 'divider' && + !item.command?.includes('sort') + ); + + // Add custom commands + return [ + ...filtered, + { + command: 'custom-action', + title: 'Custom Action', + iconCssClass: 'mdi mdi-star', + action: (e, args) => alert('Custom: ' + args.column.name) + } + ]; + } + } +} +``` + +**When to use `commandListBuilder`:** +- You want to append/prepend items to built-in commands +- You need to filter or modify commands based on column properties +- You want to customize the command list per column dynamically +- You need full control over the final command list + +**Note:** Use `commandListBuilder` **instead of** `commandItems`, not both together. + #### Callback Hooks There are 2 callback hooks which are accessible in the Grid Options - `onBeforeMenuShow` @@ -58,6 +155,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 `defaultMenuItemRenderer` 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/frameworks/angular-slickgrid/docs/grid-functionalities/composite-editor-modal.md b/frameworks/angular-slickgrid/docs/grid-functionalities/composite-editor-modal.md index b9ea239db2..ce3c9cf392 100644 --- a/frameworks/angular-slickgrid/docs/grid-functionalities/composite-editor-modal.md +++ b/frameworks/angular-slickgrid/docs/grid-functionalities/composite-editor-modal.md @@ -476,7 +476,7 @@ export class GridExample { // you can also change some editor options // not all Editors supports this functionality, so far only these Editors are supported: AutoComplete, Date, Single/Multiple Select if (columnDef.id === 'completed') { - this.compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select.js, show filter in dropdown + this.compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select-vanilla, show filter in dropdown this.compositeEditorInstance.changeFormEditorOption('product', 'minLength', 3); // autocomplete, change minLength char to type this.compositeEditorInstance.changeFormEditorOption('finish', 'displayDateMin', 'today'); } diff --git a/frameworks/angular-slickgrid/docs/grid-functionalities/Context-Menu.md b/frameworks/angular-slickgrid/docs/grid-functionalities/context-menu.md similarity index 77% rename from frameworks/angular-slickgrid/docs/grid-functionalities/Context-Menu.md rename to frameworks/angular-slickgrid/docs/grid-functionalities/context-menu.md index 4396e689ff..0a4c6cd168 100644 --- a/frameworks/angular-slickgrid/docs/grid-functionalities/Context-Menu.md +++ b/frameworks/angular-slickgrid/docs/grid-functionalities/context-menu.md @@ -23,7 +23,7 @@ This extensions is wrapped around the new SlickGrid Plugin **SlickContextMenu** ### Default Usage Technically, the Context Menu is enabled by default (copy, export) and so you don't have anything to do to enjoy it (you could disable it at any time). However, if you want to customize the content of the Context Menu, then continue reading. You can customize the menu with 2 different lists, Commands and/or Options, they can be used separately or at the same time. Also note that even though the code shown below makes a separation between the Commands and Options, you can mix them in the same Context Menu. -#### with Commands +#### with Commands (Static) ```ts this.gridOptions = { @@ -63,6 +63,98 @@ this.gridOptions = { }; ``` +#### with Commands (Dynamic Builder) +For advanced scenarios where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +```ts +this.gridOptions = { + enableContextMenu: true, + contextMenu: { + commandListBuilder: (builtInItems) => { + // Example: Add custom commands after built-in ones + return [ + ...builtInItems, + 'divider', + { + command: 'delete-row', + title: 'Delete Row', + iconCssClass: 'mdi mdi-delete text-danger', + action: (e, args) => { + if (confirm(`Delete row ${args.dataContext.id}?`)) { + this.gridService.deleteItem(args.dataContext); + } + } + }, + { + command: 'duplicate-row', + title: 'Duplicate Row', + iconCssClass: 'mdi mdi-content-duplicate', + action: (e, args) => { + const newItem = { ...args.dataContext, id: this.generateNewId() }; + this.gridService.addItem(newItem); + } + } + ]; + }, + onCommand: (e, args) => { + // Handle commands here if not using action callbacks + console.log('Command:', args.command); + } + } +}; +``` + +**Example: Filter commands based on row data** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + // You can't access row data here, but you can filter/modify built-in items + // Use itemUsabilityOverride or itemVisibilityOverride for row-specific logic + + // Only show export commands + return builtInItems.filter(item => + item === 'divider' || item.command?.includes('export') + ); + } +} +``` + +**Example: Sort and reorganize commands** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + const customFirst = [ + { + command: 'edit', + title: 'Edit Row', + iconCssClass: 'mdi mdi-pencil', + positionOrder: 0 + } + ]; + + // Sort built-in commands by positionOrder + const sorted = [...builtInItems].sort((a, b) => { + if (a === 'divider' || b === 'divider') return 0; + return (a.positionOrder || 50) - (b.positionOrder || 50); + }); + + return [...customFirst, 'divider', ...sorted]; + } +} +``` + +**When to use `commandListBuilder` vs `commandItems`:** +- Use `commandItems` for static command lists +- Use `commandListBuilder` when you need to: + - Append/prepend to built-in commands + - Filter or modify commands dynamically + - Sort or reorder the final command list + - Have full control over what gets rendered + +**Note:** Typically use `commandListBuilder` **instead of** `commandItems`, not both together. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### with Options That is when you want to define a list of Options (only 1 list) that the user can choose from and once is selected we would do something (for example change the value of a cell in the grid). @@ -79,7 +171,7 @@ this.gridOptions = { // subscribe to Context Menu onOptionSelected event (or use the "action" callback on each option) onOptionSelected: (e, args) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('priority')) { dataContext.priority = args.item.option; this.sgb.gridService.updateItem(dataContext); @@ -139,7 +231,7 @@ For example, say we want the Context Menu to only be available on the first 20 r ```ts contextMenu: { menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 }, }, @@ -153,7 +245,7 @@ contextMenu: { option: 0, title: 'n/a', textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -177,6 +269,11 @@ contextMenu: { } ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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. + ### Show Menu only over Certain Columns Say you want to show the Context Menu only when the user is over certain columns of the grid. For that, you could use the `commandShownOverColumnIds` (or `optionShownOverColumnIds`) array, by default these arrays are empty and when that is the case then the menu will be accessible from any columns. So if we want to have the Context Menu available only over the first 2 columns, we would have an array of those 2 column ids. For example, the following would show the Context Menu everywhere except the last 2 columns (priority, action) since they are not part of the array. ```ts diff --git a/frameworks/angular-slickgrid/docs/grid-functionalities/Custom-Footer.md b/frameworks/angular-slickgrid/docs/grid-functionalities/custom-footer.md similarity index 100% rename from frameworks/angular-slickgrid/docs/grid-functionalities/Custom-Footer.md rename to frameworks/angular-slickgrid/docs/grid-functionalities/custom-footer.md diff --git a/frameworks/angular-slickgrid/docs/grid-functionalities/grid-menu.md b/frameworks/angular-slickgrid/docs/grid-functionalities/grid-menu.md index a32879fa12..75ee618cde 100644 --- a/frameworks/angular-slickgrid/docs/grid-functionalities/grid-menu.md +++ b/frameworks/angular-slickgrid/docs/grid-functionalities/grid-menu.md @@ -74,6 +74,11 @@ this.gridOptions = { }; ``` +#### Advanced: Dynamic Command List Builder +For more advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### Events There are multiple events/callback hooks which are accessible from the Grid Options @@ -107,6 +112,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/6pac/SlickGrid/blob/master/controls/slick.gridmenu.js) 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 `defaultMenuItemRenderer` 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[Command]`, for example, see below for the defaults. diff --git a/frameworks/angular-slickgrid/docs/menu-slots.md b/frameworks/angular-slickgrid/docs/menu-slots.md new file mode 100644 index 0000000000..14b9f9bb47 --- /dev/null +++ b/frameworks/angular-slickgrid/docs/menu-slots.md @@ -0,0 +1,524 @@ +## 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 `defaultMenuItemRenderer` at the menu level. + +> **Note:** This documentation covers **how menu items are rendered** (visual presentation). If you need to **dynamically modify which commands appear** in the menu (filtering, sorting, adding/removing items), see the `commandListBuilder` callback documented in [Grid Menu](grid-functionalities/grid-menu.md), [Context Menu](grid-functionalities/context-menu.md), or [Header Menu](grid-functionalities/header-menu-header-buttons.md). + +### TypeScript Tip: Type Inference with commandListBuilder + +When using `commandListBuilder` to add custom menu items with slotRenderer callbacks, **cast the return value to the appropriate type** to enable proper type parameters in callbacks: + +```typescript +contextMenu: { + commandListBuilder: (builtInItems) => { + return [ + ...builtInItems, + { + command: 'custom-action', + title: 'My Action', + slotRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + } + ] as Array; + } +} +``` + +Alternatively, if you only have built-in items and dividers, you can use the simpler cast: + +```typescript +return [...] as Array; +``` + +### 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?: (cmdItem: MenuItem, args: MenuCallbackArgs, event?: Event) => string | HTMLElement +``` + +- **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()`) + +### 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: (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 = 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; + } +}; +``` + +### Default Menu-Level Renderer + +Set a `defaultMenuItemRenderer` 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) + defaultMenuItemRenderer: (cmdItem, args) => { + return ` +
+ ${cmdItem.iconCssClass ? `` : ''} + ${cmdItem.title} +
+ `; + }, + commandItems: [ + { + command: 'action-1', + title: 'Action One', + iconCssClass: 'mdi mdi-check', + // This item uses defaultMenuItemRenderer + }, + { + command: 'custom', + title: 'Custom Item', + // This item overrides defaultMenuItemRenderer with its own slotRenderer + slotRenderer: () => ` +
+ Custom rendering overrides default +
+ ` + } + ] +}; +``` + +### Menu Types & Configuration + +The `slotRenderer` and `defaultMenuItemRenderer` work identically across all menu plugins: + +#### Header Menu +```typescript +const columnDef = { + id: 'name', + header: { + menu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'sort', + title: 'Sort', + slotRenderer: () => '
Custom sort
' + } + ] + } + } +}; +``` + +#### Cell Menu +```typescript +const columnDef = { + id: 'action', + cellMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'edit', + title: 'Edit', + slotRenderer: (cmdItem, args) => `
Edit row ${args.dataContext.id}
` + } + ] + } +}; +``` + +#### Context Menu +```typescript +const gridOptions = { + enableContextMenu: true, + contextMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'export', + title: 'Export', + slotRenderer: () => '
📊 Export data
' + } + ] + } +}; +``` + +#### Grid Menu +```typescript +const gridOptions = { + enableGridMenu: true, + gridMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.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: (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); + 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: (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); + 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: (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); + 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: (cmdItem, args) => ` +
+ + Edit Row #${args.dataContext?.id || 'N/A'} +
+ ` +} +``` + +#### 4. Add Interactive Elements +```typescript +{ + command: 'toggle-setting', + title: 'Auto Refresh', + 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) => { + // Prevent menu item click from firing when toggling checkbox + event?.stopPropagation?.(); + console.log('Auto refresh:', checkbox.checked); + }); + + const label = document.createElement('span'); + label.textContent = cmdItem.title; + + container.appendChild(label); + container.appendChild(checkbox); + return container; + } +} +``` + +#### 5. Add Badges and Status Labels +```typescript +{ + command: 'export-excel', + title: 'Export as Excel', + slotRenderer: (cmdItem, args) => ` +
+ + ${cmdItem.title} + RECOMMENDED +
+ ` +} +``` + +#### 6. Gradient and Styled Icons +```typescript +{ + command: 'advanced-export', + title: 'Advanced Export', + 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'; + 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 = cmdItem.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 `defaultMenuItemRenderer` +- **Built-in command preservation** - When overriding a built-in command (e.g., `sort-asc`, `sort-desc`, `hide`, etc.) with custom properties like `slotRenderer` or `iconCssClass`, if you don't provide an `action` callback, the library will automatically preserve and use the built-in action for that command. This means you can safely customize the appearance of built-in commands without losing their functionality. +- **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 `defaultMenuItemRenderer` 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: (cmdItem, 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/frameworks/angular-slickgrid/docs/migrations/migration-to-10.x.md b/frameworks/angular-slickgrid/docs/migrations/migration-to-10.x.md index 48e583ee65..175159a65c 100644 --- a/frameworks/angular-slickgrid/docs/migrations/migration-to-10.x.md +++ b/frameworks/angular-slickgrid/docs/migrations/migration-to-10.x.md @@ -1,4 +1,4 @@ -## Cleaner Code / Smaller Code ⚡ +## Simplification and Modernization ⚡ One of the biggest change of this release is to hide columns by using the `hidden` column property (now used by Column Picker, Grid Menu, etc...). Previously we were removing columns from the original columns array and we then called `setColumns()` to update the grid, but this meant that we had to keep references for all visible and non-visible columns. With this new release we now keep the full columns array at all time and instead we just change column(s) visibility via their `hidden` column properties by using `grid.updateColumnById('id', { hidden: true })` and finally we update the grid via `grid.updateColumns()`. What I'm trying to emphasis is that you should really stop using `grid.setColumns()` in v10+, and if you want to hide some columns when declaring the columns, then just update their `hidden` properties, see more details below... @@ -89,11 +89,30 @@ gridOptions = { }; ``` -### External Resources are now auto-enabled - This change does not require any code update from the end user, but it is a change that you should probably be aware of nonetheless. The reason I decided to implement this is because I often forget myself to enable the associated flag and typically if you wanted to load the resource, then it's most probably because you also want it enabled. So for example, if your register `ExcelExportService` then the library will now auto-enable the resource with its associated flag (which in this case is `enableExcelExport:true`)... unless you already disabled the flag (or enabled) yourself, if so then the internal assignment will simply be skipped and yours will prevail. Also just to be clear, the list of auto-enabled external resources is rather small, it will auto-enable the following resources: (ExcelExportService, PdfExportService, TextExportService, CompositeEditorComponent and RowDetailView). +### Menu with Commands + +All menu plugins (Cell Menu, Context Menu, Header Menu and Grid Menu) now have a new `commandListBuilder: (items) => items` which now allow you to filter/sort and maybe override built-in commands. With this new feature in place, I'm deprecating all `hide...` properties and also `positionOrder` since you can now do that with the builder. You could also use the `hideCommands` which accepts an array of built-in command names. This well remove huge amount of `hide...` properties (over 30) that keeps increasing anytime a new built-in command gets added (in other words, this will simplify maintenance for both you and me). + +These are currently just deprecations in v10.x but it's strongly recommended to start using the `commandListBuilder` and/or `hideCommands` and move away from the deprecated properties which will be removed in v11.x. For example if we want to hide some built-in commands: + +```diff +gridOptions = { + gridMenu: { +- hideExportCsvCommand: true, +- hideTogglePreHeaderCommand: true, + +// via command name(s) ++ hideCommands: ['export-csv', 'toggle-preheader'], + +// or via builder ++ commandListBuilder: (cmdItems) => [...cmdItems.filter(x => x !== 'divider' && x.command !== 'export-csv' && x.command !== 'toggle-preheader')] + } +} +``` + ### `ngx-translate` v17.x now required Because of the Angular v21 upgrade, the user (you) will also need to upgrade [`ngx-translate`](https://ngx-translate.org/) to its latest version 17.x. diff --git a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts index 817be2f5b4..7b1d88e1bf 100644 --- a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts +++ b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts @@ -52,6 +52,7 @@ export const routes: Routes = [ { path: 'example48', loadComponent: () => import('./examples/example48.component').then((m) => m.Example48Component) }, { path: 'example49', loadComponent: () => import('./examples/example49.component').then((m) => m.Example49Component) }, { path: 'example50', loadComponent: () => import('./examples/example50.component').then((m) => m.Example50Component) }, + { path: 'example51', loadComponent: () => import('./examples/example51.component').then((m) => m.Example51Component) }, { path: '', redirectTo: '/example34', pathMatch: 'full' }, { path: '**', redirectTo: '/example34', pathMatch: 'full' }, ]; diff --git a/frameworks/angular-slickgrid/src/demos/app.component.html b/frameworks/angular-slickgrid/src/demos/app.component.html index 65cf5ad3ab..d952e76aba 100644 --- a/frameworks/angular-slickgrid/src/demos/app.component.html +++ b/frameworks/angular-slickgrid/src/demos/app.component.html @@ -190,6 +190,9 @@ + diff --git a/frameworks/angular-slickgrid/src/demos/examples/custom-angularComponentEditor.ts b/frameworks/angular-slickgrid/src/demos/examples/custom-angularComponentEditor.ts index c7a57a6bf3..b6ecc73d0d 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/custom-angularComponentEditor.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/custom-angularComponentEditor.ts @@ -32,7 +32,7 @@ export class CustomAngularComponentEditor implements Editor { grid: SlickGrid; constructor(private args: any) { - this.grid = args && args.grid; + this.grid = args?.grid; this.init(); } diff --git a/frameworks/angular-slickgrid/src/demos/examples/example02.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example02.component.ts index b97686af07..760674f53f 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example02.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example02.component.ts @@ -133,7 +133,7 @@ export class Example2Component implements OnInit { minWidth: 100, formatter: customEnableButtonFormatter, onCellClick: (e, args) => { - this.toggleCompletedProperty(args && args.dataContext); + this.toggleCompletedProperty(args?.dataContext); }, }, ]; diff --git a/frameworks/angular-slickgrid/src/demos/examples/example03.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example03.component.ts index 70943907ba..70e9768efe 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example03.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example03.component.ts @@ -37,7 +37,7 @@ const URL_COUNTRY_NAMES = 'assets/data/country_names.json'; const myCustomTitleValidator: EditorValidator = (value: any, _args?: EditorArguments) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it /* - const grid = args && args.grid; + const grid = args?.grid; const gridOptions = (grid?.getOptions() ?? {}) as GridOption; const translate = gridOptions.i18n; const columnEditor = args?.column?.editor; diff --git a/frameworks/angular-slickgrid/src/demos/examples/example04.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example04.component.ts index 985206a15c..2ce484fe97 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example04.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example04.component.ts @@ -306,12 +306,12 @@ export class Example4Component implements OnInit { } refreshMetrics(e: Event, args: any) { - if (args && args.current >= 0) { + if (args?.current >= 0) { setTimeout(() => { this.metrics = { startTime: new Date(), endTime: new Date(), - itemCount: (args && args.current) || 0, + itemCount: args?.current || 0, totalItemCount: this.dataset.length || 0, }; }); diff --git a/frameworks/angular-slickgrid/src/demos/examples/example23.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example23.component.ts index 3824b954bc..56f0d7e98c 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example23.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example23.component.ts @@ -265,11 +265,11 @@ export class Example23Component implements OnInit, OnDestroy { } refreshMetrics(_e: Event, args: any) { - if (args && args.current >= 0) { + if (args?.current >= 0) { setTimeout(() => { this.metrics = { startTime: new Date(), - itemCount: (args && args.current) || 0, + itemCount: args?.current || 0, totalItemCount: this.dataset.length || 0, }; }); diff --git a/frameworks/angular-slickgrid/src/demos/examples/example24.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example24.component.ts index 38df75e50c..df7c922056 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example24.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example24.component.ts @@ -358,7 +358,7 @@ export class Example24Component implements OnInit, OnDestroy { onCommand: (e, args) => this.executeCommand(e, args), onOptionSelected: (e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && 'completed' in dataContext) { dataContext.completed = args.item.option; this.angularGrid.gridService.updateItem(dataContext); @@ -437,7 +437,7 @@ export class Example24Component implements OnInit, OnDestroy { // optionally and conditionally define when the the menu is usable, // this should be used with a custom formatter to show/hide/disable the menu menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return dataContext.id < 21; // say we want to display the menu only from Task 0 to 20 }, // which column to show the command list? when not defined it will be shown over all columns @@ -468,7 +468,7 @@ export class Example24Component implements OnInit, OnDestroy { }, // only show command to 'Help' when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -534,7 +534,7 @@ export class Example24Component implements OnInit, OnDestroy { textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, // you can use the 'action' callback and/or subscribe to the 'onCallback' event, they both have the same arguments @@ -556,7 +556,7 @@ export class Example24Component implements OnInit, OnDestroy { disabled: true, // only shown when the task is Not Completed itemVisibilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -587,7 +587,7 @@ export class Example24Component implements OnInit, OnDestroy { // subscribe to Context Menu onOptionSelected event (or use the action callback on each option) onOptionSelected: (_e, args) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if ('priority' in dataContext) { dataContext.priority = args.item.option; this.angularGrid.gridService.updateItem(dataContext); diff --git a/frameworks/angular-slickgrid/src/demos/examples/example33.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example33.component.ts index af4e7bd6e0..90c287c5f7 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example33.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example33.component.ts @@ -464,7 +464,7 @@ export class Example33Component implements OnInit { onCommand: (e, args) => this.executeCommand(e, args), onOptionSelected: (_e, args) => { // change "Completed" property with new option selected from the Cell Menu - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && 'completed' in dataContext) { dataContext.completed = args.item.option; this.angularGrid.gridService.updateItem(dataContext); diff --git a/frameworks/angular-slickgrid/src/demos/examples/example51.component.html b/frameworks/angular-slickgrid/src/demos/examples/example51.component.html new file mode 100644 index 0000000000..6d57c9cbcf --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example51.component.html @@ -0,0 +1,62 @@ +

+ Example 51: Menus with Slots + + + code + + + +

+ +
+
+ + 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): +

+

+ Note: The demo focuses on the custom rendering capability via slotRenderer and + 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/frameworks/angular-slickgrid/src/demos/examples/example51.component.scss b/frameworks/angular-slickgrid/src/demos/examples/example51.component.scss new file mode 100644 index 0000000000..539ad2ecd2 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example51.component.scss @@ -0,0 +1,144 @@ +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-tooltip-background-color: #4c4c4c; + --slick-tooltip-color: #fff; + --slick-tooltip-font-size: 14px; + .slick-cell-menu, + .slick-context-menu, + .slick-grid-menu, + .slick-header-menu { + .slick-menu-item:hover:not(.slick-menu-item-disabled) { + color: #0a34b5; + } + } + .slick-menu-footer { + padding: 4px 6px; + border-top: 1px solid #c0c0c0; + } +} + +kbd { + background-color: #eee; + color: #202020; +} +.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/frameworks/angular-slickgrid/src/demos/examples/example51.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example51.component.ts new file mode 100644 index 0000000000..6d3d2dc823 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example51.component.ts @@ -0,0 +1,614 @@ +import { Component, ViewEncapsulation, type OnInit } from '@angular/core'; +import { format as tempoFormat } from '@formkit/tempo'; +import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; +import { + Aggregators, + AngularSlickgridComponent, + createDomElement, + Filters, + Formatters, + SortComparers, + SortDirectionNumber, + type AngularGridInstance, + type Column, + type GridOption, + type Grouping, + type MenuCommandItem, +} from '../../library'; + +const NB_ITEMS = 2000; + +interface ReportItem { + id: number; + title: string; + duration: number; + cost: number; + percentComplete: number; + start: Date; + finish: Date; + action?: string; +} + +@Component({ + templateUrl: './example51.component.html', + styleUrls: ['example51.component.scss'], + imports: [AngularSlickgridComponent], + encapsulation: ViewEncapsulation.None, +}) +export class Example51Component implements OnInit { + angularGrid!: AngularGridInstance; + columnDefinitions: Column[] = []; + gridOptions!: GridOption; + dataset: ReportItem[] = []; + hideSubTitle = false; + + angularGridReady(angularGrid: AngularGridInstance) { + this.angularGrid = angularGrid; + } + + ngOnInit(): void { + this.prepareGrid(); + // mock some data (different in each dataset) + this.dataset = this.loadData(NB_ITEMS); + } + + prepareGrid() { + 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 (can be HTML string or native DOM elements) + slotRenderer: (cmdItem) => ` + + `, + }, + { + command: 'sort-desc', + title: 'Sort Descending', + positionOrder: 51, + // 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; + }, + }, + ], + }, + }, + }, + { + 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: () => ` + + `, + }, + { 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: () => ` + + `, + }, + ], + }, + }, + }, + { + 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: (cmdItem) => { + // 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: cmdItem.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('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')}`; + containerDiv.style.color = 'white'; + containerDiv.querySelector('.key-hint')!.style.color = 'black'; + }); + containerDiv.addEventListener('mouseout', () => { + 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(); + }); + + return containerDiv; + }, + action: () => { + alert('Custom export action triggered!'); + }, + }, + { divider: true, command: '' }, + { + command: 'filter-column', + title: 'Filter Column', + // Slot renderer with status indicator and beta badge + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + id: 'percentComplete', + name: '% Complete', + field: 'percentComplete', + sortable: true, + filterable: true, + type: 'number', + filter: { model: Filters.slider, operator: '>=' }, + // Demo: Header Menu with Slot - showing interactive element (checkbox) + header: { + menu: { + commandItems: [ + { + command: 'recalc', + title: 'Recalculate', + iconCssClass: 'mdi mdi-refresh', + slotRenderer: () => ` + + `, + }, + ], + }, + }, + }, + { + 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 defaultMenuItemRenderer at menu level) + cellMenu: { + hideCloseButton: false, + commandTitle: 'Cell Actions', + // Demo: Menu-level default renderer that applies to all items unless overridden + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + commandItems: [ + { + command: 'copy-cell', + title: 'Copy Cell Value', + 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', + iconCssClass: 'mdi mdi-download', + action: (_e, args) => { + console.log('Export row:', args.dataContext); + alert(`Export row #${args.dataContext.id}`); + }, + }, + { + 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: (_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: '' }, + { + command: 'edit-row', + title: 'Edit Row', + // Individual slotRenderer overrides the defaultMenuItemRenderer + slotRenderer: (_item, args) => ` + + `, + action: (_e, args) => { + console.log('Edit row:', args.dataContext); + alert(`Edit row #${args.dataContext.id}`); + }, + }, + { + command: 'delete-row', + title: 'Delete Row', + 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.angularGrid?.gridService.deleteItemById(dataContext.id); + } + }, + }, + ], + }, + }, + ]; + + this.gridOptions = { + autoResize: { + container: '#demo-container', + }, + enableAutoResize: true, + enableCellNavigation: true, + enableFiltering: true, + enableSorting: true, + enableGrouping: true, + + // Header Menu with slots (already configured in columns above) + enableHeaderMenu: true, + headerMenu: { + // hideCommands: ['column-resize-by-content', 'clear-sort'], + + // Demo: Menu-level default renderer for all header menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Cell Menu with slots (configured in the Action column above) + enableCellMenu: true, + + // Context Menu with slot examples + enableContextMenu: true, + contextMenu: { + // hideCommands: ['clear-grouping', 'copy'], + + // build your command items list + // spread built-in commands and optionally filter/sort them however you want + commandListBuilder: (builtInItems) => { + // commandItems.sort((a, b) => (a === 'divider' || b === 'divider' ? 0 : a.title! > b.title! ? -1 : 1)); + return [ + // filter commands if you want + // ...builtInItems.filter((x) => x !== 'divider' && x.command !== 'copy' && x.command !== 'clear-grouping'), + { + command: 'edit-cell', + title: 'Edit Cell', + // Demo: Individual slotRenderer overrides the menu's defaultMenuItemRenderer + slotRenderer: (cmdItem) => { + // 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('mouseover', () => { + iconDiv.style.transform = 'rotate(15deg) scale(1.1)'; + iconDiv.style.boxShadow = '0 2px 8px rgba(0,200,83,0.4)'; + }); + containerDiv.addEventListener('mouseout', () => { + iconDiv.style.transform = 'rotate(0deg) scale(1)'; + iconDiv.style.boxShadow = 'none'; + }); + + return containerDiv; + }, + action: () => alert('Edit cell'), + }, + ...builtInItems, + { 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'), + }, + ] as Array; + }, + // Demo: Menu-level default renderer for context menu items + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + }, + + // Grid Menu with slot examples (demonstrating defaultMenuItemRenderer at menu level) + enableGridMenu: true, + gridMenu: { + // hideCommands: ['toggle-preheader', 'toggle-filter'], + + // Demo: Menu-level default renderer that applies to all items (can be overridden per item with slotRenderer) + defaultMenuItemRenderer: (cmdItem) => { + return ` + + `; + }, + 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 defaultMenuItemRenderer for this item + slotRenderer: (cmdItem) => ` + + `, + action: () => alert('Export to CSV'), + }, + { + 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'), + }, + ] as Array; + }, + }, + + // tooltip plugin + externalResources: [new SlickCustomTooltip()], + customTooltip: { + observeAllTooltips: true, + }, + }; + } + + clearGrouping() { + this.angularGrid?.dataView?.setGrouping([]); + } + + collapseAllGroups() { + this.angularGrid?.dataView?.collapseAllGroups(); + } + + expandAllGroups() { + this.angularGrid?.dataView?.expandAllGroups(); + } + + groupByDuration() { + // you need to manually add the sort icon(s) in UI + this.angularGrid?.slickGrid?.setSortColumns([{ columnId: 'duration', sortAsc: true }]); + this.angularGrid?.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.angularGrid?.slickGrid?.invalidate(); // invalidate all rows and re-render + } + + 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; + } + + toggleSubTitle() { + this.hideSubTitle = !this.hideSubTitle; + const action = this.hideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + this.angularGrid.resizerService.resizeGrid(0); + } +} diff --git a/frameworks/angular-slickgrid/test/cypress/e2e/example51.cy.ts b/frameworks/angular-slickgrid/test/cypress/e2e/example51.cy.ts new file mode 100644 index 0000000000..80e5148c97 --- /dev/null +++ b/frameworks/angular-slickgrid/test/cypress/e2e/example51.cy.ts @@ -0,0 +1,382 @@ +import { format } from '@formkit/tempo'; + +describe('Example 51 - Menus with Slots', () => { + const fullTitles = ['Title', 'Duration', 'Start', 'Finish', 'Cost', '% Complete', 'Action']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example51`); + cy.get('h2').should('contain', 'Example 51: Menus with Slots'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid51') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should open Context Menu hover "Duration" column and expect built-in and custom items listed in specific order', () => { + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + // 1st item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.edit-cell-icon').contains('✎'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Edit Cell'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.edit-cell').contains('F2'); + + // icon should rotate while hovering + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.wait(175); // wait for rotation + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .edit-cell-icon') + .invoke('css', 'transform') // Get the transform property + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'rotate').then((rotationAngle) => { + expect(rotationAngle).to.approximately(13, 15); // 15 degrees rotation + }); + }); + + // 2nd item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('i.mdi-content-copy').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item').find('span.menu-item-label').contains('Copy'); + + // 3rd item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(2)').should('have.class', 'slick-menu-item-divider'); + + // 4th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Clear all Grouping'); + + // 5th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item').find('i.mdi-arrow-collapse').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('span.menu-item-label') + .contains('Collapse all Groups'); + + // 6th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.class', 'slick-menu-item-disabled'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('i.mdi-arrow-expand').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Expand all Groups'); + + // 7th item - divider + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(6)').should('have.class', 'slick-menu-item-divider'); + + // 8th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Export'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(7)').find('.sub-item-chevron').should('exist'); + + // 9th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(8)').should('have.class', 'slick-menu-item-divider'); + + // 10th item + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('i.mdi-delete.text-danger') + .should('exist'); + cy.get('.slick-context-menu .slick-menu-command-list .slick-menu-item:nth(9) .menu-item') + .find('span.menu-item-label') + .contains('Delete Row'); + }); + + it('should open Export->Excel context sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('[data-row="0"] > .slick-cell:nth(2)').should('contain', '0'); + cy.get('[data-row="0"] > .slick-cell:nth(2)').rightclick({ force: true }); + + cy.get('.slick-context-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-context-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Header Menu from the "Title" column and expect some commands to have keyboard hints on the right side', () => { + cy.get('.slick-header-column:nth(0)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(0)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('kbd.key-hint').contains('Alt+↑'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('kbd.key-hint').contains('Alt+↓'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Duration" column and expect some commands to have tags on the right side', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover', { force: true }); + cy.get('.slick-header-column:nth(1)').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('i.mdi-arrow-expand-horizontal') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('span.menu-item-label') + .contains('Resize by Content'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('span.key-hint.danger').contains('NEW'); + + // 2nd item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('i.mdi-sort-ascending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('span.menu-item-label') + .contains('Sort Ascending'); + + // 4th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-sort-descending').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item') + .find('span.menu-item-label') + .contains('Sort Descending'); + + // 5th item - divider + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('i.mdi-filter-remove-outline') + .should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('span.menu-item-label') + .contains('Remove Filter'); + + // 7th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('i.mdi-sort-variant-off').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('span.menu-item-label') + .contains('Remove Sort'); + + // 8th item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item').find('i.mdi-close').should('exist'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(7) .menu-item') + .find('span.menu-item-label') + .contains('Hide Column'); + }); + + it('should open Header Menu from the "Cost" column and expect first item to have a dynamic tooltip timestamp when hovering', () => { + cy.get('#grid51').find('.slick-header-column:nth(4)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + // 1st item + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.advanced-export-icon').contains('📊'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').contains('Advanced Export'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('kbd.key-hint').contains('Ctrl+E'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgba(0, 0, 0, 0)' + ); + + // icon should scale up + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseover'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item .advanced-export-icon') + .invoke('css', 'transform') + .then((cssTransform) => { + const transformValue = cssTransform as unknown as string; // Cast to string + cy.getTransformValue(transformValue, true, 'scale').then((scaleValue) => { + expect(scaleValue).to.be.approximately(1.1, 1.15); // Check the scale value if applied + }); + }); + + const today = format(new Date(), 'YYYY-MM-DD'); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').should( + 'have.css', + 'background-color', + 'rgb(133, 70, 133)' + ); + cy.get('.slick-custom-tooltip').contains(`📈 Export timestamp: ${today}`); + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(0)').trigger('mouseout'); + cy.get('.slick-custom-tooltip').should('not.exist'); + cy.get('body').click(); + }); + + it('should open Action Menu from last column "Action" column and expect custom items listed in specific order', () => { + cy.get('[data-row="1"] > .slick-cell:nth(6)').click(); + cy.get('.slick-command-header.with-title.with-close').contains('Cell Actions'); + + // 1st item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item').find('.mdi-content-copy').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Copy Cell Value'); + + // 2nd item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(1)').should('have.class', 'slick-menu-item-divider'); + + // 3rd item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').find('.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Export Row'); + + // 4th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('i.mdi-download').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').find('span.menu-item-label').contains('Export'); + + // 5th item - divider + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(4)').should('have.class', 'slick-menu-item-divider'); + + // 6th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('.edit-cell-icon').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item .edit-cell-icon').contains('✎'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(5)').should('have.css', 'background-color', 'rgba(0, 0, 0, 0)'); + + // 7th item + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('.mdi-delete.text-danger').should('exist'); + cy.get('.slick-cell-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Delete Row'); + }); + + it('should open Export->Excel cell sub-menu', () => { + const subCommands1 = ['Export as Excel', 'Export as CSV', 'Export as PDF']; + + const stub = cy.stub(); + cy.on('window:alert', stub); + + cy.get('.slick-cell-menu.slick-menu-level-0 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains(/^Export$/) + .trigger('mouseover'); + + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .should('exist') + .find('.slick-menu-item .menu-item') + .each(($command, index) => expect($command.text()).to.contain(subCommands1[index])); + + // click different sub-menu + cy.get('.slick-cell-menu.slick-menu-level-1 .slick-menu-command-list') + .find('.slick-menu-item .menu-item') + .contains('Export as Excel') + .should('exist') + .click() + .then(() => expect(stub.getCall(0)).to.be.calledWith('Export row #1 to Excel')); + + cy.get('.slick-submenu').should('have.length', 0); + }); + + it('should open Grid Menu and expect built-in commands first then custom items listed in specific order', () => { + cy.get('.slick-grid-menu-button.mdi-menu').click(); + + // 1st item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item') + .find('.mdi-filter-remove-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(0) .menu-item span').contains('Clear all Filters'); + + // 2nd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item') + .find('.mdi-sort-variant-off.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(1) .menu-item span').contains('Clear all Sorting'); + + // 3rd item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item') + .find('.mdi-flip-vertical.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item span').contains('Toggle Filter Row'); + + // 4th item - divider + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(3)').should('have.class', 'slick-menu-item-divider'); + + // 5th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item') + .find('.mdi-file-excel-outline.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(4) .menu-item span').contains('Export to Excel'); + + // 6th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item') + .find('.mdi-download.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item span').contains('Export to CSV'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(5) .menu-item').find('span.key-hint.warn').contains('CUSTOM'); + + // 7th item + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item') + .find('.mdi-refresh.menu-item-icon') + .should('exist'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item span').contains('Refresh Data'); + cy.get('.slick-grid-menu .slick-menu-command-list .slick-menu-item:nth(6) .menu-item').find('kbd.key-hint').contains('F5'); + }); + + it('should sort ascending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(2) .menu-item').should('contain', 'Sort Ascending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '0'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '0'); + }); + + it('should sort descending "Duration" even though the header menu item was override without an action callback', () => { + cy.get('.slick-header-column:nth(1)').trigger('mouseover').children('.slick-header-menu-button').invoke('show').click(); + + cy.get('.slick-header-menu .slick-menu-command-list .slick-menu-item:nth(3) .menu-item').should('contain', 'Sort Descending').click(); + + cy.get('[data-row=0]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=1]').children('.slick-cell:nth(1)').should('contain', '100'); + cy.get('[data-row=2]').children('.slick-cell:nth(1)').should('contain', '100'); + }); +}); diff --git a/frameworks/angular-slickgrid/test/cypress/support/commands.ts b/frameworks/angular-slickgrid/test/cypress/support/commands.ts index 353157c927..5b395aa95c 100644 --- a/frameworks/angular-slickgrid/test/cypress/support/commands.ts +++ b/frameworks/angular-slickgrid/test/cypress/support/commands.ts @@ -46,6 +46,7 @@ declare global { ): Chainable>; saveLocalStorage: () => void; restoreLocalStorage: () => void; + getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; } } } @@ -86,3 +87,35 @@ Cypress.Commands.add('getNthCell', (row, nthCol, viewport = 'topLeft', { parentS `${parentSelector} ${canvasSelectorX}${canvasSelectorY} [style="transform: translateY(${row * rowHeight}px);"] > .slick-cell:nth(${nthCol})` ); }); + +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/frameworks/aurelia-slickgrid/docs/column-functionalities/cell-menu.md b/frameworks/aurelia-slickgrid/docs/column-functionalities/cell-menu.md index fe68977b51..5d6810e225 100644 --- a/frameworks/aurelia-slickgrid/docs/column-functionalities/cell-menu.md +++ b/frameworks/aurelia-slickgrid/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 `defaultMenuItemRenderer` 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 @@ -152,10 +157,7 @@ For example, say we want the Cell Menu to only be available on the first 20 rows this.columnDefinitions = [ { id: 'action', field: 'action', name: 'Action', cellMenu: { - menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; - return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 - }, + menuUsabilityOverride: (args) => args?.dataContext.id < 21, // say we want to display the menu only from Task 0 to 20 } } ]; @@ -167,12 +169,13 @@ this.columnDefinitions = [ { id: 'action', field: 'action', name: 'Action', cellMenu: { optionItems: [ - { - option: 0, title: 'n/a', textCssClass: 'italic', - // only enable this option when the task is Not Completed - itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; - return !dataContext.completed; + { + option: 0, title: 'n/a', textCssClass: 'italic', + // only enable this option when the task is Not Completed + itemUsabilityOverride: (args) => { + const dataContext = args?.dataContext; + return !dataContext.completed; + }, }, { option: 1, iconCssClass: 'mdi mdi-star-outline yellow', title: 'Low' }, { option: 2, iconCssClass: 'mdi mdi-star orange', title: 'Medium' }, diff --git a/frameworks/aurelia-slickgrid/docs/column-functionalities/editors.md b/frameworks/aurelia-slickgrid/docs/column-functionalities/editors.md index 4d3b058060..28217c5797 100644 --- a/frameworks/aurelia-slickgrid/docs/column-functionalities/editors.md +++ b/frameworks/aurelia-slickgrid/docs/column-functionalities/editors.md @@ -231,7 +231,7 @@ this.columnDefinitions = [ ``` ### Editor Options (`MultipleSelectOption` interface) -All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts editor: { @@ -351,8 +351,8 @@ this.columnDefinitions = [ ]; ``` -### `multiple-select.js` Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### `multiple-select-vanilla` Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit Aurelia-SlickGrid needs, which is why it points to `aurelia-slickgrid/lib` folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -378,7 +378,7 @@ this.columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } @@ -449,8 +449,8 @@ So if we take all of these informations and we want to create our own Custom Edi ```ts const myCustomTitleValidator: EditorValidator = (value: any, args: EditorArgs) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it - const grid = args && args.grid; - const gridOptions = (grid && grid.getOptions) ? grid.getOptions() : {}; + const grid = args.grid; + const gridOptions = grid.getOptions() : {}; const i18n = gridOptions.i18n; if (value == null || value === undefined || !value.length) { diff --git a/frameworks/aurelia-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md b/frameworks/aurelia-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md index 493ea40de3..c89db6de90 100644 --- a/frameworks/aurelia-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md +++ b/frameworks/aurelia-slickgrid/docs/column-functionalities/editors/select-dropdown-editor.md @@ -6,11 +6,11 @@ - [Collection Label Prefix/Suffix](#collection-label-prefixsuffix) - [Collection Label Render HTML](#collection-label-render-html) - [Collection Change Watch](#collection-watch) - - [`multiple-select.js` Options](#multiple-selectjs-options) + - [`multiple-select-vanilla` Options](#multiple-selectjs-options) - See the [Editors - Wiki](../Editors.md) for more general info about Editors (validators, event handlers, ...) ## Select Editors -The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select](https://github.com/ghiscoding/multiple-select-adapted/blob/master/src/multiple-select.js) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). +The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). @@ -37,7 +37,7 @@ this.columnDefinitions = [ ``` ### Editor Options (`MultipleSelectOption` interface) -All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts editor: { @@ -191,7 +191,7 @@ this.columnDefinitions = [ ``` ### `multiple-select-vanilla.js` Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/lib` folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -217,7 +217,7 @@ this.columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } diff --git a/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/custom-filter.md b/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/custom-filter.md index b6a752eff8..0776a2acc5 100644 --- a/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/custom-filter.md +++ b/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/custom-filter.md @@ -13,7 +13,7 @@ You can also create your own Custom Filter with any html/css you want and/or jQu - as mentioned in the description, only html/css and/or jQuery libraries are supported. - this mainly mean that Aurelia templates (Views) are not supported (feel free to contribute). - SlickGrid uses `table-cell` as CSS for it to display a consistent height for each rows (this keeps the same row height/line-height to always be the same). - - all this to say that you might be in a situation were your filter shows in the back of the grid. The best approach to overcome this is to use a modal if you can or if the library support `append to body container`. For example, you can see that `multiple-select.js` support a `container` and is needed for the filter to work as can be seen in the `multipleSelectFilter.ts` + - all this to say that you might be in a situation were your filter shows in the back of the grid. The best approach to overcome this is to use a modal if you can or if the library support `append to body container`. For example, you can see that `multiple-select-vanilla` support a `container` and is needed for the filter to work as can be seen in the `multipleSelectFilter.ts` ### How to use Custom Filter? 1. You first need to create a `class` using the [Filter interface](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/interfaces/filter.interface.ts). Make sure to create all necessary public properties and functions. diff --git a/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/select-filter.md b/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/select-filter.md index 9e42c14bed..e879ef9c0f 100644 --- a/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/select-filter.md +++ b/frameworks/aurelia-slickgrid/docs/column-functionalities/filters/select-filter.md @@ -16,7 +16,7 @@ - [Collection Async Load](#collection-async-load) - [Collection Lazy Load](#collection-lazy-load) - [Collection Watch](#collection-watch) -- [`multiple-select.js` Options](#multiple-selectjs-options) +- [`multiple-select-vanilla` Options](#multiple-selectjs-options) - [Filter Options (`MultipleSelectOption` interface)](#filter-options-multipleselectoption-interface) - [Display shorter selected label text](#display-shorter-selected-label-text) - [Query against a different field](#query-against-another-field-property) @@ -36,7 +36,7 @@ Multiple Select (dropdown) filter is useful when we want to filter the grid 1 or We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). #### Note -For this filter to work you will need to add [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) to your project. This is a customized version of the original (thought all the original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) +For this filter to work you will need to add [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) to your project. This is a customized version of the original (thought all the original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. - `okButtonText` was also added for locale (i18n) - `offsetLeft` option was added to make it possible to offset the dropdown. By default it is set to 0 and is aligned to the left of the select element. This option is particularly helpful when used as the last right column, not to fall off the screen. @@ -579,7 +579,7 @@ this.columnDefinitions = [ ``` ### Filter Options (`MultipleSelectOption` interface) -All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts filter: { @@ -602,8 +602,8 @@ this.gridOptions = { } ``` -### Multiple-select.js Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### multiple-select-vanilla Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why we are using a fork [ghiscoding/multiple-select-modified](https://github.com/ghiscoding/multiple-select-modified) folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -632,7 +632,7 @@ this.columnDefinitions = [ model: Filters.singleSelect, // previously known as `filterOptions` for < 9.0 options: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } as MultipleSelectOption diff --git a/frameworks/aurelia-slickgrid/docs/grid-functionalities/composite-editor-modal.md b/frameworks/aurelia-slickgrid/docs/grid-functionalities/composite-editor-modal.md index fbd5d12c06..c7f58c74d6 100644 --- a/frameworks/aurelia-slickgrid/docs/grid-functionalities/composite-editor-modal.md +++ b/frameworks/aurelia-slickgrid/docs/grid-functionalities/composite-editor-modal.md @@ -476,7 +476,7 @@ export class GridExample { // you can also change some editor options // not all Editors supports this functionality, so far only these Editors are supported: AutoComplete, Date, Single/Multiple Select if (columnDef.id === 'completed') { - this.compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select.js, show filter in dropdown + this.compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select-vanilla, show filter in dropdown this.compositeEditorInstance.changeFormEditorOption('product', 'minLength', 3); // autocomplete, change minLength char to type this.compositeEditorInstance.changeFormEditorOption('finish', 'displayDateMin', 'today'); // calendar picker, change minDate to today } diff --git a/frameworks/aurelia-slickgrid/docs/grid-functionalities/context-menu.md b/frameworks/aurelia-slickgrid/docs/grid-functionalities/context-menu.md index 2e77bed499..7906c2b194 100644 --- a/frameworks/aurelia-slickgrid/docs/grid-functionalities/context-menu.md +++ b/frameworks/aurelia-slickgrid/docs/grid-functionalities/context-menu.md @@ -23,7 +23,7 @@ This extensions is wrapped around the new SlickGrid Plugin **SlickContextMenu** ### Default Usage Technically, the Context Menu is enabled by default (copy, export) and so you don't have anything to do to enjoy it (you could disable it at any time). However, if you want to customize the content of the Context Menu, then continue reading. You can customize the menu with 2 different lists, Commands and/or Options, they can be used separately or at the same time. Also note that even though the code shown below makes a separation between the Commands and Options, you can mix them in the same Context Menu. -#### with Commands +#### with Commands (Static) ```ts this.gridOptions = { @@ -63,6 +63,98 @@ this.gridOptions = { }; ``` +#### with Commands (Dynamic Builder) +For advanced scenarios where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +```ts +this.gridOptions = { + enableContextMenu: true, + contextMenu: { + commandListBuilder: (builtInItems) => { + // Example: Add custom commands after built-in ones + return [ + ...builtInItems, + 'divider', + { + command: 'delete-row', + title: 'Delete Row', + iconCssClass: 'mdi mdi-delete text-danger', + action: (e, args) => { + if (confirm(`Delete row ${args.dataContext.id}?`)) { + this.gridService.deleteItem(args.dataContext); + } + } + }, + { + command: 'duplicate-row', + title: 'Duplicate Row', + iconCssClass: 'mdi mdi-content-duplicate', + action: (e, args) => { + const newItem = { ...args.dataContext, id: this.generateNewId() }; + this.gridService.addItem(newItem); + } + } + ]; + }, + onCommand: (e, args) => { + // Handle commands here if not using action callbacks + console.log('Command:', args.command); + } + } +}; +``` + +**Example: Filter commands based on row data** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + // You can't access row data here, but you can filter/modify built-in items + // Use itemUsabilityOverride or itemVisibilityOverride for row-specific logic + + // Only show export commands + return builtInItems.filter(item => + item === 'divider' || item.command?.includes('export') + ); + } +} +``` + +**Example: Sort and reorganize commands** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + const customFirst = [ + { + command: 'edit', + title: 'Edit Row', + iconCssClass: 'mdi mdi-pencil', + positionOrder: 0 + } + ]; + + // Sort built-in commands by positionOrder + const sorted = [...builtInItems].sort((a, b) => { + if (a === 'divider' || b === 'divider') return 0; + return (a.positionOrder || 50) - (b.positionOrder || 50); + }); + + return [...customFirst, 'divider', ...sorted]; + } +} +``` + +**When to use `commandListBuilder` vs `commandItems`:** +- Use `commandItems` for static command lists +- Use `commandListBuilder` when you need to: + - Append/prepend to built-in commands + - Filter or modify commands dynamically + - Sort or reorder the final command list + - Have full control over what gets rendered + +**Note:** Typically use `commandListBuilder` **instead of** `commandItems`, not both together. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### with Options That is when you want to define a list of Options (only 1 list) that the user can choose from and once is selected we would do something (for example change the value of a cell in the grid). @@ -79,7 +171,7 @@ this.gridOptions = { // subscribe to Context Menu onOptionSelected event (or use the "action" callback on each option) onOptionSelected: (e, args) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('priority')) { dataContext.priority = args.item.option; this.sgb.gridService.updateItem(dataContext); @@ -139,7 +231,7 @@ For example, say we want the Context Menu to only be available on the first 20 r ```ts contextMenu: { menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 }, }, @@ -153,7 +245,7 @@ contextMenu: { option: 0, title: 'n/a', textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -177,6 +269,11 @@ contextMenu: { } ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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. + ### Show Menu only over Certain Columns Say you want to show the Context Menu only when the user is over certain columns of the grid. For that, you could use the `commandShownOverColumnIds` (or `optionShownOverColumnIds`) array, by default these arrays are empty and when that is the case then the menu will be accessible from any columns. So if we want to have the Context Menu available only over the first 2 columns, we would have an array of those 2 column ids. For example, the following would show the Context Menu everywhere except the last 2 columns (priority, action) since they are not part of the array. ```ts diff --git a/frameworks/aurelia-slickgrid/docs/grid-functionalities/grid-menu.md b/frameworks/aurelia-slickgrid/docs/grid-functionalities/grid-menu.md index 45b6ff831f..79ec2166a0 100644 --- a/frameworks/aurelia-slickgrid/docs/grid-functionalities/grid-menu.md +++ b/frameworks/aurelia-slickgrid/docs/grid-functionalities/grid-menu.md @@ -73,6 +73,11 @@ this.gridOptions = { }; ``` +#### Advanced: Dynamic Command List Builder +For more advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### Events There are multiple events/callback hooks which are accessible from the Grid Options - `onBeforeMenuShow` @@ -105,6 +110,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/6pac/SlickGrid/blob/master/controls/slick.gridmenu.js) 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 `defaultMenuItemRenderer` 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/frameworks/aurelia-slickgrid/docs/grid-functionalities/header-menu-header-buttons.md b/frameworks/aurelia-slickgrid/docs/grid-functionalities/header-menu-header-buttons.md index 04e75c5cf5..2a12bea3f8 100644 --- a/frameworks/aurelia-slickgrid/docs/grid-functionalities/header-menu-header-buttons.md +++ b/frameworks/aurelia-slickgrid/docs/grid-functionalities/header-menu-header-buttons.md @@ -20,7 +20,10 @@ The Header Menu also comes, by default, with a list of built-in custom commands - Sort Descending (you can hide it with `hideSortCommands: true`) - Hide Column (you can hide it with `hideColumnHideCommand: true`) -This section is called Custom Commands because you can also customize this section with your own commands. To do that, you need to fill in 2 properties (an array of `headerMenuItems` that will go under each column definition and define `onCommand` callbacks) in your Grid Options. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): +This section is called Custom Commands because you can also customize this section with your own commands. You can do this in two ways: using static command items or using a dynamic command list builder. + +#### Static Command Items +To add static commands, fill in an array of items in your column definition's `header.menu.commandItems`. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): ```ts this.gridOptions = { enableAutoResize: true, @@ -51,6 +54,100 @@ this.gridOptions = { } }; ``` + +#### Dynamic Command List Builder +For advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. This is executed **after** `commandItems` and is the **last call before rendering**. + +```ts +this.columnDefinitions = [ + { + id: 'title', + name: 'Title', + field: 'title', + header: { + menu: { + commandListBuilder: (builtInItems) => { + // Append custom commands to the built-in sort/hide commands + return [ + ...builtInItems, + 'divider', + { + command: 'freeze-column', + title: 'Freeze Column', + iconCssClass: 'mdi mdi-pin', + action: (e, args) => { + // Implement column freezing + console.log('Freeze column:', args.column.name); + } + } + ]; + } + } + } + } +]; +``` + +**Example: Conditional commands based on column type** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + const column = this; // column context + + // Add filtering option only for filterable columns + const extraCommands = []; + if (column.filterable !== false) { + extraCommands.push({ + command: 'clear-filter', + title: 'Clear Filter', + iconCssClass: 'mdi mdi-filter-remove', + action: (e, args) => { + this.filterService.clearFilterByColumnId(args.column.id); + } + }); + } + + return [...builtInItems, ...extraCommands]; + } + } +} +``` + +**Example: Remove sort commands, keep only custom ones** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + // Filter out sort commands + const filtered = builtInItems.filter(item => + item !== 'divider' && + !item.command?.includes('sort') + ); + + // Add custom commands + return [ + ...filtered, + { + command: 'custom-action', + title: 'Custom Action', + iconCssClass: 'mdi mdi-star', + action: (e, args) => alert('Custom: ' + args.column.name) + } + ]; + } + } +} +``` + +**When to use `commandListBuilder`:** +- You want to append/prepend items to built-in commands +- You need to filter or modify commands based on column properties +- You want to customize the command list per column dynamically +- You need full control over the final command list + +**Note:** Use `commandListBuilder` **instead of** `commandItems`, not both together. + #### Callback Hooks There are 2 callback hooks which are accessible in the Grid Options - `onBeforeMenuShow` @@ -58,6 +155,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 `defaultMenuItemRenderer` 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/frameworks/aurelia-slickgrid/docs/menu-slots.md b/frameworks/aurelia-slickgrid/docs/menu-slots.md new file mode 100644 index 0000000000..14b9f9bb47 --- /dev/null +++ b/frameworks/aurelia-slickgrid/docs/menu-slots.md @@ -0,0 +1,524 @@ +## 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 `defaultMenuItemRenderer` at the menu level. + +> **Note:** This documentation covers **how menu items are rendered** (visual presentation). If you need to **dynamically modify which commands appear** in the menu (filtering, sorting, adding/removing items), see the `commandListBuilder` callback documented in [Grid Menu](grid-functionalities/grid-menu.md), [Context Menu](grid-functionalities/context-menu.md), or [Header Menu](grid-functionalities/header-menu-header-buttons.md). + +### TypeScript Tip: Type Inference with commandListBuilder + +When using `commandListBuilder` to add custom menu items with slotRenderer callbacks, **cast the return value to the appropriate type** to enable proper type parameters in callbacks: + +```typescript +contextMenu: { + commandListBuilder: (builtInItems) => { + return [ + ...builtInItems, + { + command: 'custom-action', + title: 'My Action', + slotRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + } + ] as Array; + } +} +``` + +Alternatively, if you only have built-in items and dividers, you can use the simpler cast: + +```typescript +return [...] as Array; +``` + +### 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?: (cmdItem: MenuItem, args: MenuCallbackArgs, event?: Event) => string | HTMLElement +``` + +- **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()`) + +### 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: (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 = 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; + } +}; +``` + +### Default Menu-Level Renderer + +Set a `defaultMenuItemRenderer` 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) + defaultMenuItemRenderer: (cmdItem, args) => { + return ` +
+ ${cmdItem.iconCssClass ? `` : ''} + ${cmdItem.title} +
+ `; + }, + commandItems: [ + { + command: 'action-1', + title: 'Action One', + iconCssClass: 'mdi mdi-check', + // This item uses defaultMenuItemRenderer + }, + { + command: 'custom', + title: 'Custom Item', + // This item overrides defaultMenuItemRenderer with its own slotRenderer + slotRenderer: () => ` +
+ Custom rendering overrides default +
+ ` + } + ] +}; +``` + +### Menu Types & Configuration + +The `slotRenderer` and `defaultMenuItemRenderer` work identically across all menu plugins: + +#### Header Menu +```typescript +const columnDef = { + id: 'name', + header: { + menu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'sort', + title: 'Sort', + slotRenderer: () => '
Custom sort
' + } + ] + } + } +}; +``` + +#### Cell Menu +```typescript +const columnDef = { + id: 'action', + cellMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'edit', + title: 'Edit', + slotRenderer: (cmdItem, args) => `
Edit row ${args.dataContext.id}
` + } + ] + } +}; +``` + +#### Context Menu +```typescript +const gridOptions = { + enableContextMenu: true, + contextMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'export', + title: 'Export', + slotRenderer: () => '
📊 Export data
' + } + ] + } +}; +``` + +#### Grid Menu +```typescript +const gridOptions = { + enableGridMenu: true, + gridMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.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: (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); + 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: (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); + 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: (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); + 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: (cmdItem, args) => ` +
+ + Edit Row #${args.dataContext?.id || 'N/A'} +
+ ` +} +``` + +#### 4. Add Interactive Elements +```typescript +{ + command: 'toggle-setting', + title: 'Auto Refresh', + 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) => { + // Prevent menu item click from firing when toggling checkbox + event?.stopPropagation?.(); + console.log('Auto refresh:', checkbox.checked); + }); + + const label = document.createElement('span'); + label.textContent = cmdItem.title; + + container.appendChild(label); + container.appendChild(checkbox); + return container; + } +} +``` + +#### 5. Add Badges and Status Labels +```typescript +{ + command: 'export-excel', + title: 'Export as Excel', + slotRenderer: (cmdItem, args) => ` +
+ + ${cmdItem.title} + RECOMMENDED +
+ ` +} +``` + +#### 6. Gradient and Styled Icons +```typescript +{ + command: 'advanced-export', + title: 'Advanced Export', + 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'; + 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 = cmdItem.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 `defaultMenuItemRenderer` +- **Built-in command preservation** - When overriding a built-in command (e.g., `sort-asc`, `sort-desc`, `hide`, etc.) with custom properties like `slotRenderer` or `iconCssClass`, if you don't provide an `action` callback, the library will automatically preserve and use the built-in action for that command. This means you can safely customize the appearance of built-in commands without losing their functionality. +- **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 `defaultMenuItemRenderer` 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: (cmdItem, 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/frameworks/aurelia-slickgrid/docs/migrations/migration-to-10.x.md b/frameworks/aurelia-slickgrid/docs/migrations/migration-to-10.x.md index e33e944d9a..99c5b21c51 100644 --- a/frameworks/aurelia-slickgrid/docs/migrations/migration-to-10.x.md +++ b/frameworks/aurelia-slickgrid/docs/migrations/migration-to-10.x.md @@ -1,4 +1,4 @@ -## Cleaner Code / Smaller Code ⚡ +## Simplification and Modernization ⚡ One of the biggest change of this release is to hide columns by using the `hidden` column property (now used by Column Picker, Grid Menu, etc...). Previously we were removing columns from the original columns array and we then called `setColumns()` to update the grid, but this meant that we had to keep references for all visible and non-visible columns. With this new release we now keep the full columns array at all time and instead we just change column(s) visibility via their `hidden` column properties by using `grid.updateColumnById('id', { hidden: true })` and finally we update the grid via `grid.updateColumns()`. What I'm trying to emphasis is that you should really stop using `grid.setColumns()` in v10+, and if you want to hide some columns when declaring the columns, then just update their `hidden` properties, see more details below... @@ -85,11 +85,30 @@ gridOptions = { }; ``` -### External Resources are now auto-enabled - This change does not require any code update from the end user, but it is a change that you should probably be aware of nonetheless. The reason I decided to implement this is because I often forget myself to enable the associated flag and typically if you wanted to load the resource, then it's most probably because you also want it enabled. So for example, if your register `ExcelExportService` then the library will now auto-enable the resource with its associated flag (which in this case is `enableExcelExport:true`)... unless you already disabled the flag (or enabled) yourself, if so then the internal assignment will simply be skipped and yours will prevail. Also just to be clear, the list of auto-enabled external resources is rather small, it will auto-enable the following resources: (ExcelExportService, PdfExportService, TextExportService, CompositeEditorComponent and RowDetailView). +### Menu with Commands + +All menu plugins (Cell Menu, Context Menu, Header Menu and Grid Menu) now have a new `commandListBuilder: (items) => items` which now allow you to filter/sort and maybe override built-in commands. With this new feature in place, I'm deprecating all `hide...` properties and also `positionOrder` since you can now do that with the builder. You could also use the `hideCommands` which accepts an array of built-in command names. This well remove huge amount of `hide...` properties (over 30) that keeps increasing anytime a new built-in command gets added (in other words, this will simplify maintenance for both you and me). + +These are currently just deprecations in v10.x but it's strongly recommended to start using the `commandListBuilder` and/or `hideCommands` and move away from the deprecated properties which will be removed in v11.x. For example if we want to hide some built-in commands: + +```diff +gridOptions = { + gridMenu: { +- hideExportCsvCommand: true, +- hideTogglePreHeaderCommand: true, + +// via command name(s) ++ hideCommands: ['export-csv', 'toggle-preheader'], + +// or via builder ++ commandListBuilder: (cmdItems) => [...cmdItems.filter(x => x !== 'divider' && x.command !== 'export-csv' && x.command !== 'toggle-preheader')] + } +} +``` + --- {% hint style="note" %} diff --git a/frameworks/aurelia-slickgrid/tsconfig.json b/frameworks/aurelia-slickgrid/tsconfig.json index 82191a3a37..201c1b1dc0 100644 --- a/frameworks/aurelia-slickgrid/tsconfig.json +++ b/frameworks/aurelia-slickgrid/tsconfig.json @@ -21,7 +21,6 @@ "skipLibCheck": true, "sourceMap": true, "newLine": "lf", - "downlevelIteration": true, "outDir": "dist" }, "include": ["src"], diff --git a/frameworks/slickgrid-react/docs/column-functionalities/cell-menu.md b/frameworks/slickgrid-react/docs/column-functionalities/cell-menu.md index ff0b52b027..b9e63d392a 100644 --- a/frameworks/slickgrid-react/docs/column-functionalities/cell-menu.md +++ b/frameworks/slickgrid-react/docs/column-functionalities/cell-menu.md @@ -142,6 +142,11 @@ const gridOptions = { }; ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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 @@ -155,10 +160,7 @@ const columnDefinitions = [ { id: 'action', field: 'action', name: 'Action', cellMenu: { - menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; - return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 - }, + menuUsabilityOverride: (args) => args?.dataContext.id < 21, // say we want to display the menu only from Task 0 to 20 } } ]; @@ -171,12 +173,13 @@ const columnDefinitions = [ id: 'action', field: 'action', name: 'Action', cellMenu: { optionItems: [ - { - option: 0, title: 'n/a', textCssClass: 'italic', - // only enable this option when the task is Not Completed - itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; - return !dataContext.completed; + { + option: 0, title: 'n/a', textCssClass: 'italic', + // only enable this option when the task is Not Completed + itemUsabilityOverride: (args) => { + const dataContext = args?.dataContext; + return !dataContext.completed; + }, }, { option: 1, iconCssClass: 'mdi mdi-star-outline yellow', title: 'Low' }, { option: 2, iconCssClass: 'mdi mdi-star orange', title: 'Medium' }, diff --git a/frameworks/slickgrid-react/docs/column-functionalities/editors.md b/frameworks/slickgrid-react/docs/column-functionalities/editors.md index fd6b4043f3..3a3a2a6789 100644 --- a/frameworks/slickgrid-react/docs/column-functionalities/editors.md +++ b/frameworks/slickgrid-react/docs/column-functionalities/editors.md @@ -13,7 +13,7 @@ - [Collection Async Load](#collection-async-load) - [Collection Label Prefix/Suffix](#collection-label-prefixsuffix) - [Collection Label Render HTML](#collection-label-render-html) - - [`multiple-select.js` Options](#multiple-selectjs-options) + - [`multiple-select-vanilla` Options](#multiple-selectjs-options) - [Editor Options](#editor-options) - [Validators](#validators) - [Custom Validator](#custom-validator) @@ -226,7 +226,7 @@ const columnDefinitions = [ ``` ### Editor Options (`MultipleSelectOption` interface) -All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/lib/src/interfaces/multipleSelectOption.interface.ts) and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/lib/src/interfaces/multipleSelectOption.interface.ts) and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```tsx editor: { @@ -349,8 +349,8 @@ const columnDefinitions = [ ]; ``` -### `multiple-select.js` Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### `multiple-select-vanilla` Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit slickgrid-react needs, which is why it points to `slickgrid-react/lib` folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -376,7 +376,7 @@ const columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } @@ -447,8 +447,8 @@ So if we take all of these informations and we want to create our own Custom Edi ```tsx const myCustomTitleValidator: EditorValidator = (value: any, args: EditorArgs) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it - const grid = args && args.grid; - const gridOptions = (grid && grid.getOptions) ? grid.getOptions() : {}; + const grid = args.grid; + const gridOptions = grid.getOptions() : {}; const i18n = gridOptions.i18n; if (value == null || value === undefined || !value.length) { diff --git a/frameworks/slickgrid-react/docs/column-functionalities/editors/select-dropdown-editor.md b/frameworks/slickgrid-react/docs/column-functionalities/editors/select-dropdown-editor.md index 9de1f4207e..a81c26710c 100644 --- a/frameworks/slickgrid-react/docs/column-functionalities/editors/select-dropdown-editor.md +++ b/frameworks/slickgrid-react/docs/column-functionalities/editors/select-dropdown-editor.md @@ -6,11 +6,11 @@ - [Collection Label Prefix/Suffix](#collection-label-prefixsuffix) - [Collection Label Render HTML](#collection-label-render-html) - [Collection Change Watch](#collection-watch) - - [`multiple-select.js` Options](#multiple-selectjs-options) + - [`multiple-select-vanilla` Options](#multiple-selectjs-options) - See the [Editors - Wiki](../Editors.md) for more general info about Editors (validators, event handlers, ...) ## Select Editors -The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select](https://github.com/ghiscoding/multiple-select-adapted/blob/master/src/multiple-select.js) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). +The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). @@ -37,7 +37,7 @@ const columnDefinitions = [ ``` ### Editor Options (`MultipleSelectOption` interface) -All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts editor: { @@ -191,7 +191,7 @@ const columnDefinitions = [ ``` ### `multiple-select-vanilla.js` Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/lib` folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -217,7 +217,7 @@ const columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } diff --git a/frameworks/slickgrid-react/docs/column-functionalities/filters/custom-filter.md b/frameworks/slickgrid-react/docs/column-functionalities/filters/custom-filter.md index 561137e42c..efab5a435e 100644 --- a/frameworks/slickgrid-react/docs/column-functionalities/filters/custom-filter.md +++ b/frameworks/slickgrid-react/docs/column-functionalities/filters/custom-filter.md @@ -12,7 +12,7 @@ You can also create your own Custom Filter with any html/css you want to use. Re - as mentioned in the description, only html/css and/or JS libraries are supported. - this mainly mean that React templates (Views) are not supported (feel free to contribute). - SlickGrid uses `table-cell` as CSS for it to display a consistent height for each rows (this keeps the same row height/line-height to always be the same). - - all this to say that you might be in a situation were your filter shows in the back of the grid. The best approach to overcome this is to use a modal if you can or if the library support `append to body container`. For example, you can see that `multiple-select.js` support a `container` and is needed for the filter to work as can be seen in the [multipleSelectFilter.ts](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/filters/multipleSelectFilter.ts#L26) + - all this to say that you might be in a situation were your filter shows in the back of the grid. The best approach to overcome this is to use a modal if you can or if the library support `append to body container`. For example, you can see that `multiple-select-vanilla` support a `container` and is needed for the filter to work as can be seen in the [multipleSelectFilter.ts](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/filters/multipleSelectFilter.ts#L26) ### How to use Custom Filter? 1. You first need to create a `class` using the [Filter interface](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/models/filter.interface.ts). Make sure to create all necessary public properties and functions. diff --git a/frameworks/slickgrid-react/docs/column-functionalities/filters/select-filter.md b/frameworks/slickgrid-react/docs/column-functionalities/filters/select-filter.md index 1a0ab4e6f1..37b65bf7cf 100644 --- a/frameworks/slickgrid-react/docs/column-functionalities/filters/select-filter.md +++ b/frameworks/slickgrid-react/docs/column-functionalities/filters/select-filter.md @@ -16,7 +16,7 @@ - [Collection Async Load](#collection-async-load) - [Collection Lazy Load](#collection-lazy-load) - [Collection Watch](#collection-watch) -- [`multiple-select.js` Options](#multiple-selectjs-options) +- [`multiple-select-vanilla` Options](#multiple-selectjs-options) - [Filter Options (`MultipleSelectOption` interface)](#filter-options-multipleselectoption-interface) - [Display shorter selected label text](#display-shorter-selected-label-text) - [Query against a different field](#query-against-another-field-property) @@ -36,7 +36,7 @@ Multiple Select (dropdown) filter is useful when we want to filter the grid 1 or We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). #### Note -For this filter to work you will need to add [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) to your project. This is a customized version of the original (thought all the original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) +For this filter to work you will need to add [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) to your project. This is a customized version of the original (thought all the original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. - `okButtonText` was also added for locale (i18n) - `offsetLeft` option was added to make it possible to offset the dropdown. By default it is set to 0 and is aligned to the left of the select element. This option is particularly helpful when used as the last right column, not to fall off the screen. @@ -601,7 +601,7 @@ const columnDefinitions = [ ``` ### Filter Options (`MultipleSelectOption` interface) -All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts filter: { @@ -624,8 +624,8 @@ const gridOptions = { } ``` -### Multiple-select.js Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### multiple-select-vanilla Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why we are using a fork [ghiscoding/multiple-select-modified](https://github.com/ghiscoding/multiple-select-modified) folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -653,7 +653,7 @@ const columnDefinitions = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Filters.singleSelect, options: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } as MultipleSelectOption diff --git a/frameworks/slickgrid-react/docs/grid-functionalities/composite-editor-modal.md b/frameworks/slickgrid-react/docs/grid-functionalities/composite-editor-modal.md index 7df59bd2c8..54497a33fc 100644 --- a/frameworks/slickgrid-react/docs/grid-functionalities/composite-editor-modal.md +++ b/frameworks/slickgrid-react/docs/grid-functionalities/composite-editor-modal.md @@ -481,7 +481,7 @@ const Example: React.FC = () => { // you can also change some editor options // not all Editors supports this functionality, so far only these Editors are supported: AutoComplete, Date, Single/Multiple Select if (columnDef.id === 'completed') { - compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select.js, show filter in dropdown + compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select-vanilla, show filter in dropdown compositeEditorInstance.changeFormEditorOption('product', 'minLength', 3); // autocomplete, change minLength char to type this.compositeEditorInstance.changeFormEditorOption('finish', 'displayDateMin', 'today'); // calendar picker, change minDate to today } diff --git a/frameworks/slickgrid-react/docs/grid-functionalities/context-menu.md b/frameworks/slickgrid-react/docs/grid-functionalities/context-menu.md index c1381ffc06..e07d7bb89d 100644 --- a/frameworks/slickgrid-react/docs/grid-functionalities/context-menu.md +++ b/frameworks/slickgrid-react/docs/grid-functionalities/context-menu.md @@ -23,7 +23,7 @@ This extensions is wrapped around the new SlickGrid Plugin **SlickContextMenu** ### Default Usage Technically, the Context Menu is enabled by default (copy, export) and so you don't have anything to do to enjoy it (you could disable it at any time). However, if you want to customize the content of the Context Menu, then continue reading. You can customize the menu with 2 different lists, Commands and/or Options, they can be used separately or at the same time. Also note that even though the code shown below makes a separation between the Commands and Options, you can mix them in the same Context Menu. -#### with Commands +#### with Commands (Static) ```ts const gridOptions = { @@ -63,6 +63,98 @@ const gridOptions = { }; ``` +#### with Commands (Dynamic Builder) +For advanced scenarios where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +```ts +this.gridOptions = { + enableContextMenu: true, + contextMenu: { + commandListBuilder: (builtInItems) => { + // Example: Add custom commands after built-in ones + return [ + ...builtInItems, + 'divider', + { + command: 'delete-row', + title: 'Delete Row', + iconCssClass: 'mdi mdi-delete text-danger', + action: (e, args) => { + if (confirm(`Delete row ${args.dataContext.id}?`)) { + this.gridService.deleteItem(args.dataContext); + } + } + }, + { + command: 'duplicate-row', + title: 'Duplicate Row', + iconCssClass: 'mdi mdi-content-duplicate', + action: (e, args) => { + const newItem = { ...args.dataContext, id: this.generateNewId() }; + this.gridService.addItem(newItem); + } + } + ]; + }, + onCommand: (e, args) => { + // Handle commands here if not using action callbacks + console.log('Command:', args.command); + } + } +}; +``` + +**Example: Filter commands based on row data** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + // You can't access row data here, but you can filter/modify built-in items + // Use itemUsabilityOverride or itemVisibilityOverride for row-specific logic + + // Only show export commands + return builtInItems.filter(item => + item === 'divider' || item.command?.includes('export') + ); + } +} +``` + +**Example: Sort and reorganize commands** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + const customFirst = [ + { + command: 'edit', + title: 'Edit Row', + iconCssClass: 'mdi mdi-pencil', + positionOrder: 0 + } + ]; + + // Sort built-in commands by positionOrder + const sorted = [...builtInItems].sort((a, b) => { + if (a === 'divider' || b === 'divider') return 0; + return (a.positionOrder || 50) - (b.positionOrder || 50); + }); + + return [...customFirst, 'divider', ...sorted]; + } +} +``` + +**When to use `commandListBuilder` vs `commandItems`:** +- Use `commandItems` for static command lists +- Use `commandListBuilder` when you need to: + - Append/prepend to built-in commands + - Filter or modify commands dynamically + - Sort or reorder the final command list + - Have full control over what gets rendered + +**Note:** Typically use `commandListBuilder` **instead of** `commandItems`, not both together. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### with Options That is when you want to define a list of Options (only 1 list) that the user can choose from and once is selected we would do something (for example change the value of a cell in the grid). @@ -79,7 +171,7 @@ const gridOptions = { // subscribe to Context Menu onOptionSelected event (or use the "action" callback on each option) onOptionSelected: (e, args) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('priority')) { dataContext.priority = args.item.option; reactGridRef.current?.gridService.updateItem(dataContext); @@ -139,7 +231,7 @@ For example, say we want the Context Menu to only be available on the first 20 r ```ts contextMenu: { menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 }, }, @@ -153,7 +245,7 @@ contextMenu: { option: 0, title: 'n/a', textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -177,6 +269,11 @@ contextMenu: { } ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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. + ### Show Menu only over Certain Columns Say you want to show the Context Menu only when the user is over certain columns of the grid. For that, you could use the `commandShownOverColumnIds` (or `optionShownOverColumnIds`) array, by default these arrays are empty and when that is the case then the menu will be accessible from any columns. So if we want to have the Context Menu available only over the first 2 columns, we would have an array of those 2 column ids. For example, the following would show the Context Menu everywhere except the last 2 columns (priority, action) since they are not part of the array. ```ts diff --git a/frameworks/slickgrid-react/docs/grid-functionalities/grid-menu.md b/frameworks/slickgrid-react/docs/grid-functionalities/grid-menu.md index 586574a12a..9fb66f858e 100644 --- a/frameworks/slickgrid-react/docs/grid-functionalities/grid-menu.md +++ b/frameworks/slickgrid-react/docs/grid-functionalities/grid-menu.md @@ -73,6 +73,11 @@ const gridOptions = { }; ``` +#### Advanced: Dynamic Command List Builder +For more advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### Events There are multiple events/callback hooks which are accessible from the Grid Options - `onBeforeMenuShow` @@ -105,6 +110,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/6pac/SlickGrid/blob/master/controls/slick.gridmenu.js) 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 `defaultMenuItemRenderer` 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/frameworks/slickgrid-react/docs/grid-functionalities/header-menu-header-buttons.md b/frameworks/slickgrid-react/docs/grid-functionalities/header-menu-header-buttons.md index 0bcf07b3ab..1d14f68ab3 100644 --- a/frameworks/slickgrid-react/docs/grid-functionalities/header-menu-header-buttons.md +++ b/frameworks/slickgrid-react/docs/grid-functionalities/header-menu-header-buttons.md @@ -20,7 +20,10 @@ The Header Menu also comes, by default, with a list of built-in custom commands - Sort Descending (you can hide it with `hideSortCommands: true`) - Hide Column (you can hide it with `hideColumnHideCommand: true`) -This section is called Custom Commands because you can also customize this section with your own commands. To do that, you need to fill in 2 properties (an array of `headerMenuItems` that will go under each column definition and define `onCommand` callbacks) in your Grid Options. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): +This section is called Custom Commands because you can also customize this section with your own commands. You can do this in two ways: using static command items or using a dynamic command list builder. + +#### Static Command Items +To add static commands, fill in an array of items in your column definition's `header.menu.commandItems`. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): ```ts const gridOptions = { enableAutoResize: true, @@ -51,6 +54,100 @@ const gridOptions = { } }; ``` + +#### Dynamic Command List Builder +For advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. This is executed **after** `commandItems` and is the **last call before rendering**. + +```ts +const columnDefinitions = [ + { + id: 'title', + name: 'Title', + field: 'title', + header: { + menu: { + commandListBuilder: (builtInItems) => { + // Append custom commands to the built-in sort/hide commands + return [ + ...builtInItems, + 'divider', + { + command: 'freeze-column', + title: 'Freeze Column', + iconCssClass: 'mdi mdi-pin', + action: (e, args) => { + // Implement column freezing + console.log('Freeze column:', args.column.name); + } + } + ]; + } + } + } + } +]; +``` + +**Example: Conditional commands based on column type** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + const column = this; // column context + + // Add filtering option only for filterable columns + const extraCommands = []; + if (column.filterable !== false) { + extraCommands.push({ + command: 'clear-filter', + title: 'Clear Filter', + iconCssClass: 'mdi mdi-filter-remove', + action: (e, args) => { + filterService.clearFilterByColumnId(args.column.id); + } + }); + } + + return [...builtInItems, ...extraCommands]; + } + } +} +``` + +**Example: Remove sort commands, keep only custom ones** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + // Filter out sort commands + const filtered = builtInItems.filter(item => + item !== 'divider' && + !item.command?.includes('sort') + ); + + // Add custom commands + return [ + ...filtered, + { + command: 'custom-action', + title: 'Custom Action', + iconCssClass: 'mdi mdi-star', + action: (e, args) => alert('Custom: ' + args.column.name) + } + ]; + } + } +} +``` + +**When to use `commandListBuilder`:** +- You want to append/prepend items to built-in commands +- You need to filter or modify commands based on column properties +- You want to customize the command list per column dynamically +- You need full control over the final command list + +**Note:** Use `commandListBuilder` **instead of** `commandItems`, not both together. + #### Callback Hooks There are 2 callback hooks which are accessible in the Grid Options - `onBeforeMenuShow` @@ -58,6 +155,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 `defaultMenuItemRenderer` 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/frameworks/slickgrid-react/docs/menu-slots.md b/frameworks/slickgrid-react/docs/menu-slots.md new file mode 100644 index 0000000000..14b9f9bb47 --- /dev/null +++ b/frameworks/slickgrid-react/docs/menu-slots.md @@ -0,0 +1,524 @@ +## 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 `defaultMenuItemRenderer` at the menu level. + +> **Note:** This documentation covers **how menu items are rendered** (visual presentation). If you need to **dynamically modify which commands appear** in the menu (filtering, sorting, adding/removing items), see the `commandListBuilder` callback documented in [Grid Menu](grid-functionalities/grid-menu.md), [Context Menu](grid-functionalities/context-menu.md), or [Header Menu](grid-functionalities/header-menu-header-buttons.md). + +### TypeScript Tip: Type Inference with commandListBuilder + +When using `commandListBuilder` to add custom menu items with slotRenderer callbacks, **cast the return value to the appropriate type** to enable proper type parameters in callbacks: + +```typescript +contextMenu: { + commandListBuilder: (builtInItems) => { + return [ + ...builtInItems, + { + command: 'custom-action', + title: 'My Action', + slotRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + } + ] as Array; + } +} +``` + +Alternatively, if you only have built-in items and dividers, you can use the simpler cast: + +```typescript +return [...] as Array; +``` + +### 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?: (cmdItem: MenuItem, args: MenuCallbackArgs, event?: Event) => string | HTMLElement +``` + +- **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()`) + +### 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: (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 = 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; + } +}; +``` + +### Default Menu-Level Renderer + +Set a `defaultMenuItemRenderer` 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) + defaultMenuItemRenderer: (cmdItem, args) => { + return ` +
+ ${cmdItem.iconCssClass ? `` : ''} + ${cmdItem.title} +
+ `; + }, + commandItems: [ + { + command: 'action-1', + title: 'Action One', + iconCssClass: 'mdi mdi-check', + // This item uses defaultMenuItemRenderer + }, + { + command: 'custom', + title: 'Custom Item', + // This item overrides defaultMenuItemRenderer with its own slotRenderer + slotRenderer: () => ` +
+ Custom rendering overrides default +
+ ` + } + ] +}; +``` + +### Menu Types & Configuration + +The `slotRenderer` and `defaultMenuItemRenderer` work identically across all menu plugins: + +#### Header Menu +```typescript +const columnDef = { + id: 'name', + header: { + menu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'sort', + title: 'Sort', + slotRenderer: () => '
Custom sort
' + } + ] + } + } +}; +``` + +#### Cell Menu +```typescript +const columnDef = { + id: 'action', + cellMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'edit', + title: 'Edit', + slotRenderer: (cmdItem, args) => `
Edit row ${args.dataContext.id}
` + } + ] + } +}; +``` + +#### Context Menu +```typescript +const gridOptions = { + enableContextMenu: true, + contextMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'export', + title: 'Export', + slotRenderer: () => '
📊 Export data
' + } + ] + } +}; +``` + +#### Grid Menu +```typescript +const gridOptions = { + enableGridMenu: true, + gridMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.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: (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); + 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: (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); + 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: (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); + 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: (cmdItem, args) => ` +
+ + Edit Row #${args.dataContext?.id || 'N/A'} +
+ ` +} +``` + +#### 4. Add Interactive Elements +```typescript +{ + command: 'toggle-setting', + title: 'Auto Refresh', + 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) => { + // Prevent menu item click from firing when toggling checkbox + event?.stopPropagation?.(); + console.log('Auto refresh:', checkbox.checked); + }); + + const label = document.createElement('span'); + label.textContent = cmdItem.title; + + container.appendChild(label); + container.appendChild(checkbox); + return container; + } +} +``` + +#### 5. Add Badges and Status Labels +```typescript +{ + command: 'export-excel', + title: 'Export as Excel', + slotRenderer: (cmdItem, args) => ` +
+ + ${cmdItem.title} + RECOMMENDED +
+ ` +} +``` + +#### 6. Gradient and Styled Icons +```typescript +{ + command: 'advanced-export', + title: 'Advanced Export', + 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'; + 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 = cmdItem.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 `defaultMenuItemRenderer` +- **Built-in command preservation** - When overriding a built-in command (e.g., `sort-asc`, `sort-desc`, `hide`, etc.) with custom properties like `slotRenderer` or `iconCssClass`, if you don't provide an `action` callback, the library will automatically preserve and use the built-in action for that command. This means you can safely customize the appearance of built-in commands without losing their functionality. +- **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 `defaultMenuItemRenderer` 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: (cmdItem, 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/frameworks/slickgrid-react/docs/migrations/migration-to-10.x.md b/frameworks/slickgrid-react/docs/migrations/migration-to-10.x.md index 407756ae80..0005d30687 100644 --- a/frameworks/slickgrid-react/docs/migrations/migration-to-10.x.md +++ b/frameworks/slickgrid-react/docs/migrations/migration-to-10.x.md @@ -1,4 +1,4 @@ -## Cleaner Code / Smaller Code ⚡ +## Simplification and Modernization ⚡ One of the biggest change of this release is to hide columns by using the `hidden` column property (now used by Column Picker, Grid Menu, etc...). Previously we were removing columns from the original columns array and we then called `setColumns()` to update the grid, but this meant that we had to keep references for all visible and non-visible columns. With this new release we now keep the full columns array at all time and instead we just change column(s) visibility via their `hidden` column properties by using `grid.updateColumnById('id', { hidden: true })` and finally we update the grid via `grid.updateColumns()`. What I'm trying to emphasis is that you should really stop using `grid.setColumns()` in v10+, and if you want to hide some columns when declaring the columns, then just update their `hidden` properties, see more details below... @@ -85,11 +85,30 @@ gridOptions = { }; ``` -### External Resources are now auto-enabled - This change does not require any code update from the end user, but it is a change that you should probably be aware of nonetheless. The reason I decided to implement this is because I often forget myself to enable the associated flag and typically if you wanted to load the resource, then it's most probably because you also want it enabled. So for example, if your register `ExcelExportService` then the library will now auto-enable the resource with its associated flag (which in this case is `enableExcelExport:true`)... unless you already disabled the flag (or enabled) yourself, if so then the internal assignment will simply be skipped and yours will prevail. Also just to be clear, the list of auto-enabled external resources is rather small, it will auto-enable the following resources: (ExcelExportService, PdfExportService, TextExportService, CompositeEditorComponent and RowDetailView). +### Menu with Commands + +All menu plugins (Cell Menu, Context Menu, Header Menu and Grid Menu) now have a new `commandListBuilder: (items) => items` which now allow you to filter/sort and maybe override built-in commands. With this new feature in place, I'm deprecating all `hide...` properties and also `positionOrder` since you can now do that with the builder. You could also use the `hideCommands` which accepts an array of built-in command names. This well remove huge amount of `hide...` properties (over 30) that keeps increasing anytime a new built-in command gets added (in other words, this will simplify maintenance for both you and me). + +These are currently just deprecations in v10.x but it's strongly recommended to start using the `commandListBuilder` and/or `hideCommands` and move away from the deprecated properties which will be removed in v11.x. For example if we want to hide some built-in commands: + +```diff +gridOptions = { + gridMenu: { +- hideExportCsvCommand: true, +- hideTogglePreHeaderCommand: true, + +// via command name(s) ++ hideCommands: ['export-csv', 'toggle-preheader'], + +// or via builder ++ commandListBuilder: (cmdItems) => [...cmdItems.filter(x => x !== 'divider' && x.command !== 'export-csv' && x.command !== 'toggle-preheader')] + } +} +``` + --- {% hint style="note" %} diff --git a/frameworks/slickgrid-react/tsconfig.json b/frameworks/slickgrid-react/tsconfig.json index d6cc0e4af9..27be555f78 100644 --- a/frameworks/slickgrid-react/tsconfig.json +++ b/frameworks/slickgrid-react/tsconfig.json @@ -22,7 +22,6 @@ "skipLibCheck": true, "sourceMap": true, "newLine": "lf", - "downlevelIteration": true, "outDir": "dist" }, "include": ["src"], diff --git a/frameworks/slickgrid-vue/docs/column-functionalities/cell-menu.md b/frameworks/slickgrid-vue/docs/column-functionalities/cell-menu.md index 15a8b3d00f..4ff57137ed 100644 --- a/frameworks/slickgrid-vue/docs/column-functionalities/cell-menu.md +++ b/frameworks/slickgrid-vue/docs/column-functionalities/cell-menu.md @@ -94,7 +94,8 @@ So if you decide to use the `action` callback, then your code would look like th ##### with `action` callback ```ts columnDefinitions.value = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { commandItems: [ { command: 'command1', title: 'Command 1', action: (e, args) => console.log(args) }, @@ -111,7 +112,8 @@ The `onCommand` (or `onOptionSelected`) **must** be defined in the Grid Options ```ts columnDefinitions.value = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { commandItems: [ { command: 'command1', title: 'Command 1' }, @@ -140,6 +142,11 @@ gridOptions.value = { }; ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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 @@ -150,12 +157,10 @@ What if you want to dynamically disable or hide a Command/Option or even disable For example, say we want the Cell Menu to only be available on the first 20 rows of the grid, we could use the override this way ```ts columnDefinitions.value = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { - menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; - return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 - }, + menuUsabilityOverride: (args) => args?.dataContext.id < 21, // say we want to display the menu only from Task 0 to 20 } } ]; @@ -164,14 +169,15 @@ columnDefinitions.value = [ To give another example, with Options this time, we could say that we enable the `n/a` option only when the row is Completed. So we could do it this way ```ts columnDefinitions.value = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { optionItems: [ { option: 0, title: 'n/a', textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; } }, @@ -188,7 +194,8 @@ columnDefinitions.value = [ It works exactly like the rest of the library when `enableTranslate` is set, all we have to do is to provide translations with the `Key` suffix, so for example without translations, we would use `title` and that would become `titleKey` with translations, that;'s easy enough. So for example, a list of Options could be defined as follow: ```ts columnDefinitions.value = [ - { id: 'action', field: 'action', name: 'Action', + { + id: 'action', field: 'action', name: 'Action', cellMenu: { optionTitleKey: 'OPTIONS', // optionally pass a title to show over the Options optionItems: [ diff --git a/frameworks/slickgrid-vue/docs/column-functionalities/editors.md b/frameworks/slickgrid-vue/docs/column-functionalities/editors.md index 78151d2c9c..511b3fe4c8 100644 --- a/frameworks/slickgrid-vue/docs/column-functionalities/editors.md +++ b/frameworks/slickgrid-vue/docs/column-functionalities/editors.md @@ -13,7 +13,7 @@ - [Collection Async Load](#collection-async-load) - [Collection Label Prefix/Suffix](#collection-label-prefixsuffix) - [Collection Label Render HTML](#collection-label-render-html) - - [`multiple-select.js` Options](#multiple-selectjs-options) + - [`multiple-select-vanilla` Options](#multiple-selectjs-options) - [Editor Options](#editor-options) - [Validators](#validators) - [Custom Validator](#custom-validator) @@ -256,7 +256,7 @@ function defineGrid() { ``` ### Editor Options (`MultipleSelectOption` interface) -All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts editor: { @@ -417,7 +417,7 @@ function defineGrid() { collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } @@ -498,8 +498,8 @@ So if we take all of these informations and we want to create our own Custom Edi ```ts const myCustomTitleValidator: EditorValidator = (value: any, args: EditorArgs) => { // you can get the Editor Args which can be helpful, e.g. we can get the Translate Service from it - const grid = args && args.grid; - const gridOptions = (grid && grid.getOptions) ? grid.getOptions() : {}; + const grid = args.grid; + const gridOptions = grid.getOptions() : {}; const i18n = gridOptions.i18n; if (value == null || value === undefined || !value.length) { diff --git a/frameworks/slickgrid-vue/docs/column-functionalities/editors/select-dropdown-editor.md b/frameworks/slickgrid-vue/docs/column-functionalities/editors/select-dropdown-editor.md index ec1f631ff7..22d767d68a 100644 --- a/frameworks/slickgrid-vue/docs/column-functionalities/editors/select-dropdown-editor.md +++ b/frameworks/slickgrid-vue/docs/column-functionalities/editors/select-dropdown-editor.md @@ -6,11 +6,11 @@ - [Collection Label Prefix/Suffix](#collection-label-prefixsuffix) - [Collection Label Render HTML](#collection-label-render-html) - [Collection Change Watch](#collection-watch) - - [`multiple-select.js` Options](#multiple-selectjs-options) + - [`multiple-select-vanilla` Options](#multiple-selectjs-options) - See the [Editors - Wiki](../Editors.md) for more general info about Editors (validators, event handlers, ...) ## Select Editors -The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select](https://github.com/ghiscoding/multiple-select-adapted/blob/master/src/multiple-select.js) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). +The library ships with two select editors: `singleSelectEditor` and the `multipleSelectEditor`. Both support the [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) library, but fallback to the bootstrap form-control style if you decide to exclude this library from your build. These editors will work with a list of foreign key values (custom structure not supported) and can be displayed properly with the [collectionFormatter](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/formatters/collectionFormatter.ts). We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). @@ -38,7 +38,7 @@ columnDefinitions.value = [ ``` ### Editor Options (`MultipleSelectOption` interface) -All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as editor `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your editor `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts editor: { @@ -195,7 +195,7 @@ columnDefinitions.value = [ ``` ### `multiple-select-vanilla.js` Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your editor `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/lib` folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -221,7 +221,7 @@ columnDefinitions.value = [ collection: [{ value: '', label: '' }, { value: true, label: 'true' }, { value: false, label: 'false' }], model: Editors.singleSelect, elementOptions: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } diff --git a/frameworks/slickgrid-vue/docs/column-functionalities/filters/custom-filter.md b/frameworks/slickgrid-vue/docs/column-functionalities/filters/custom-filter.md index 63ec606e33..91f3c98070 100644 --- a/frameworks/slickgrid-vue/docs/column-functionalities/filters/custom-filter.md +++ b/frameworks/slickgrid-vue/docs/column-functionalities/filters/custom-filter.md @@ -12,7 +12,7 @@ You can also create your own Custom Filter with any html/css you want to use. Vu - as mentioned in the description, only html/css and/or JS libraries are supported. - this mainly mean that Vue templates (Views) are not supported (feel free to contribute). - SlickGrid uses `table-cell` as CSS for it to display a consistent height for each rows (this keeps the same row height/line-height to always be the same). - - all this to say that you might be in a situation were your filter shows in the back of the grid. The best approach to overcome this is to use a modal if you can or if the library support `append to body container`. For example, you can see that `multiple-select.js` support a `container` and is needed for the filter to work as can be seen in the [multipleSelectFilter.ts](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/filters/multipleSelectFilter.ts#L26) + - all this to say that you might be in a situation were your filter shows in the back of the grid. The best approach to overcome this is to use a modal if you can or if the library support `append to body container`. For example, you can see that `multiple-select-vanilla` support a `container` and is needed for the filter to work as can be seen in the [multipleSelectFilter.ts](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/filters/multipleSelectFilter.ts#L26) ### How to use Custom Filter? 1. You first need to create a `class` using the [Filter interface](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/models/filter.interface.ts). Make sure to create all necessary public properties and functions. diff --git a/frameworks/slickgrid-vue/docs/column-functionalities/filters/select-filter.md b/frameworks/slickgrid-vue/docs/column-functionalities/filters/select-filter.md index f0955109e0..fbc65424de 100644 --- a/frameworks/slickgrid-vue/docs/column-functionalities/filters/select-filter.md +++ b/frameworks/slickgrid-vue/docs/column-functionalities/filters/select-filter.md @@ -16,7 +16,7 @@ - [Collection Async Load](#collection-async-load) - [Collection Lazy Load](#collection-lazy-load) - [Collection Watch](#collection-watch) -- [`multiple-select.js` Options](#multiple-selectjs-options) +- [`multiple-select-vanilla` Options](#multiple-selectjs-options) - [Filter Options (`MultipleSelectOption` interface)](#filter-options-multipleselectoption-interface) - [Display shorter selected label text](#display-shorter-selected-label-text) - [Query against a different field](#query-against-another-field-property) @@ -36,7 +36,7 @@ Multiple Select (dropdown) filter is useful when we want to filter the grid 1 or We use an external lib named [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla). #### Note -For this filter to work you will need to add [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) to your project. This is a customized version of the original (thought all the original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) +For this filter to work you will need to add [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) to your project. This is a customized version of the original (thought all the original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why it points to `slickgrid-universal/dist/lib` folder. This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. - `okButtonText` was also added for locale (i18n) - `offsetLeft` option was added to make it possible to offset the dropdown. By default it is set to 0 and is aligned to the left of the select element. This option is particularly helpful when used as the last right column, not to fall off the screen. @@ -590,7 +590,7 @@ columnDefinitions.value = [ ``` ### Filter Options (`MultipleSelectOption` interface) -All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select.js` library. +All the available options that can be provided as filter `options` to your column definitions can be found under this [MultipleSelectOption](https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/multiple-select-vanilla/src/models/multipleSelectOption.interface.ts) interface and you should cast your filter `options` to that interface to make sure that you use only valid options of the `multiple-select-vanilla` library. ```ts filter: { @@ -613,8 +613,8 @@ gridOptions.value = { } ``` -### Multiple-select.js Options -You can use any options from [Multiple-Select.js](http://wenzhixin.net.cn/p/multiple-select) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](http://wenzhixin.net.cn/p/multiple-select/docs/) are available so you can still consult the original site for all options). +### multiple-select-vanilla Options +You can use any options from [multiple-select-vanilla](https://github.com/ghiscoding/multiple-select-vanilla) and add them to your filter `options` property. However please note that this is a customized version of the original (all original [lib options](https://ghiscoding.github.io/multiple-select-vanilla/) are available so you can still consult the original site for all options). Couple of small options were added to suit SlickGrid-Universal needs, which is why we are using a fork [ghiscoding/multiple-select-modified](https://github.com/ghiscoding/multiple-select-modified) folder (which is our customized version of the original). This lib is required if you plan to use `multipleSelect` or `singleSelect` Filters. What was customized to (compare to the original) is the following: - `okButton` option was added to add an OK button for simpler closing of the dropdown after selecting multiple options. @@ -643,7 +643,7 @@ columnDefinitions.value = [ model: Filters.singleSelect, // previously known as `filterOptions` for < 9.0 options: { - // add any multiple-select.js options (from original or custom version) + // add any multiple-select-vanilla options (from original or custom version) autoAdjustDropPosition: false, // by default set to True, but you can disable it position: 'top' } as MultipleSelectOption diff --git a/frameworks/slickgrid-vue/docs/grid-functionalities/composite-editor-modal.md b/frameworks/slickgrid-vue/docs/grid-functionalities/composite-editor-modal.md index 8a8e367454..94aa193282 100644 --- a/frameworks/slickgrid-vue/docs/grid-functionalities/composite-editor-modal.md +++ b/frameworks/slickgrid-vue/docs/grid-functionalities/composite-editor-modal.md @@ -492,7 +492,7 @@ function handleOnCompositeEditorChange(event) { // you can also change some editor options // not all Editors supports this functionality, so far only these Editors are supported: AutoComplete, Date, Single/Multiple Select if (columnDef.id === 'completed') { - compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select.js, show filter in dropdown + compositeEditorInstance.changeFormEditorOption('percentComplete', 'filter', true); // multiple-select-vanilla, show filter in dropdown compositeEditorInstance.changeFormEditorOption('product', 'minLength', 3); // autocomplete, change minLength char to type compositeEditorInstance.changeFormEditorOption('finish', 'displayDateMin', 'today'); } diff --git a/frameworks/slickgrid-vue/docs/grid-functionalities/context-menu.md b/frameworks/slickgrid-vue/docs/grid-functionalities/context-menu.md index c03cbc04c5..638528bf65 100644 --- a/frameworks/slickgrid-vue/docs/grid-functionalities/context-menu.md +++ b/frameworks/slickgrid-vue/docs/grid-functionalities/context-menu.md @@ -23,7 +23,7 @@ This extensions is wrapped around the new SlickGrid Plugin **SlickContextMenu** ### Default Usage Technically, the Context Menu is enabled by default (copy, export) and so you don't have anything to do to enjoy it (you could disable it at any time). However, if you want to customize the content of the Context Menu, then continue reading. You can customize the menu with 2 different lists, Commands and/or Options, they can be used separately or at the same time. Also note that even though the code shown below makes a separation between the Commands and Options, you can mix them in the same Context Menu. -#### with Commands +#### with Commands (Static) ```ts gridOptions.value = { @@ -63,6 +63,98 @@ gridOptions.value = { }; ``` +#### with Commands (Dynamic Builder) +For advanced scenarios where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +```ts +this.gridOptions = { + enableContextMenu: true, + contextMenu: { + commandListBuilder: (builtInItems) => { + // Example: Add custom commands after built-in ones + return [ + ...builtInItems, + 'divider', + { + command: 'delete-row', + title: 'Delete Row', + iconCssClass: 'mdi mdi-delete text-danger', + action: (e, args) => { + if (confirm(`Delete row ${args.dataContext.id}?`)) { + this.gridService.deleteItem(args.dataContext); + } + } + }, + { + command: 'duplicate-row', + title: 'Duplicate Row', + iconCssClass: 'mdi mdi-content-duplicate', + action: (e, args) => { + const newItem = { ...args.dataContext, id: this.generateNewId() }; + this.gridService.addItem(newItem); + } + } + ]; + }, + onCommand: (e, args) => { + // Handle commands here if not using action callbacks + console.log('Command:', args.command); + } + } +}; +``` + +**Example: Filter commands based on row data** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + // You can't access row data here, but you can filter/modify built-in items + // Use itemUsabilityOverride or itemVisibilityOverride for row-specific logic + + // Only show export commands + return builtInItems.filter(item => + item === 'divider' || item.command?.includes('export') + ); + } +} +``` + +**Example: Sort and reorganize commands** +```ts +contextMenu: { + commandListBuilder: (builtInItems) => { + const customFirst = [ + { + command: 'edit', + title: 'Edit Row', + iconCssClass: 'mdi mdi-pencil', + positionOrder: 0 + } + ]; + + // Sort built-in commands by positionOrder + const sorted = [...builtInItems].sort((a, b) => { + if (a === 'divider' || b === 'divider') return 0; + return (a.positionOrder || 50) - (b.positionOrder || 50); + }); + + return [...customFirst, 'divider', ...sorted]; + } +} +``` + +**When to use `commandListBuilder` vs `commandItems`:** +- Use `commandItems` for static command lists +- Use `commandListBuilder` when you need to: + - Append/prepend to built-in commands + - Filter or modify commands dynamically + - Sort or reorder the final command list + - Have full control over what gets rendered + +**Note:** Typically use `commandListBuilder` **instead of** `commandItems`, not both together. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### with Options That is when you want to define a list of Options (only 1 list) that the user can choose from and once is selected we would do something (for example change the value of a cell in the grid). @@ -79,7 +171,7 @@ gridOptions.value = { // subscribe to Context Menu onOptionSelected event (or use the "action" callback on each option) onOptionSelected: (e, args) => { // change Priority - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; if (dataContext && dataContext.hasOwnProperty('priority')) { dataContext.priority = args.item.option; sgb.gridService.updateItem(dataContext); @@ -139,7 +231,7 @@ For example, say we want the Context Menu to only be available on the first 20 r ```ts contextMenu: { menuUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return (dataContext.id < 21); // say we want to display the menu only from Task 0 to 20 }, }, @@ -153,7 +245,7 @@ contextMenu: { option: 0, title: 'n/a', textCssClass: 'italic', // only enable this option when the task is Not Completed itemUsabilityOverride: (args) => { - const dataContext = args && args.dataContext; + const dataContext = args?.dataContext; return !dataContext.completed; }, }, @@ -177,6 +269,11 @@ contextMenu: { } ``` +### Custom Menu Item Rendering +For advanced customization of menu item appearance, you can use the `slotRenderer` or `defaultMenuItemRenderer` 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. + ### Show Menu only over Certain Columns Say you want to show the Context Menu only when the user is over certain columns of the grid. For that, you could use the `commandShownOverColumnIds` (or `optionShownOverColumnIds`) array, by default these arrays are empty and when that is the case then the menu will be accessible from any columns. So if we want to have the Context Menu available only over the first 2 columns, we would have an array of those 2 column ids. For example, the following would show the Context Menu everywhere except the last 2 columns (priority, action) since they are not part of the array. ```ts diff --git a/frameworks/slickgrid-vue/docs/grid-functionalities/grid-menu.md b/frameworks/slickgrid-vue/docs/grid-functionalities/grid-menu.md index d89c54482b..5a5485a1d1 100644 --- a/frameworks/slickgrid-vue/docs/grid-functionalities/grid-menu.md +++ b/frameworks/slickgrid-vue/docs/grid-functionalities/grid-menu.md @@ -74,6 +74,11 @@ gridOptions.value = { }; ``` +#### Advanced: Dynamic Command List Builder +For more advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. + +See the main [Custom Menu Slots](../menu-slots.md) documentation for detailed `commandListBuilder` examples. + #### Events There are multiple events/callback hooks which are accessible from the Grid Options - `onBeforeMenuShow` @@ -106,6 +111,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/6pac/SlickGrid/blob/master/controls/slick.gridmenu.js) 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 `defaultMenuItemRenderer` 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/frameworks/slickgrid-vue/docs/grid-functionalities/header-menu-header-buttons.md b/frameworks/slickgrid-vue/docs/grid-functionalities/header-menu-header-buttons.md index db633290d7..09bafcdb3c 100644 --- a/frameworks/slickgrid-vue/docs/grid-functionalities/header-menu-header-buttons.md +++ b/frameworks/slickgrid-vue/docs/grid-functionalities/header-menu-header-buttons.md @@ -20,7 +20,10 @@ The Header Menu also comes, by default, with a list of built-in custom commands - Sort Descending (you can hide it with `hideSortCommands: true`) - Hide Column (you can hide it with `hideColumnHideCommand: true`) -This section is called Custom Commands because you can also customize this section with your own commands. To do that, you need to fill in 2 properties (an array of `headerMenuItems` that will go under each column definition and define `onCommand` callbacks) in your Grid Options. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): +This section is called Custom Commands because you can also customize this section with your own commands. You can do this in two ways: using static command items or using a dynamic command list builder. + +#### Static Command Items +To add static commands, fill in an array of items in your column definition's `header.menu.commandItems`. For example, `Slickgrid-Universal` is configured by default with these settings (you can overwrite any one of them): ```ts gridOptions.value = { enableAutoResize: true, @@ -51,6 +54,98 @@ gridOptions.value = { } }; ``` +#### Dynamic Command List Builder +For advanced use cases where you need to dynamically build the command list, use `commandListBuilder`. This callback receives the built-in commands and allows you to filter, sort, or modify the list before it's rendered in the UI, giving you full control over the final command list. This is executed **after** `commandItems` and is the **last call before rendering**. + +```ts +columnDefinitions.value = [ + { + id: 'title', + name: 'Title', + field: 'title', + header: { + menu: { + commandListBuilder: (builtInItems) => { + // Append custom commands to the built-in sort/hide commands + return [ + ...builtInItems, + 'divider', + { + command: 'freeze-column', + title: 'Freeze Column', + iconCssClass: 'mdi mdi-pin', + action: (e, args) => { + // Implement column freezing + console.log('Freeze column:', args.column.name); + } + } + ]; + } + } + } + } +]; +``` + +**Example: Conditional commands based on column type** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + const column = this; // column context + + // Add filtering option only for filterable columns + const extraCommands = []; + if (column.filterable !== false) { + extraCommands.push({ + command: 'clear-filter', + title: 'Clear Filter', + iconCssClass: 'mdi mdi-filter-remove', + action: (e, args) => { + filterService.clearFilterByColumnId(args.column.id); + } + }); + } + + return [...builtInItems, ...extraCommands]; + } + } +} +``` + +**Example: Remove sort commands, keep only custom ones** +```ts +header: { + menu: { + commandListBuilder: (builtInItems) => { + // Filter out sort commands + const filtered = builtInItems.filter(item => + item !== 'divider' && + !item.command?.includes('sort') + ); + + // Add custom commands + return [ + ...filtered, + { + command: 'custom-action', + title: 'Custom Action', + iconCssClass: 'mdi mdi-star', + action: (e, args) => alert('Custom: ' + args.column.name) + } + ]; + } + } +} +``` + +**When to use `commandListBuilder`:** +- You want to append/prepend items to built-in commands +- You need to filter or modify commands based on column properties +- You want to customize the command list per column dynamically +- You need full control over the final command list + +**Note:** Use `commandListBuilder` **instead of** `commandItems`, not both together. #### Callback Hooks There are 2 callback hooks which are accessible in the Grid Options - `onBeforeMenuShow` @@ -58,6 +153,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 `defaultMenuItemRenderer` 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/frameworks/slickgrid-vue/docs/menu-slots.md b/frameworks/slickgrid-vue/docs/menu-slots.md new file mode 100644 index 0000000000..14b9f9bb47 --- /dev/null +++ b/frameworks/slickgrid-vue/docs/menu-slots.md @@ -0,0 +1,524 @@ +## 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 `defaultMenuItemRenderer` at the menu level. + +> **Note:** This documentation covers **how menu items are rendered** (visual presentation). If you need to **dynamically modify which commands appear** in the menu (filtering, sorting, adding/removing items), see the `commandListBuilder` callback documented in [Grid Menu](grid-functionalities/grid-menu.md), [Context Menu](grid-functionalities/context-menu.md), or [Header Menu](grid-functionalities/header-menu-header-buttons.md). + +### TypeScript Tip: Type Inference with commandListBuilder + +When using `commandListBuilder` to add custom menu items with slotRenderer callbacks, **cast the return value to the appropriate type** to enable proper type parameters in callbacks: + +```typescript +contextMenu: { + commandListBuilder: (builtInItems) => { + return [ + ...builtInItems, + { + command: 'custom-action', + title: 'My Action', + slotRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + } + ] as Array; + } +} +``` + +Alternatively, if you only have built-in items and dividers, you can use the simpler cast: + +```typescript +return [...] as Array; +``` + +### 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?: (cmdItem: MenuItem, args: MenuCallbackArgs, event?: Event) => string | HTMLElement +``` + +- **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()`) + +### 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: (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 = 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; + } +}; +``` + +### Default Menu-Level Renderer + +Set a `defaultMenuItemRenderer` 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) + defaultMenuItemRenderer: (cmdItem, args) => { + return ` +
+ ${cmdItem.iconCssClass ? `` : ''} + ${cmdItem.title} +
+ `; + }, + commandItems: [ + { + command: 'action-1', + title: 'Action One', + iconCssClass: 'mdi mdi-check', + // This item uses defaultMenuItemRenderer + }, + { + command: 'custom', + title: 'Custom Item', + // This item overrides defaultMenuItemRenderer with its own slotRenderer + slotRenderer: () => ` +
+ Custom rendering overrides default +
+ ` + } + ] +}; +``` + +### Menu Types & Configuration + +The `slotRenderer` and `defaultMenuItemRenderer` work identically across all menu plugins: + +#### Header Menu +```typescript +const columnDef = { + id: 'name', + header: { + menu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'sort', + title: 'Sort', + slotRenderer: () => '
Custom sort
' + } + ] + } + } +}; +``` + +#### Cell Menu +```typescript +const columnDef = { + id: 'action', + cellMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'edit', + title: 'Edit', + slotRenderer: (cmdItem, args) => `
Edit row ${args.dataContext.id}
` + } + ] + } +}; +``` + +#### Context Menu +```typescript +const gridOptions = { + enableContextMenu: true, + contextMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.title}
`, + commandItems: [ + { + command: 'export', + title: 'Export', + slotRenderer: () => '
📊 Export data
' + } + ] + } +}; +``` + +#### Grid Menu +```typescript +const gridOptions = { + enableGridMenu: true, + gridMenu: { + defaultMenuItemRenderer: (cmdItem, args) => `
${cmdItem.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: (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); + 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: (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); + 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: (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); + 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: (cmdItem, args) => ` +
+ + Edit Row #${args.dataContext?.id || 'N/A'} +
+ ` +} +``` + +#### 4. Add Interactive Elements +```typescript +{ + command: 'toggle-setting', + title: 'Auto Refresh', + 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) => { + // Prevent menu item click from firing when toggling checkbox + event?.stopPropagation?.(); + console.log('Auto refresh:', checkbox.checked); + }); + + const label = document.createElement('span'); + label.textContent = cmdItem.title; + + container.appendChild(label); + container.appendChild(checkbox); + return container; + } +} +``` + +#### 5. Add Badges and Status Labels +```typescript +{ + command: 'export-excel', + title: 'Export as Excel', + slotRenderer: (cmdItem, args) => ` +
+ + ${cmdItem.title} + RECOMMENDED +
+ ` +} +``` + +#### 6. Gradient and Styled Icons +```typescript +{ + command: 'advanced-export', + title: 'Advanced Export', + 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'; + 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 = cmdItem.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 `defaultMenuItemRenderer` +- **Built-in command preservation** - When overriding a built-in command (e.g., `sort-asc`, `sort-desc`, `hide`, etc.) with custom properties like `slotRenderer` or `iconCssClass`, if you don't provide an `action` callback, the library will automatically preserve and use the built-in action for that command. This means you can safely customize the appearance of built-in commands without losing their functionality. +- **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 `defaultMenuItemRenderer` 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: (cmdItem, 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/frameworks/slickgrid-vue/docs/migrations/migration-to-10.x.md b/frameworks/slickgrid-vue/docs/migrations/migration-to-10.x.md index c5a19be365..51cc1c94d8 100644 --- a/frameworks/slickgrid-vue/docs/migrations/migration-to-10.x.md +++ b/frameworks/slickgrid-vue/docs/migrations/migration-to-10.x.md @@ -1,4 +1,4 @@ -## Cleaner Code / Smaller Code ⚡ +## Simplification and Modernization ⚡ One of the biggest change of this release is to hide columns by using the `hidden` column property (now used by Column Picker, Grid Menu, etc...). Previously we were removing columns from the original columns array and we then called `setColumns()` to update the grid, but this meant that we had to keep references for all visible and non-visible columns. With this new release we now keep the full columns array at all time and instead we just change column(s) visibility via their `hidden` column properties by using `grid.updateColumnById('id', { hidden: true })` and finally we update the grid via `grid.updateColumns()`. What I'm trying to emphasis is that you should really stop using `grid.setColumns()` in v10+, and if you want to hide some columns when declaring the columns, then just update their `hidden` properties, see more details below... @@ -99,11 +99,30 @@ gridOptions = { }; ``` -### External Resources are now auto-enabled - This change does not require any code update from the end user, but it is a change that you should probably be aware of nonetheless. The reason I decided to implement this is because I often forget myself to enable the associated flag and typically if you wanted to load the resource, then it's most probably because you also want it enabled. So for example, if your register `ExcelExportService` then the library will now auto-enable the resource with its associated flag (which in this case is `enableExcelExport:true`)... unless you already disabled the flag (or enabled) yourself, if so then the internal assignment will simply be skipped and yours will prevail. Also just to be clear, the list of auto-enabled external resources is rather small, it will auto-enable the following resources: (ExcelExportService, PdfExportService, TextExportService, CompositeEditorComponent and RowDetailView). +### Menu with Commands + +All menu plugins (Cell Menu, Context Menu, Header Menu and Grid Menu) now have a new `commandListBuilder: (items) => items` which now allow you to filter/sort and maybe override built-in commands. With this new feature in place, I'm deprecating all `hide...` properties and also `positionOrder` since you can now do that with the builder. You could also use the `hideCommands` which accepts an array of built-in command names. This well remove huge amount of `hide...` properties (over 30) that keeps increasing anytime a new built-in command gets added (in other words, this will simplify maintenance for both you and me). + +These are currently just deprecations in v10.x but it's strongly recommended to start using the `commandListBuilder` and/or `hideCommands` and move away from the deprecated properties which will be removed in v11.x. For example if we want to hide some built-in commands: + +```diff +gridOptions = { + gridMenu: { +- hideExportCsvCommand: true, +- hideTogglePreHeaderCommand: true, + +// via command name(s) ++ hideCommands: ['export-csv', 'toggle-preheader'], + +// or via builder ++ commandListBuilder: (cmdItems) => [...cmdItems.filter(x => x !== 'divider' && x.command !== 'export-csv' && x.command !== 'toggle-preheader')] + } +} +``` + --- {% hint style="note" %} diff --git a/packages/common/src/editors/editors.index.ts b/packages/common/src/editors/editors.index.ts index 2f52419da0..7038a8958b 100644 --- a/packages/common/src/editors/editors.index.ts +++ b/packages/common/src/editors/editors.index.ts @@ -37,13 +37,13 @@ export const Editors: Record = { */ longText: LongTextEditor, - /** Multiple Select editor (which uses 3rd party lib "multiple-select.js") */ + /** Multiple Select editor (which uses 3rd party lib "multiple-select-vanilla") */ multipleSelect: MultipleSelectEditor, /** Editor with an input of type Password (note that only the text shown in the UI will be masked, the editor value is still plain text) */ password: InputPasswordEditor, - /** Single Select editor (which uses 3rd party lib "multiple-select.js") */ + /** Single Select editor (which uses 3rd party lib "multiple-select-vanilla") */ singleSelect: SingleSelectEditor, /** Slider Editor using an input of type "range" */ diff --git a/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts b/packages/common/src/extensions/__tests__/slickCellMenu.plugin.spec.ts index 79a2888321..d79002ab4c 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 defaultMenuItemRenderer 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({ defaultMenuItemRenderer: 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 defaultMenuItemRenderer', () => { + 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({ defaultMenuItemRenderer: 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..fbc69b4810 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!.defaultMenuItemRenderer; + 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 defaultMenuItemRenderer 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' }], defaultMenuItemRenderer: 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 defaultMenuItemRenderer', () => { + 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 }], + defaultMenuItemRenderer: 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'); @@ -1264,7 +1433,8 @@ describe('ContextMenu Plugin', () => { ...gridOptionsMock, enableExcelExport: true, enableTextExport: false, - contextMenu: { hideCopyCellValueCommand: true, hideExportCsvCommand: true, hideExportExcelCommand: false }, + contextMenu: { hideCommands: ['copy', 'export-csv'] }, + // contextMenu: { hideCopyCellValueCommand: true, hideExportCsvCommand: true, hideExportExcelCommand: false }, } as GridOption; vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(copyGridOptionsMock); vi.spyOn(gridStub, 'getOptions').mockReturnValue(copyGridOptionsMock); diff --git a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts index bd716d32b0..e681f14280 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 defaultMenuItemRenderer 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, + defaultMenuItemRenderer: 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 defaultMenuItemRenderer', () => { + 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, + defaultMenuItemRenderer: 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'); @@ -1282,6 +1483,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-pinning', positionOrder: 52, + action: expect.any(Function), }, ]); }); @@ -1302,6 +1504,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-filter', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1311,6 +1514,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-filter', positionOrder: 53, + action: expect.any(Function), }, { _orgTitle: '', @@ -1320,6 +1524,36 @@ describe('GridMenuControl', () => { disabled: false, command: 'refresh-dataset', positionOrder: 58, + action: expect.any(Function), + }, + ]); + }); + + it('should have only 1 menu "clear-filter" when all other menus are set in `hideCommands` with "enableFilering" is set', () => { + const copyGridOptionsMock: GridOption = { + ...gridOptionsMock, + enableFiltering: true, + showHeaderRow: true, + gridMenu: { + hideCommands: ['clear-pinning', 'refresh-dataset', 'toggle-filter', 'toggle-dark-mode'], + commandLabels: gridOptionsMock.gridMenu!.commandLabels, + }, + }; + vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(copyGridOptionsMock); + vi.spyOn(gridStub, 'getOptions').mockReturnValue(copyGridOptionsMock); + control.columns = columnsMock; + control.init(); + control.init(); // calling 2x register to make sure it doesn't duplicate commands + expect(SharedService.prototype.gridOptions.gridMenu!.commandItems).toEqual([ + { + _orgTitle: '', + iconCssClass: 'mdi mdi-filter-remove-outline', + titleKey: 'CLEAR_ALL_FILTERS', + title: 'Supprimer tous les filtres', + disabled: false, + command: 'clear-filter', + positionOrder: 50, + action: expect.any(Function), }, ]); }); @@ -1351,6 +1585,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-filter', positionOrder: 50, + action: expect.any(Function), }, ]); }); @@ -1362,6 +1597,7 @@ describe('GridMenuControl', () => { showHeaderRow: true, gridMenu: { commandLabels: gridOptionsMock.gridMenu!.commandLabels, + // @deprecated `hideXYZ`, replace by `hideCommands` in next major hideClearFrozenColumnsCommand: true, hideClearAllFiltersCommand: true, hideToggleDarkModeCommand: true, @@ -1382,6 +1618,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-filter', positionOrder: 53, + action: expect.any(Function), }, ]); }); @@ -1391,6 +1628,7 @@ describe('GridMenuControl', () => { ...gridOptionsMock, gridMenu: { commandLabels: gridOptionsMock.gridMenu!.commandLabels, + // @deprecated `hideXYZ`, replace by `hideCommands` in next major hideClearFrozenColumnsCommand: true, hideClearAllFiltersCommand: true, hideToggleFilterCommand: true, @@ -1412,6 +1650,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-dark-mode', positionOrder: 54, + action: expect.any(Function), }, ]); }); @@ -1423,6 +1662,7 @@ describe('GridMenuControl', () => { showHeaderRow: true, gridMenu: { commandLabels: gridOptionsMock.gridMenu!.commandLabels, + // @deprecated `hideXYZ`, replace by `hideCommands` in next major hideClearFrozenColumnsCommand: true, hideClearAllFiltersCommand: true, hideToggleDarkModeCommand: true, @@ -1443,6 +1683,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'refresh-dataset', positionOrder: 58, + action: expect.any(Function), }, ]); }); @@ -1463,6 +1704,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'toggle-preheader', positionOrder: 53, + action: expect.any(Function), }, ]); }); @@ -1502,6 +1744,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'clear-sorting', positionOrder: 51, + action: expect.any(Function), }, ]); }); @@ -1551,6 +1794,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-csv', positionOrder: 55, + action: expect.any(Function), }, ]); }); @@ -1603,6 +1847,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-excel', positionOrder: 56, + action: expect.any(Function), }, ]); }); @@ -1636,6 +1881,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-pdf', positionOrder: 57, + action: expect.any(Function), }, ]); }); @@ -1666,6 +1912,7 @@ describe('GridMenuControl', () => { disabled: false, command: 'export-text-delimited', positionOrder: 58, + action: expect.any(Function), }, ]); }); @@ -1912,8 +2159,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 +2181,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 +2215,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..2318e36c3d 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 defaultMenuItemRenderer 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.defaultMenuItemRenderer = 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 defaultMenuItemRenderer', () => { + 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.defaultMenuItemRenderer = 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; @@ -871,6 +1081,61 @@ describe('HeaderMenu Plugin', () => { vi.clearAllMocks(); }); + it('should expect menu related to Freeze Columns when "hideFreezeColumnsCommand" is disabled and also expect grid "setOptions" method to be called with current column position', async () => { + vi.spyOn(gridStub, 'validateColumnFreezeWidth').mockReturnValue(true); + const setOptionsSpy = vi.spyOn(gridStub, 'setOptions'); + vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue({ + ...gridOptionsMock, + headerMenu: { + hideCommands: ['column-resize-by-content', 'hide-column'], + }, + }); + + // calling `onBeforeSetColumns` 2x times shouldn't duplicate clear sort menu + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData as any, gridStub); + gridStub.onBeforeSetColumns.notify({ previousColumns: [], newColumns: columnsMock, grid: gridStub }, eventData as any, gridStub); + gridStub.onHeaderCellRendered.notify({ column: columnsMock[1], node: headerDiv, grid: gridStub }, eventData as any, gridStub); + const headerButtonElm = headerDiv.querySelector('.slick-header-menu-button') as HTMLDivElement; + headerButtonElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + + const commandDivElm = gridContainerDiv.querySelector('[data-command="freeze-columns"]') as HTMLDivElement; + const commandIconElm = commandDivElm.querySelector('.slick-menu-icon') as HTMLDivElement; + const commandLabelElm = commandDivElm.querySelector('.slick-menu-content') as HTMLDivElement; + expect(columnsMock[1].header!.menu!.commandItems!).toEqual([ + { + _orgTitle: '', + iconCssClass: 'mdi mdi-pin-outline', + title: 'Freeze Columns', + titleKey: 'FREEZE_COLUMNS', + command: 'freeze-columns', + positionOrder: 45, + action: expect.any(Function), + }, + { divider: true, command: 'divider-1', positionOrder: 48 }, + ]); + expect(commandIconElm.classList.contains('mdi-pin-outline')).toBeTruthy(); + expect(commandLabelElm.textContent).toBe('Freeze Columns'); + + await translateService.use('fr'); + plugin.translateHeaderMenu(); + expect(columnsMock[1].header!.menu!.commandItems!).toEqual([ + { + _orgTitle: '', + iconCssClass: 'mdi mdi-pin-outline', + title: 'Geler les colonnes', + titleKey: 'FREEZE_COLUMNS', + command: 'freeze-columns', + positionOrder: 45, + action: expect.any(Function), + }, + { divider: true, command: 'divider-1', positionOrder: 48 }, + ]); + + commandDivElm.dispatchEvent(new Event('click')); // execute command + expect(setOptionsSpy).toHaveBeenCalledWith({ frozenColumn: 1, enableMouseWheelScrollHandler: true }, false, true); + expect(gridStub.setColumns).toHaveBeenCalledWith(columnsMock); + }); + it('should expect menu related to Freeze Columns when "hideFreezeColumnsCommand" is disabled and also expect grid "setOptions" method to be called with current column position', async () => { vi.spyOn(gridStub, 'validateColumnFreezeWidth').mockReturnValue(true); const setOptionsSpy = vi.spyOn(gridStub, 'setOptions'); @@ -897,8 +1162,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); expect(commandIconElm.classList.contains('mdi-pin-outline')).toBeTruthy(); expect(commandLabelElm.textContent).toBe('Freeze Columns'); @@ -913,8 +1179,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); commandDivElm.dispatchEvent(new Event('click')); // execute command @@ -927,6 +1194,7 @@ describe('HeaderMenu Plugin', () => { const setOptionsSpy = vi.spyOn(gridStub, 'setOptions'); vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue({ ...gridOptionsMock, + // @deprecated `hideXYZ`, replace by `hideCommands` in next major headerMenu: { hideFreezeColumnsCommand: false, hideColumnHideCommand: true, hideColumnResizeByContentCommand: true }, frozenColumn: 1, }); @@ -949,8 +1217,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'UNFREEZE_COLUMNS', command: 'unfreeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); expect(commandIconElm.classList.contains('mdi-pin-off-outline')).toBeTruthy(); expect(commandLabelElm.textContent).toBe('Unfreeze Columns'); @@ -965,8 +1234,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'UNFREEZE_COLUMNS', command: 'unfreeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); commandDivElm.dispatchEvent(new Event('click')); // execute command @@ -981,6 +1251,7 @@ describe('HeaderMenu Plugin', () => { vi.spyOn(gridStub, 'validateColumnFreezeWidth').mockReturnValue(true); vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue({ ...gridOptionsMock, + // @deprecated `hideXYZ`, replace by `hideCommands` in next major headerMenu: { hideFreezeColumnsCommand: false, hideColumnHideCommand: true, hideColumnResizeByContentCommand: true }, }); vi.spyOn(gridStub, 'getOptions').mockReturnValueOnce({ frozenColumn: -1 } as GridOption); @@ -999,8 +1270,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); commandDivElm.dispatchEvent(new Event('click')); // execute command @@ -1018,6 +1290,7 @@ describe('HeaderMenu Plugin', () => { sharedService.hasColumnsReordered = true; vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue({ ...gridOptionsMock, + // @deprecated `hideXYZ`, replace by `hideCommands` in next major headerMenu: { hideFreezeColumnsCommand: false, hideColumnHideCommand: true, @@ -1040,8 +1313,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); expect(commandDivElm).toBeFalsy(); }); @@ -1051,6 +1325,7 @@ describe('HeaderMenu Plugin', () => { vi.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue({ ...gridOptionsMock, enableFiltering: true, + // @deprecated `hideXYZ`, replace by `hideCommands` in next major headerMenu: { hideFilterCommand: false, hideFreezeColumnsCommand: true, hideColumnHideCommand: true, hideColumnResizeByContentCommand: true }, }); @@ -1071,6 +1346,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; @@ -1115,9 +1391,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'COLUMN_RESIZE_BY_CONTENT', command: 'column-resize-by-content', positionOrder: 47, + action: expect.any(Function), + }, + { divider: true, command: 'divider-1', positionOrder: 48 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-close', + title: 'Hide Column', + titleKey: 'HIDE_COLUMN', + command: 'hide-column', + positionOrder: 59, + 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 }, ]; const commandDivElm = gridContainerDiv.querySelector('[data-command="column-resize-by-content"]') as HTMLDivElement; const commandIconElm = commandDivElm.querySelector('.slick-menu-icon') as HTMLDivElement; @@ -1160,6 +1445,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.' }, { @@ -1169,8 +1455,9 @@ describe('HeaderMenu Plugin', () => { positionOrder: 47, title: 'Resize by Content', titleKey: 'COLUMN_RESIZE_BY_CONTENT', + action: expect.any(Function), }, - { command: '', divider: true, positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, { _orgTitle: '', command: 'filter-shortcuts-root-menu', @@ -1197,8 +1484,16 @@ describe('HeaderMenu Plugin', () => { title: 'Filter Shortcuts', titleKey: 'FILTER_SHORTCUTS', }, - { command: '', divider: true, positionOrder: 56 }, - { _orgTitle: '', command: 'hide-column', iconCssClass: 'mdi mdi-close', positionOrder: 59, title: 'Hide Column', titleKey: 'HIDE_COLUMN' }, + { divider: true, command: 'divider-3', positionOrder: 56 }, + { + _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')); @@ -1242,9 +1537,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), + }, + { divider: true, command: 'divider-1', positionOrder: 48 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-close', + title: 'Hide Column', + titleKey: 'HIDE_COLUMN', + command: 'hide-column', + positionOrder: 59, + 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 }, ]; const commandDivElm = gridContainerDiv.querySelector('[data-command="hide-column"]') as HTMLDivElement; const commandIconElm = commandDivElm.querySelector('.slick-menu-icon') as HTMLDivElement; @@ -1280,6 +1584,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; @@ -1320,6 +1625,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1328,9 +1634,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), + }, + { divider: true, command: 'divider-2', positionOrder: 52 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-sort-variant-off', + title: 'Remove Sort', + titleKey: 'REMOVE_SORT', + command: 'clear-sort', + positionOrder: 58, + 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 }, ]); expect(commandIconElm.classList.contains('mdi-sort-variant-off')).toBeTruthy(); expect(commandLabelElm.textContent).toBe('Remove Sort'); @@ -1345,6 +1660,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1353,8 +1669,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 52 }, + { divider: true, command: 'divider-2', positionOrder: 52 }, { _orgTitle: '', iconCssClass: 'mdi mdi-sort-variant-off', @@ -1362,6 +1679,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'REMOVE_SORT', command: 'clear-sort', positionOrder: 58, + action: expect.any(Function), }, ]); @@ -1401,8 +1719,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); commandDivElm.dispatchEvent(new Event('click')); // execute command @@ -1445,8 +1764,9 @@ describe('HeaderMenu Plugin', () => { titleKey: 'FREEZE_COLUMNS', command: 'freeze-columns', positionOrder: 45, + action: expect.any(Function), }, - { divider: true, command: '', positionOrder: 48 }, + { divider: true, command: 'divider-1', positionOrder: 48 }, ]); commandDivElm.dispatchEvent(new Event('click')); // execute command @@ -1485,6 +1805,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1493,9 +1814,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), + }, + { divider: true, command: 'divider-2', positionOrder: 52 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-sort-variant-off', + title: 'Remove Sort', + titleKey: 'REMOVE_SORT', + command: 'clear-sort', + positionOrder: 58, + 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 }, ]); const clickEvent = new Event('click'); @@ -1537,6 +1867,7 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_ASCENDING', command: 'sort-asc', positionOrder: 50, + action: expect.any(Function), }, { _orgTitle: '', @@ -1545,9 +1876,18 @@ describe('HeaderMenu Plugin', () => { titleKey: 'SORT_DESCENDING', command: 'sort-desc', positionOrder: 51, + action: expect.any(Function), + }, + { divider: true, command: 'divider-2', positionOrder: 52 }, + { + _orgTitle: '', + iconCssClass: 'mdi mdi-sort-variant-off', + title: 'Remove Sort', + titleKey: 'REMOVE_SORT', + command: 'clear-sort', + positionOrder: 58, + 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 }, ]); 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/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 e2ed767321..31ef4e5bc1 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 = ''; @@ -131,12 +139,67 @@ export class MenuBaseClass