Help with Implementing Context Menu in Vaadin Grid in Angular

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