The right-click menu
| Person 1 | Engineering | $50,000 |
| Person 2 | Design | $57,919 |
| Person 3 | Sales | $65,838 |
| Person 4 | Support | $73,757 |
| Person 5 | Engineering | $81,676 |
| Person 6 | Design | $89,595 |
<script setup lang="ts">
import { shallowRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; email: string; salary: number }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{ id: 'salary', header: 'Salary', type: 'number', align: 'right', format: (v) => `$${Number(v).toLocaleString()}` },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
email: `person${i + 1}@example.com`,
salary: 50_000 + ((i * 7919) % 90_000),
}))
}
const rows = shallowRef<Person[]>(makePeople(60))
const state = useTableState({ pageSize: 6 })
const source = useLocalDataSource(rows, columns, state.query)
function mailTo(row: Person | undefined): void {
if (row) window.open(`mailto:${row.email}`)
}
</script>
<template>
<!-- `context-menu` claims the right-click; `cell-cursor` is what gives
Shift+F10 a cell to open the same menu on. -->
<DataTable :columns="columns" :source="source" :state="state" context-menu cell-cursor>
<!-- Items of your own, after the five built-in ones. `close` is handed in
rather than assumed: an item that opens something else decides for
itself when the menu is done. -->
<template #contextMenu="{ rowId, close }">
<button
type="button"
class="vt-context-item"
role="menuitem"
@click="mailTo(rows.find((row) => row.id === rowId)), close()"
>
Email this person
</button>
</template>
</DataTable>
</template>A right-click on a cell opens a menu of the things the table can already do to that column, aimed at the cell under the pointer. One prop turns it on:
<DataTable :columns="columns" :source="source" context-menu />Five items, and every one of them is a call the table offers somewhere else:
| Item | What it calls |
|---|---|
| Filter by this value | state.setFilter(columnId, valuesFilter([value])) — the filter panel's own values list, with one value ticked |
| Sort ascending / descending | state.setSort(columnId, 'asc' | 'desc') — the header's sort, without the cycle through "unsorted" |
| Group by this column | state.toggleGroup(columnId) — the grouping menu's checkbox |
| Hide column | columns.toggleVisibility(columnId, false) — the column menu's checkbox |
| Copy | the cell's displayed text, the same string Ctrl/Cmd+C puts on the clipboard |
Nothing here is a second implementation, so a column filtered from the menu is filtered exactly as the panel would have filtered it, and clearing it from the chip works without the menu knowing.
What the column says
An item the column has refused stays in the menu, greyed: sortable: false disables both sort items, filterable: false the filter, groupable: false the grouping and hideable: false the hide. They are disabled rather than dropped because a menu whose items move about depending on the column makes the one you wanted hard to aim at.
Header cells
A right-click on a header opens the same menu without the two items that need a cell to read — filter by this value, and copy. Sort, group and hide are what a header has to offer, and they are the three that are left.
From the keyboard
Shift+F10 — and the dedicated ContextMenu key, on keyboards that have one — opens the menu on the cursor's cell. That needs cell-cursor, since without a cursor there is no focused cell to open it on; the right-click works either way. Inside the menu, ↑ and ↓ walk the items and wrap, Esc closes it, and Enter or Space is the browser's own on a <button>.
Neither key is spent on anything else: a bare F10 is the browser's menu bar on Windows and Linux, which is why the Shift is required rather than merely tolerated. The rest of the table's keyboard is Keyboard navigation.
Items of your own
The #contextMenu slot adds items after the built-in five, inside the same panel — so they share the dismissal, the arrow keys and the theme. It is handed the columnId, the rowId (absent on a header) and a close function:
<template #contextMenu="{ rowId, close }">
<button type="button" class="vt-context-item" role="menuitem" @click="audit(rowId), close()">
Show audit trail
</button>
</template>close is handed in rather than called for you: an item that opens a dialog of its own decides when the menu is done.
Icons
Each built-in item draws a mark in a gutter on its leading edge — the same funnel the header's filter button draws, arrows for the two sorts, and so on. The gutter belongs to .vt-context-item, not to the mark, so an item of yours lines up with the built-in five whether or not it has one. To give it one, put an aria-hidden <svg class="vt-context-icon"> inside the button ahead of the label; the preset positions it and dims it, and currentColor keeps it in step with the item's own colour when it is hovered or disabled:
<button type="button" class="vt-context-item" role="menuitem" @click="audit(rowId), close()">
<svg class="vt-context-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M8 4.6v3.8l2.6 1.6" fill="none" stroke="currentColor" stroke-width="1.4" />
</svg>
<span class="vt-context-label">Show audit trail</span>
</button>The mark is decoration: it repeats what the label already says, so it is hidden from assistive technology rather than given a name of its own.
The primitive
TableContextMenu is what DataTable renders, and you can render it yourself over a table built from the primitives. It is the fourth primitive that requires a <TableRoot> above it — it reads the whole column, filter and grouping model rather than taking it as props, like ColumnVisibilityMenu, RowGroupMenu and ActiveFilters. Give it open, the anchor element to hang off, the columnId and optionally the rowId:
<TableGrid :cursor="cursor" context-menu @context-menu="(target, anchor) => (menu = { target, anchor })">
…
</TableGrid>
<TableContextMenu
v-if="menu"
open
:anchor="menu.anchor"
:column-id="menu.target.columnId"
:row-id="menu.target.rowId"
@update:open="menu = null"
/>TableGrid's context-menu prop is what binds the listener at all — off by default, and off means off: a right-click then gets the browser's own menu, which is the honest answer for a table with nothing to put in its place. contextMenuFor(event) is the keyboard half, the decoder that answers Shift+F10 and ContextMenu, so a hand-assembled grid gets the same keys without reimplementing the decision.
The panel is teleported to <body> and positioned at the anchor cell's bottom-left corner, so neither the scroll box's overflow nor a <td>'s can clip it; low on the page it flips above the cell instead. Pass :teleport="false" to render it inline, where it takes no position of its own.
Live: the Right-click menu tab of pnpm demo (#context-menu). Back to the docs index.