Hello Vaadin Community,
I am working on an Angular application where I am using the Vaadin Grid component. I am trying to implement the context menu feature in the grid, but despite referring to multiple documentation sources, including the official Vaadin documentation, I am unable to get it to work.
I have tried multiple approaches based on the docs, but none of them seem to work for my specific case. Could anyone please help me with the correct Angular code to implement a context menu in the Vaadin Grid?
For context, I have already tried using the examples from the official Vaadin documentation and even sought help from ChatGPT, but unfortunately, I have not been able to resolve the issue.
I have attached some screenshots that might help in understanding the issue better.
Thank you in advance for your help!
This is my code:
html:
<!-- Search and Filter Section -->
<div class="p-4 bg-gray-50 border-b">
<div class="mt-2">
<input [(ngModel)]="searchQuery" (input)="filterItems()" placeholder="Search..."
class="mt-1 p-2 border border-gray-300 rounded-md w-full focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div class="space-x-4 my-4 mx-2">
<button (click)="addRow()"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition duration-300 ease-in-out">
Add Row
</button>
<button (click)="removeRow()"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition duration-300 ease-in-out">
Remove Last Row
</button>
<button (click)="addColumn()"
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition duration-300 ease-in-out">
Add Column
</button>
<button (click)="removeColumn()"
class="px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition duration-300 ease-in-out">
Remove Last Column
</button>
</div>
<div class="space-x-4 flex justify-center items-center">
<!-- First Name Filter -->
<div>
<label for="firstNameFilter" class="block text-sm font-semibold text-gray-700">First Name:</label>
<input id="firstNameFilter" [(ngModel)]="filters.firstName" (input)="filterItemsByFirstName()"
placeholder="Filter by First Name"
class="mt-1 p-2 border border-gray-300 rounded-md w-full focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<!-- Last Name Filter -->
<div>
<label for="lastNameFilter" class="block text-sm font-semibold text-gray-700">Last Name:</label>
<input id="lastNameFilter" [(ngModel)]="filters.lastName" (input)="filterItemsByLastName()"
placeholder="Filter by Last Name"
class="mt-1 p-2 border border-gray-300 rounded-md w-full focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<!-- Address Filter -->
<div>
<label for="addressFilter" class="block text-sm font-semibold text-gray-700">Address:</label>
<input id="addressFilter" [(ngModel)]="filters.address" (input)="filterItemsByAddress()"
placeholder="Filter by Address"
class="mt-1 p-2 border border-gray-300 rounded-md w-full focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<!-- General Search -->
</div>
<!-- Vaadin Grid Section -->
<div class="p-4">
<!-- <vaadin-grid #grid [items]="filteredItems" column-reordering-allowed multi-sort class="w-full shadow-sm rounded-lg"
theme="wrap-cell-content">
<vaadin-grid-selection-column></vaadin-grid-selection-column>
<ng-container *ngFor="let col of columns">
<vaadin-grid-sort-column *ngIf="col.sortable && !col.dateFormat; else normalColumn" [path]="col.path"
[header]="col.header" [frozen]="col.frozen">
</vaadin-grid-sort-column>
<ng-template #normalColumn>
<vaadin-grid-column [path]="col.path" [header]="col.header" [renderer]="dateRenderer" [frozen]="col.frozen"
[editable]="col.editable"></vaadin-grid-column>
</ng-template>
</ng-container>
</vaadin-grid> -->
<vaadin-context-menu #contextMenu>
<template>
<vaadin-list-box>
<vaadin-item (click)="handleMenuAction('Edit')">Edit</vaadin-item>
<vaadin-item (click)="handleMenuAction('Delete')">Delete</vaadin-item>
<hr />
<vaadin-item *ngIf="selectedPerson" (click)="handleMenuAction('Email')">
Email
</vaadin-item>
<vaadin-item *ngIf="selectedPerson" (click)="handleMenuAction('Call')">
Call
</vaadin-item>
</vaadin-list-box>
</template>
</vaadin-context-menu>
<vaadin-grid #grid [items]="people" (contextmenu)="openContextMenu($event)">
<vaadin-grid-column path="firstName"></vaadin-grid-column>
<vaadin-grid-column path="lastName"></vaadin-grid-column>
<vaadin-grid-column path="email"></vaadin-grid-column>
<vaadin-grid-column path="profession"></vaadin-grid-column>
</vaadin-grid>
<vaadin-context-menu #contextMenu>
<span>Open a context menu with <b>right-click</b> or <b>long press</b>.</span>
</vaadin-context-menu>
</div>
<!-- Export Button -->
<div class="p-4 text-center">
<button (click)="exportToExcel()"
class="bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400">
Export to Excel
</button>
</div>
typescript code:
// import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild ,AfterViewInit} from '@angular/core';
import { saveAs } from 'file-saver';
import '@vaadin/grid';
import '@vaadin/grid/vaadin-grid-column-group.js';
import '@vaadin/grid/vaadin-grid-filter-column.js';
import '@vaadin/grid/vaadin-grid-selection-column.js';
import '@vaadin/grid/vaadin-grid-sort-column.js';
import '@vaadin/grid/vaadin-grid-tree-column.js';
import '@vaadin/tooltip/theme/material/vaadin-tooltip.js';
import { Component, ViewChild, ElementRef, AfterViewInit, ChangeDetectionStrategy,CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@vaadin/context-menu';
import { FormsModule } from '@angular/forms';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import * as XLSX from 'xlsx';
import { format } from 'date-fns'; // Import the format function from date-fns
export interface Person {
firstName: string;
lastName: string;
email: string;
profession: string;
address: { phone: string };
}
@Component({
selector: 'app-root',
imports: [FormsModule,CommonModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
schemas: [CUSTOM_ELEMENTS_SCHEMA], // Add this line
})
export class AppComponent {
@ViewChild('grid', { static: false }) grid!: ElementRef;
editRow($event: MouseEvent) {
throw new Error('Method not implemented.');
}
people: Person[] = [
{ firstName: 'John', lastName: 'Doe', email: 'john@example.com', profession: 'Engineer', address: { phone: '1234567890' } },
{ firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com', profession: 'Designer', address: { phone: '9876543210' } }
];
selectedPerson: Person | null = null; // Store selected person
@ViewChild('contextMenu', { static: false }) contextMenu!: ElementRef;
preventHeaderContextMenu(event: any) {
const grid = this.grid.nativeElement;
const context = grid.getEventContext(event);
if (context.section !== 'body') {
event.stopPropagation();
}
}
openContextMenu(event: MouseEvent) {
event.preventDefault();
// Cast ElementRef to GridElement to access Vaadin grid's methods
const vaadinGrid = this.grid.nativeElement;
const selectedItems = vaadinGrid.selectedItems; // Access selectedItems
if (selectedItems && selectedItems.length > 0) {
this.selectedPerson = selectedItems[0]; // Get the selected person
}
// Open the context menu at the clicked position
const contextMenu = this.contextMenu?.nativeElement;
contextMenu.open({ x: event.clientX, y: event.clientY });
}
// Menu item action handler
handleMenuAction(action: string) {
if (this.selectedPerson) {
console.log(`${action} clicked for ${this.selectedPerson.firstName} ${this.selectedPerson.lastName}`);
}
}
// // Handle right-click on a row to open the menu
// openContextMenu(event: any) {
// const grid = this.grid.nativeElement;
// const context = grid.getEventContext(event);
// if (context.item) {
// this.selectedPerson = context.item;
// this.contextMenu.nativeElement.Open(event); // Open menu at event location
// }
// }
// handleMenuAction(action: string) {
// if (this.selectedPerson) {
// console.log(`${action}: ${this.selectedPerson.firstName} ${this.selectedPerson.lastName}`);
// }
// }
ngAfterViewInit(): void {
setTimeout(() => {
if (!this.contextMenu) return;
const contextMenu = this.contextMenu.nativeElement;
contextMenu.renderer = (root: HTMLElement) => {
// Ensure we clear old content
root.innerHTML = '';
// Create vaadin-list-box and add it to root
const listBox = document.createElement('vaadin-list-box');
listBox.setAttribute('style', 'display: block; width: 100%;'); // Forces vertical stacking
root.appendChild(listBox);
// Create menu items
const items = ['First', 'Second', 'Third'];
items.forEach((name) => {
const item = document.createElement('vaadin-item');
item.textContent = `${name} menu item`;
listBox.appendChild(item);
});
// DEBUG: Ensure items are added
console.log('Context menu rendered:', listBox.innerHTML);
};
});
}
searchQuery: string = '';
// @ViewChild('grid') grid!: ElementRef;
@ViewChild('searchBox') searchBox!: ElementRef;
// @ViewChild('contextMenu', { static: false }) contextMenu!: ElementRef;
// ngAfterViewInit() {
// const gridElement = this.grid.nativeElement as any;
// const contextMenuElement = this.contextMenu.nativeElement as any;
// // Set context menu items
// contextMenuElement.items = this.contextMenuItems;
// // Prevent context menu on grid headers
// gridElement.addEventListener('vaadin-contextmenu', (event: Event) => {
// const context = gridElement.getEventContext(event);
// if (context.section !== 'body') {
// event.stopPropagation();
// }
// });
// }
columns = [
{ path: 'id', header: 'Id', width: '15rem' ,sortable: true, visible: true, frozen: "true", editable: true },
{ path: 'firstName', header: 'First Name', width: '9rem', sortable: true, visible: true, frozen: "false", editable: true },
{ path: 'lastName', header: 'Last Name', width: '9rem', sortable: true, visible: true, frozen: "false", editable: true },
{ path: 'address', header: 'Address', width: '15rem', sortable: false, visible: true, frozen: "false", editable: true },// Special handling
{ path: 'joiningDate', header: 'Joining Date', sortable: true, dateFormat: 'dd-MM-yyyy', visible: true, frozen: "false", editable: true },
];
filters = {
firstName: '',
lastName: '',
address: ''
};
items = [
{ id: 1, firstName: 'Alice', lastName: 'Johnson', address: '123 Main St, Springfield', joiningDate: '2020-01-15' },
{ id: 2, firstName: 'Bob', lastName: 'Smith', address: '456 Oak St, Shelbyville', joiningDate: '2019-02-10' },
{ id: 3, firstName: 'Charlie', lastName: 'Brown', address: '789 Pine St, Capital City', joiningDate: '2018-06-22' },
{ id: 4, firstName: 'Diana', lastName: 'White', address: '101 Maple St, Springfield', joiningDate: '2021-04-30' },
{ id: 5, firstName: 'Eve', lastName: 'Davis', address: '202 Elm St, Rivertown', joiningDate: '2017-08-18' },
{ id: 6, firstName: 'Frank', lastName: 'Wilson', address: '303 Birch St, Lakeside', joiningDate: '2022-11-12' },
{ id: 7, firstName: 'Grace', lastName: 'Moore', address: '404 Cedar St, Greenwood', joiningDate: '2020-05-14' },
{ id: 8, firstName: 'Henry', lastName: 'Taylor', address: '505 Pine St, Hill Valley', joiningDate: '2021-03-25' },
{ id: 9, firstName: 'Ivy', lastName: 'Anderson', address: '606 Oak St, Riverdale', joiningDate: '2019-09-09' },
{ id: 10, firstName: 'Jack', lastName: 'Thomas', address: '707 Maple St, Lakeside', joiningDate: '2018-12-03' },
{ id: 11, firstName: 'Katherine', lastName: 'Jackson', address: '808 Elm St, Bayside', joiningDate: '2021-01-07' },
{ id: 12, firstName: 'Liam', lastName: 'Martin', address: '909 Birch St, Sunset City', joiningDate: '2019-06-18' },
{ id: 13, firstName: 'Mia', lastName: 'Garcia', address: '1010 Pine St, Northwood', joiningDate: '2022-04-20' },
{ id: 14, firstName: 'Noah', lastName: 'Harris', address: '1111 Oak St, Greenfield', joiningDate: '2020-07-30' },
{ id: 15, firstName: 'Olivia', lastName: 'Clark', address: '1212 Cedar St, Maplewood', joiningDate: '2021-09-05' },
{ id: 16, firstName: 'Paul', lastName: 'Lewis', address: '1313 Elm St, Lakeview', joiningDate: '2018-04-15' },
{ id: 17, firstName: 'Quincy', lastName: 'Walker', address: '1414 Birch St, Springfield', joiningDate: '2019-11-21' },
{ id: 18, firstName: 'Rachel', lastName: 'Hall', address: '1515 Pine St, Rivertown', joiningDate: '2021-07-22' },
{ id: 19, firstName: 'Samuel', lastName: 'Young', address: '1616 Oak St, Hilltop', joiningDate: '2022-02-10' },
{ id: 20, firstName: 'Tina', lastName: 'King', address: '1717 Maple St, Southtown', joiningDate: '2021-12-15' },
{ id: 21, firstName: 'Ursula', lastName: 'Scott', address: '1818 Cedar St, Woodland', joiningDate: '2020-08-04' },
{ id: 22, firstName: 'Victor', lastName: 'Adams', address: '1919 Elm St, Bayside', joiningDate: '2019-03-03' },
{ id: 23, firstName: 'Wendy', lastName: 'Baker', address: '2020 Birch St, Riverdale', joiningDate: '2022-09-25' },
{ id: 24, firstName: 'Xander', lastName: 'Green', address: '2121 Pine St, Hill Valley', joiningDate: '2018-07-09' },
{ id: 25, firstName: 'Yara', lastName: 'Carter', address: '2222 Oak St, Sunset City', joiningDate: '2021-05-30' },
{ id: 26, firstName: 'Zane', lastName: 'Nelson', address: '2323 Cedar St, Northwood', joiningDate: '2019-01-12' }
];
filteredItems = [...this.items];
contextMenuItems = [
{ text: 'Edit', action: () => console.log('Edit clicked') },
{ text: 'Delete', action: () => console.log('Delete clicked') },
];
toggleColumnVisibility(columnKey: string) {
const column = this.columns.find(c => c.path === columnKey);
if (column) {
column.visible = !column.visible;
}
}
imageRenderer = (root: HTMLElement, _column: any, model: any) => {
const item = model.item;
root.innerHTML = `<img src="${item.pictureUrl}" alt="${item.firstName} ${item.lastName}" class="avatar-img">`;
};
dateRenderer = (root: HTMLElement, _column: any, model: any) => {
const { item } = model;
const column = this.columns.find(col => col.path === _column.path);
if (column && column.dateFormat) {
const date = new Date(item[column.path]); // Convert to Date object
const formattedDate = format(date, column.dateFormat); // Apply user-defined format
root.textContent = formattedDate;
} else {
root.textContent = item[_column.path]; // Default rendering for non-date columns
}
};
filterItemsByFirstName() {
console.log(this.filters.firstName)
this.filteredItems = this.items.filter(item =>
item.firstName.toLowerCase().includes(this.filters.firstName.toLowerCase())
);
console.log(this.filteredItems)
}
filterItemsByLastName() {
this.filteredItems = this.items.filter(item =>
item.lastName.toLowerCase().includes(this.filters.lastName.toLowerCase())
);
console.log(this.filteredItems)
}
filterItemsByAddress() {
console.log(this.filters.firstName)
this.filteredItems = this.items.filter(item =>
item.address.toLowerCase().includes(this.filters.address.toLowerCase())
);
console.log(this.filteredItems)
}
addRow() {
const newId = this.items.length + 1;
const newRow = { id: newId, firstName: 'Zane', lastName: 'Nelson', address: '2323 Cedar St, Northwood', joiningDate: '2019-01-12' }
this.items.push(newRow);
this.filteredItems = [...this.items]; // Update filtered items
}
// Method to remove the last row
removeRow() {
if (this.items.length > 0) {
this.items.pop();
this.filteredItems = [...this.items]; // Update filtered items
}
}
// Method to add a new column
addColumn() {
const newColumn = { path: 'new', header: 'New COL', width: '9rem', sortable: true, visible: true, frozen: "true" ,editable:true}
this.columns.push(newColumn);
}
// Method to remove the last column
removeColumn() {
if (this.columns.length > 0) {
this.columns.pop();
}
}
filterItems() {
const query = this.searchQuery.toLowerCase();
this.filteredItems = this.items.filter(item =>
// Convert id to string for searching
item.id.toString().toLowerCase().includes(query) ||
item.firstName.toLowerCase().includes(query) ||
item.lastName.toLowerCase().includes(query) ||
item.address.toLowerCase().includes(query) ||
// Convert date to string for searching
item.joiningDate.toLowerCase().includes(query)
);
}
exportToExcel() {
const headers = ['ID', 'First Name', 'Last Name', 'Address', 'DOJ'];
// Convert data to an array of arrays
const rows = this.items.map(item => [
item.id,
item.firstName,
item.lastName,
item.address,
item.joiningDate
]);
// Create a worksheet
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows]);
// Create a workbook and append the worksheet
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
// Generate the XLSX file and trigger the download
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(data, 'grid-data.xlsx');
}
actionsRenderer = (root: HTMLElement, _column: any, model: any) => {
const item = model.item;
root.innerHTML = '';
// Create main container
const container = document.createElement('div');
container.className = 'relative inline-block text-left';
// Create action button
const button = document.createElement('button');
button.className = 'p-2 text-gray-600 hover:text-gray-800 focus:outline-none';
button.innerHTML = '⋮'; // Vertical ellipsis
// Create dropdown menu
const dropdown = document.createElement('div');
dropdown.className = 'absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 hidden';
dropdown.style.zIndex = '1000';
// Create menu items
const menuItems = [
{ icon: '👁️', text: 'View', action: () => this.onView(item) },
{ icon: '✏️', text: 'Edit', action: () => this.onEdit(item) },
{ icon: '🗑️', text: 'Delete', action: () => this.onDelete(item) }
];
const menuList = document.createElement('div');
menuList.className = 'py-1';
menuItems.forEach(menuItem => {
const menuItemElement = document.createElement('a');
menuItemElement.className = 'flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer';
menuItemElement.innerHTML = `
<span class="mr-2">${menuItem.icon} >${menuItem.text}</span>
`;
menuItemElement.addEventListener('click', (e) => {
e.stopPropagation();
menuItem.action();
dropdown.classList.add('hidden');
});
menuList.appendChild(menuItemElement);
});
dropdown.appendChild(menuList);
// Handle button click
button.addEventListener('click', (e) => {
e.stopPropagation();
const allDropdowns = document.querySelectorAll('.action-dropdown');
allDropdowns.forEach(d => {
if (d !== dropdown) {
d.classList.add('hidden');
}
});
dropdown.classList.toggle('hidden');
});
// Close dropdown when clicking outside
document.addEventListener('click', () => {
dropdown.classList.add('hidden');
});
// Add classes for dropdown positioning
// dropdown.classList.add('action-dropdown');
// Append elements
container.appendChild(dropdown);
container.appendChild(button);
root.appendChild(container);
};
// Add these new methods to your component class
onView(item: any) {
console.log('View clicked for item:', item);
// Implement your view logic here
}
onEdit(item: any) {
console.log('Edit clicked for item:', item);
// Implement your edit logic here
}
onDelete(item: any) {
console.log('Delete clicked for item:', item);
// Implement your delete logic here
}
onContextMenuOpened(event: any) {
// Set context menu items dynamically if needed
event.detail.contextMenu.items = this.contextMenuItems;
}
onGridContextMenu(event: any) {
// Prevent context menu on headers
const grid = event.target;
if (grid.getEventContext(event).section !== 'body') {
event.stopPropagation();
}
}
}
here are some screenshots:
i want to implement like this:
on clicking elipsis we can see menu showing view ,edit and delete option
please provide angular code for this