Skip to content

Rows and Columns Context Menu

This example demonstrates how to create separate context menus with interactive actions for grid rows and columns. Use the same ContextMenuPlugin, but pass row actions through rowContextMenu and column-header actions through columnContextMenu.

Key Features:

  • Separate row and column context menu configurations
  • Actions with visual indicators
  • Styled menu buttons with hover effects

Source code
TypeScript ts
// src/components/row-header/rowHeader.ts
import '@fortawesome/fontawesome-free/css/all.min.css';

import { defineCustomElements } from '@revolist/revogrid/loader';
defineCustomElements();


import { currentTheme, useRandomData } from '../composables/useRandomData';
import { ContextMenuPlugin, RowHeaderPlugin, ColumnStretchPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { columnContextMenuConfig, rowContextMenuConfig } from './context-menu.config';
const { createRandomData } = useRandomData();
const { isDark } = currentTheme();

export function load(parentSelector: string) {
  const parent = document.querySelector(parentSelector);
  if (!parent) {
    return;
  }

  const grid = document.createElement('revo-grid');
  grid.source = createRandomData(100);
  // Define columns
  grid.columns = [
    {
      name: '#',
      prop: 'id',
      size: 70,
      pin: 'colPinStart',
    },
    {
      name: '🍎 Fruit',
      prop: 'name',
    },
    {
      name: '💰 Price',
      prop: 'price',
      pin: 'colPinEnd',
    },
  ];
  // Define plugin
  grid.plugins = [RowHeaderPlugin, ContextMenuPlugin, ColumnStretchPlugin, RowOddPlugin];
  // grid.rowHeaders = rowHeaders({ showHeaderFocusBtn: true });

  Object.assign(grid, {
    // Define separate context menus for row and column targets
    rowContextMenu: rowContextMenuConfig,
    columnContextMenu: columnContextMenuConfig,
    stretch: 'all'
  })

  // Set theme
  grid.theme = isDark() ? 'darkMaterial' : 'material';
  grid.hideAttribution = true;
  parent.appendChild(grid);
}
Vue vue
<template>
  <RevoGrid
    class="rounded-lg overflow-hidden"
    :theme="isDark ? 'darkMaterial' : 'material'"
    :source="source"
    :columns="columns"
    :plugins="plugins"
    :row-context-menu.prop="rowContextMenuConfig"
    :column-context-menu.prop="columnContextMenuConfig"
    stretch="all"
    :rowHeaders="rowHeadersConfig"
    hideAttribution
    style="min-height: 300px;"
  />
</template>

<script setup lang="ts">
import RevoGrid, { type ColumnRegular } from '@revolist/vue3-datagrid';
import { ContextMenuPlugin, RowHeaderPlugin, ColumnStretchPlugin, RowOddPlugin, RowOrderPlugin, rowHeaders } from '@revolist/revogrid-pro';
import { ref } from 'vue';
import { columnContextMenuConfig, rowContextMenuConfig } from './context-menu.config';
import { makeData } from '../composables/makeData';
import { currentThemeVue } from '../composables/useRandomData';

const { isDark } = currentThemeVue();

const source = ref(makeData(100));

const columns: ColumnRegular[] = [
  {
    name: '#',
    prop: 'id',
    size: 150,
    pin: 'colPinStart',
  },
  {
    name: 'Name',
    prop: 'fullName',
  },
  {
    name: 'Job Title',
    prop: 'jobTitle',
    pin: 'colPinEnd',
  },
];

const plugins = [RowHeaderPlugin, ContextMenuPlugin, ColumnStretchPlugin, RowOddPlugin, RowOrderPlugin];

const rowHeadersConfig = ref(
  rowHeaders({ showHeaderFocusBtn: false, rowDrag: true }),
);
</script>

<style lang="css" src="@fortawesome/fontawesome-free/css/all.min.css"/>

<style scoped>
:deep(.rowHeaders) {
  revogr-data .rgCell {
    padding: 0 !important;
  }
}

:deep(.row-header-holder) {
  button {
    width: 100%;
    border: 0;
    background: none;

    &:hover {
      background-color: var(--sl-color-gray-6);
    }
  }
}
</style>
React tsx
import '@fortawesome/fontawesome-free/css/all.min.css';

import React, { useMemo } from 'react';
import { RevoGrid, type ColumnRegular } from '@revolist/react-datagrid';
import { ContextMenuPlugin, RowHeaderPlugin, ColumnStretchPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { columnContextMenuConfig, rowContextMenuConfig } from './context-menu.config';
import { makeData } from '../composables/makeData';
import { currentTheme } from '../composables/useRandomData';

const { isDark } = currentTheme();

function ContextMenu() {
  const source = useMemo(() => makeData(100), []);

  const columns: ColumnRegular[] = useMemo(
    () => [
      {
        name: '#',
        prop: 'id',
        size: 70,
        pin: 'colPinStart',
      },
      {
        name: '🍎 Fruit',
        prop: 'name',
      },
      {
        name: '💰 Price',
        prop: 'price',
        pin: 'colPinEnd',
      },
    ],
    [],
  );

  const plugins = [RowHeaderPlugin, ContextMenuPlugin, ColumnStretchPlugin, RowOddPlugin] as any;

  const RevoGridComponent = RevoGrid as any;
  
  return (
    <RevoGridComponent
      theme={isDark() ? 'darkMaterial' : 'material'}
      source={source}
      columns={columns}
      plugins={plugins}
      rowContextMenu={rowContextMenuConfig}
      columnContextMenu={columnContextMenuConfig}
      stretch="all"
      hideAttribution
      style={{ minHeight: '300px' }}
    />
  );
}

export default ContextMenu;
Angular ts
import '@fortawesome/fontawesome-free/css/all.min.css';

import { Component, ViewEncapsulation, NO_ERRORS_SCHEMA } from '@angular/core';
import { defineCustomElements } from '@revolist/revogrid/loader';
import { ContextMenuPlugin, RowHeaderPlugin, ColumnStretchPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { columnContextMenuConfig, rowContextMenuConfig } from './context-menu.config';
import { makeData } from '../composables/makeData';
import { currentTheme } from '../composables/useRandomData';

defineCustomElements();

@Component({
  selector: 'context-menu-grid',
  standalone: true,
  imports: [],
  template: `
    <revo-grid
      [source]="source"
      [columns]="columns"
      [plugins]="plugins"
      [theme]="theme"
      [rowContextMenu]="rowContextMenu"
      [columnContextMenu]="columnContextMenu"
      [stretch]="stretch"
      [hideAttribution]="true"
      range
      style="min-height: 300px;"
    ></revo-grid>
  `,
  encapsulation: ViewEncapsulation.None,
  // Allows Angular demos to bind RevoGrid plugin props that are not wrapper inputs.
  schemas: [NO_ERRORS_SCHEMA],
})
export class ContextMenuGridComponent {
  source = makeData(100);

  columns = [
    {
      name: '#',
      prop: 'id',
      size: 150,
      pin: 'colPinStart',
    },
    {
      name: 'Name',
      prop: 'fullName',
    },
    {
      name: 'Job Title',
      prop: 'jobTitle',
      pin: 'colPinEnd',
    },
  ];

  plugins = [RowHeaderPlugin, ContextMenuPlugin, ColumnStretchPlugin, RowOddPlugin];

  theme = currentTheme().isDark() ? 'darkMaterial' : 'material';

  rowContextMenu = rowContextMenuConfig;

  columnContextMenu = columnContextMenuConfig;

  stretch = 'all';
}
Config ts
import type {
  ColumnContextMenuOpenContext,
  ContextMenuActionContext,
  ContextMenuConfig,
  ContextMenuItem,
} from '@revolist/revogrid-pro';
import type { ColumnData, ColumnGrouping, ColumnProp, ColumnRegular } from '@revolist/revogrid';

// Buffer to store copied/cut row data.
let rowBuffer: any = null;

function getColumnContext(context?: ContextMenuActionContext): ColumnContextMenuOpenContext | undefined {
  return context?.menu?.target === 'column' ? context.menu : undefined;
}

function getGrid(context?: ContextMenuActionContext) {
  return context?.revogrid;
}

function updateColumn(context: ContextMenuActionContext | undefined, updater: (column: ColumnRegular) => ColumnRegular) {
  const columnContext = getColumnContext(context);
  const grid = getGrid(context);
  if (!grid || !columnContext?.column) {
    return;
  }

  const updatedColumns = updateColumnsByProp(
    grid.columns || [],
    columnContext.column.prop,
    updater,
  );

  grid.columns = updatedColumns;
}

function sortColumn(context: ContextMenuActionContext | undefined, order: 'asc' | 'desc') {
  const columnContext = getColumnContext(context);
  const grid = getGrid(context);
  if (!grid || !columnContext?.column) {
    return;
  }

  updateColumn(context, column => ({
    ...column,
    sortable: true,
  }));

  void grid.updateColumnSorting(
    {
      prop: columnContext.column.prop,
      cellCompare: columnContext.column.cellCompare,
    },
    order,
    false,
  );
}

function isColumnGrouping(column: ColumnGrouping | ColumnRegular): column is ColumnGrouping {
  return Array.isArray((column as ColumnGrouping).children);
}

function updateColumnsByProp(
  columns: ColumnData,
  prop: ColumnProp,
  updater: (column: ColumnRegular) => ColumnRegular,
): ColumnData {
  return columns.map(column => {
    if (isColumnGrouping(column)) {
      return {
        ...column,
        children: updateColumnsByProp(column.children, prop, updater),
      };
    }

    if (column.prop !== prop) {
      return column;
    }

    return updater({ ...column });
  });
}

export const rowContextMenuConfig: ContextMenuConfig = {
  items: [
    {
      icon: 'fa-solid fa-copy',
      name: 'Copy row',
      action: (_, cell, __, ____, context) => {
        const grid = getGrid(context);
        if (!cell || !grid) return;
        // todo: it's virtual index, we need to convert it to physical index, it's not the same as the source index
        rowBuffer = { ...grid.source[cell.y] };
      },
    },
    {
      icon: 'fa-solid fa-cut',
      name: 'Cut row',
      action: (_, cell, __, ____, context) => {
        const grid = getGrid(context);
        if (!cell || !grid) return;
        rowBuffer = { ...grid.source[cell.y] };
        // todo: it's virtual index, we need to convert it to physical index, it's not the same as the source index
        grid.source.splice(cell.y, 1);
        grid.source = [...grid.source];
      },
    },
    {
      icon: 'fa-solid fa-paste',
      name: 'Paste row',
      hidden: () => !rowBuffer,
      action: (_, cell, __, ____, context) => {
        const grid = getGrid(context);
        if (!cell || !rowBuffer || !grid) return;
        const newRow = { ...rowBuffer };
        // todo: it's virtual index, we need to convert it to physical index
        // it's not the same as the source index
        grid.source.splice(cell.y + 1, 0, newRow);
        grid.source = [...grid.source];
      },
    },
    {
      icon: 'fa-solid fa-arrow-up',
      name: 'Add row above',
      action: (_, cell, __, ____, context) => {
        const grid = getGrid(context);
        if (!cell || !grid) {
          return;
        }
        // todo: it's virtual index, we need to convert it to physical index
        // it's not the same as the source index
        grid.source.splice(cell.y, 0, {
          id: 0,
          name: 'New row',
          price: 0,
        });
        grid.source = [...grid.source];
      },
    },
    {
      icon: 'fa-solid fa-arrow-down',
      name: 'Add row below',
      action: (_, cell, __, ____, context) => {
        const grid = getGrid(context);
        if (!cell || !grid) {
          return;
        }
        // todo: it's virtual index, we need to convert it to physical index
        // it's not the same as the source index
        grid.source.splice(cell.y + 1, 0, {
          id: 0,
          name: 'New row',
          price: 0,
        });
        grid.source = [...grid.source];
      },
    },
    {
      icon: 'fa-solid fa-trash',
      name: (focused, range) => {
        if (!focused) {
          return '';
        }
        if (!range) {
          range = {
            x: 0,
            y: focused.y,
            x1: 0,
            y1: focused.y,
          };
        }
        // todo: it's virtual index, we need to convert it to physical index
        // it's not the same as the source index
        const rows = range.y1 - range.y + 1;
        if (!range || rows < 2) {
          return 'Delete row';
        }
        return `Delete ${rows} rows`;
      },
      action: (_, focused, range, __, context) => {
        const grid = getGrid(context);
        if (!focused || !grid) {
          return;
        }

        if (!range) {
          range = {
            x: 0,
            y: focused.y,
            x1: 0,
            y1: focused.y,
          };
        }

        const rows = range.y1 - range.y + 1;
        // todo: it's virtual index, we need to convert it to physical index
        // it's not the same as the source index
        grid.source.splice(range.y, rows);
        grid.source = [...grid.source];
      },
    },
  ],
};

const sortColumnItems: ContextMenuItem[] = [
  {
    icon: 'fa-solid fa-arrow-up-a-z',
    name: 'Sort ascending',
    action: (_, __, ___, ____, context) => sortColumn(context, 'asc'),
  },
  {
    icon: 'fa-solid fa-arrow-down-z-a',
    name: 'Sort descending',
    action: (_, __, ___, ____, context) => sortColumn(context, 'desc'),
  },
];

export const columnContextMenuConfig: ContextMenuConfig = {
  resolve: (context) => {
    if (context.target !== 'column') {
      return;
    }

    if (context.column?.prop === 'id') {
      return {
        items: idColumnItems,
        anchorToTarget: true,
      };
    }

    if (context.columnType === 'colPinStart') {
      return {
        items: pinnedStartColumnItems,
        anchorToTarget: true,
      };
    }

    if (context.columnType === 'colPinEnd') {
      return {
        items: pinnedEndColumnItems,
        anchorToTarget: true,
      };
    }
  },
  items: [
    ...sortColumnItems,
    {
      icon: 'fa-solid fa-thumbtack',
      name: 'Pin column left',
      action: (_, __, ___, ____, context) => updateColumn(context, column => ({
        ...column,
        pin: 'colPinStart',
      })),
    },
    {
      icon: 'fa-solid fa-thumbtack',
      name: 'Pin column right',
      action: (_, __, ___, ____, context) => updateColumn(context, column => ({
        ...column,
        pin: 'colPinEnd',
      })),
    },
    {
      icon: 'fa-solid fa-circle-info',
      name: 'Log column info',
      action: (_, __, ___, ____, context) => {
        const columnContext = getColumnContext(context);
        console.log('Column info', {
          column: columnContext?.column,
          columnIndex: columnContext?.columnIndex,
          columnType: columnContext?.columnType,
        });
      },
    },
  ],
};

const idColumnItems: ContextMenuItem[] = [
  {
    icon: 'fa-solid fa-fingerprint',
    name: 'ID column',
    action: (_, __, ___, ____, context) => {
      console.log('ID column context', getColumnContext(context));
    },
  },
  ...sortColumnItems,
];

const pinnedStartColumnItems: ContextMenuItem[] = [
  ...sortColumnItems,
  {
    icon: 'fa-solid fa-thumbtack',
    name: 'Unpin left column',
    action: (_, __, ___, ____, context) => updateColumn(context, column => {
      delete column.pin;
      return column;
    }),
  },
];

const pinnedEndColumnItems: ContextMenuItem[] = [
  ...sortColumnItems,
  {
    icon: 'fa-solid fa-thumbtack',
    name: 'Unpin right column',
    action: (_, __, ___, ____, context) => updateColumn(context, column => {
      delete column.pin;
      return column;
    }),
  },
  {
    icon: 'fa-solid fa-circle-info',
    name: 'Log pinned column info',
    action: (_, __, ___, ____, context) => {
      const columnContext = getColumnContext(context);
      console.log('Pinned column info', {
        column: columnContext?.column,
        columnIndex: columnContext?.columnIndex,
        columnType: columnContext?.columnType,
      });
    },
  },
];