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 { currentTheme, useRandomData } from '../composables/useRandomData';
const { isDark } = currentTheme();
const { createRandomData } = useRandomData();
function ContextMenu() {
const source = useMemo(() => createRandomData(100), []);
const columns: ColumnRegular[] = useMemo(
() => [
{
name: '#',
prop: 'id',
size: 70,
pin: 'colPinStart',
},
{
name: '🍎 Fruit',
prop: 'name',
},
{
name: '💰 Price',
prop: 'price',
pin: 'colPinEnd',
},
],
[],
);
const plugins = useMemo(
() => [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';
import copySvg from '@fortawesome/fontawesome-free/svgs/solid/copy.svg?raw';
import scissorsSvg from '@fortawesome/fontawesome-free/svgs/solid/scissors.svg?raw';
import pasteSvg from '@fortawesome/fontawesome-free/svgs/solid/paste.svg?raw';
import arrowUpSvg from '@fortawesome/fontawesome-free/svgs/solid/arrow-up.svg?raw';
import arrowDownSvg from '@fortawesome/fontawesome-free/svgs/solid/arrow-down.svg?raw';
import trashSvg from '@fortawesome/fontawesome-free/svgs/solid/trash.svg?raw';
import sortAscSvg from '@fortawesome/fontawesome-free/svgs/solid/arrow-up-a-z.svg?raw';
import sortDescSvg from '@fortawesome/fontawesome-free/svgs/solid/arrow-down-z-a.svg?raw';
import thumbtackSvg from '@fortawesome/fontawesome-free/svgs/solid/thumbtack.svg?raw';
import infoSvg from '@fortawesome/fontawesome-free/svgs/solid/circle-info.svg?raw';
import fingerprintSvg from '@fortawesome/fontawesome-free/svgs/solid/fingerprint.svg?raw';
// 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: copySvg,
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: scissorsSvg,
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: pasteSvg,
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: arrowUpSvg,
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: arrowDownSvg,
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: trashSvg,
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: sortAscSvg,
name: 'Sort ascending',
action: (_, __, ___, ____, context) => sortColumn(context, 'asc'),
},
{
icon: sortDescSvg,
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: thumbtackSvg,
name: 'Pin column left',
action: (_, __, ___, ____, context) => updateColumn(context, column => ({
...column,
pin: 'colPinStart',
})),
},
{
icon: thumbtackSvg,
name: 'Pin column right',
action: (_, __, ___, ____, context) => updateColumn(context, column => ({
...column,
pin: 'colPinEnd',
})),
},
{
icon: infoSvg,
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: fingerprintSvg,
name: 'ID column',
action: (_, __, ___, ____, context) => {
console.log('ID column context', getColumnContext(context));
},
},
...sortColumnItems,
];
const pinnedStartColumnItems: ContextMenuItem[] = [
...sortColumnItems,
{
icon: thumbtackSvg,
name: 'Unpin left column',
action: (_, __, ___, ____, context) => updateColumn(context, column => {
delete column.pin;
return column;
}),
},
];
const pinnedEndColumnItems: ContextMenuItem[] = [
...sortColumnItems,
{
icon: thumbtackSvg,
name: 'Unpin right column',
action: (_, __, ___, ____, context) => updateColumn(context, column => {
delete column.pin;
return column;
}),
},
{
icon: infoSvg,
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,
});
},
},
];