Context Menu
LyteNyte Grid's context menu appears on right-click and provides auxiliary actions for the clicked element.
Note
This guide assumes familiarity with the Menu component. For more details, review the Menu Button guide.
Grid Context Menu
Use the grid’s events property to handle contextmenu on the grid region that should open
the context menu. In the demo below, right-click any cell to trigger the context
menu and perform an action.
Context Menu
17 collapsed lines
1import "./demo.css";2import "@1771technologies/lytenyte-pro/components.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import type { OrderData } from "@1771technologies/grid-sample-data/orders";5import { data } from "@1771technologies/grid-sample-data/orders";6import {7 AvatarCell,8 EmailCell,9 IdCell,10 PaymentMethodCell,11 PriceCell,12 ProductCell,13 PurchaseDateCell,14} from "./components.jsx";15import { useClientDataSource, Grid, computeField, virtualFromXY } from "@1771technologies/lytenyte-pro";16import { useMemo, useState } from "react";17import { Menu } from "@1771technologies/lytenyte-pro/components";18
19export interface GridSpec {20 readonly data: OrderData;21}22
23const initialColumns: Grid.Column<GridSpec>[] = [24 { id: "id", width: 60, widthMin: 60, cellRenderer: IdCell, name: "ID" },25 { id: "product", cellRenderer: ProductCell, width: 200, name: "Product" },26 { id: "price", type: "number", cellRenderer: PriceCell, width: 100, name: "Price" },27 { id: "customer", cellRenderer: AvatarCell, width: 180, name: "Customer" },28 { id: "purchaseDate", cellRenderer: PurchaseDateCell, name: "Purchase Date", width: 130 },29 { id: "paymentMethod", cellRenderer: PaymentMethodCell, name: "Payment Method", width: 150 },30 { id: "email", cellRenderer: EmailCell, width: 220, name: "Email" },31];32
33export default function ComponentDemo() {34 const [columns, setColumns] = useState(initialColumns);35 const ds = useClientDataSource({ data: data });36
37 const [menu, setMenu] = useState<null | {38 row: Grid.T.RowNode<GridSpec["data"]> | null;39 column: Grid.Column<GridSpec>;40 }>(null);41 const [anchor, setAnchor] = useState<null | Grid.T.VirtualTarget>(null);42
43 const [api, setApi] = useState<Grid.API<GridSpec> | null>(null);44
45 return (46 <>47 <Menu48 anchor={anchor}49 open={!!menu}50 modal={false}51 lockScroll52 onOpenChange={(b) => {53 if (!b) {54 setMenu(null);55 setAnchor(null);56 }57 }}58 >59 <Menu.Popover>60 <Menu.Container>61 {menu && (62 <>63 <Menu.Item64 onAction={() => {65 if (menu.row) {66 const field = computeField(menu.column.field ?? menu.column.id, menu.row);67 navigator.clipboard.writeText(String(field));68 } else {69 navigator.clipboard.writeText(menu.column.name ?? menu.column.id);70 }71
72 const columnIndex = api73 ?.columnView()74 .visibleColumns.findIndex((x) => x.id === menu.column.id);75 const rowIndex = menu.row ? api?.rowIdToRowIndex(menu.row!.id) : null;76
77 const query =78 rowIndex != null79 ? `[data-ln-colindex="${columnIndex}"][data-ln-cell][data-ln-rowindex="${rowIndex}"]`80 : `[data-ln-header-cell][data-ln-colindex="${columnIndex}"]`;81
82 const cell = document.querySelector(query);83 cell?.classList.add("copy-flash");84 setTimeout(() => cell?.classList.remove("copy-flash"), 500);85 }}86 >87 Copy {menu.row ? "Cell" : "Header Name"}88 </Menu.Item>89 {menu.row && (90 <Menu.Item91 onAction={() => {92 const fields = columns.map((x) => computeField(x.field ?? x.id, menu.row!)).join("\t");93 navigator.clipboard.writeText(fields);94
95 const rowIndex = api?.rowIdToRowIndex(menu.row!.id);96
97 const cells = Array.from(98 document.querySelectorAll(`[data-ln-cell][data-ln-rowindex="${rowIndex}"]`),99 );100 cells.forEach((x) => x.classList.add("copy-flash"));101 setTimeout(() => cells.forEach((x) => x.classList.remove("copy-flash")), 500);102 }}103 >104 Copy Row105 </Menu.Item>106 )}107 <Menu.Divider />108 <Menu.Item109 disabled={menu.column.pin === "start"}110 onAction={() => {111 if (!api) return;112 api.columnUpdate({ [menu.column.id]: { pin: "start" } });113 }}114 >115 Pin Column Left116 </Menu.Item>117 <Menu.Item118 disabled={menu.column.pin === "end"}119 onAction={() => {120 if (!api) return;121 api.columnUpdate({ [menu.column.id]: { pin: "end" } });122 }}123 >124 Pin Column Right125 </Menu.Item>126 <Menu.Item127 disabled={!menu.column.pin}128 onAction={() => {129 if (!api) return;130
131 api.columnUpdate({ [menu.column.id]: { pin: null } });132 }}133 >134 Unpin Column135 </Menu.Item>136 </>137 )}138 </Menu.Container>139 </Menu.Popover>140 </Menu>141 <div className="ln-grid" style={{ height: 500 }}>142 <Grid143 rowHeight={50}144 columns={columns}145 onColumnsChange={setColumns}146 ref={setApi}147 rowSource={ds}148 events={useMemo<Grid.Events<GridSpec>>(() => {149 return {150 headerCell: {151 contextMenu: ({ event, column }) => {152 event.preventDefault();153 event.stopPropagation();154 setAnchor(virtualFromXY(event.clientX, event.clientY));155
156 setMenu({ column, row: null });157 },158 },159 cell: {160 contextMenu: ({ event, row, column }) => {161 event.preventDefault();162 event.stopPropagation();163 setAnchor(virtualFromXY(event.clientX, event.clientY));164
165 setMenu({ row, column });166 },167 },168 };169 }, [])}170 />171 </div>172 </>173 );174}1@keyframes flash {2 0% {3 background-color: var(--color-ln-primary-10);4 }5 50% {6 background-color: var(--color-ln-primary-30);7 }8 100% {9 background-color: var(--color-ln-primary-10);10 }11}12.copy-flash {13 animation: flash 0.5s ease-out forwards;14}1import { format } from "date-fns";2import { type JSX, type ReactNode } from "react";3import type { Grid } from "@1771technologies/lytenyte-pro";4import type { GridSpec } from "./demo.jsx";5
6export function ProductCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {7 if (!api.rowIsLeaf(row) || !row.data) return;8
9 const url = row.data?.productThumbnail;10 const title = row.data.product;11 const desc = row.data.productDescription;12
13 return (14 <div className="flex h-full w-full items-center gap-2">15 <img className="border-ln-border-strong h-7 w-7 rounded-lg border" src={url} alt={title + desc} />16 <div className="text-ln-text-dark flex flex-col gap-0.5">17 <div className="font-semibold">{title}</div>18 <div className="text-ln-text-light text-xs">{desc}</div>19 </div>20 </div>21 );22}23
24export function AvatarCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {25 if (!api.rowIsLeaf(row) || !row.data) return;26
27 const url = row.data?.customerAvatar;28
29 const name = row.data.customer;30
31 return (32 <div className="flex h-full w-full items-center gap-2">33 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />34 <div className="text-ln-text-dark flex flex-col gap-0.5">35 <div>{name}</div>36 </div>37 </div>38 );39}40
41const formatter = new Intl.NumberFormat("en-Us", {42 minimumFractionDigits: 2,43 maximumFractionDigits: 2,44});45export function PriceCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {46 if (!api.rowIsLeaf(row) || !row.data) return;47
48 const price = formatter.format(row.data.price);49 const [dollars, cents] = price.split(".");50
51 return (52 <div className="flex h-full w-full items-center justify-end">53 <div className="flex items-baseline tabular-nums">54 <span className="text-ln-text font-semibold">${dollars}</span>.55 <span className="relative text-xs">{cents}</span>56 </div>57 </div>58 );59}60
61export function PurchaseDateCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {62 if (!api.rowIsLeaf(row) || !row.data) return;63
64 const formattedDate = format(row.data.purchaseDate, "dd MMM, yyyy");65
66 return <div className="flex h-full w-full items-center">{formattedDate}</div>;67}68export function IdCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {69 if (!api.rowIsLeaf(row) || !row.data) return;70
71 return <div className="text-xs tabular-nums">{row.data.id}</div>;72}73
74export function PaymentMethodCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {75 if (!api.rowIsLeaf(row) || !row.data) return;76
77 const cardNumber = row.data.cardNumber;78 const provider = row.data.paymentMethod;79
80 let Logo: ReactNode = null;81 if (provider === "Visa") Logo = <VisaLogo className="w-6" />;82 if (provider === "Mastercard") Logo = <MastercardLogo className="w-6" />;83
84 return (85 <div className="flex h-full w-full items-center gap-2">86 <div className="flex w-7 items-center justify-center">{Logo}</div>87 <div className="flex items-center gap-px">88 <div className="bg-ln-gray-40 size-2 rounded-full"></div>89 <div className="bg-ln-gray-40 size-2 rounded-full"></div>90 <div className="bg-ln-gray-40 size-2 rounded-full"></div>91 <div className="bg-ln-gray-40 size-2 rounded-full"></div>92 </div>93 <div className="tabular-nums">{cardNumber}</div>94 </div>95 );96}97
98export function EmailCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {99 if (!api.rowIsLeaf(row) || !row.data) return;100
101 return <div className="text-ln-primary-50 flex h-full w-full items-center">{row.data.email}</div>;102}103
104const VisaLogo = (props: JSX.IntrinsicElements["svg"]) => (105 <svg xmlns="http://www.w3.org/2000/svg" width={2500} height={812} viewBox="0.5 0.5 999 323.684" {...props}>106 <path107 fill="#1434cb"108 d="M651.185.5c-70.933 0-134.322 36.766-134.322 104.694 0 77.9 112.423 83.28 112.423 122.415 0 16.478-18.884 31.229-51.137 31.229-45.773 0-79.984-20.611-79.984-20.611l-14.638 68.547s39.41 17.41 91.734 17.41c77.552 0 138.576-38.572 138.576-107.66 0-82.316-112.89-87.537-112.89-123.86 0-12.91 15.501-27.053 47.662-27.053 36.286 0 65.892 14.99 65.892 14.99l14.326-66.204S696.614.5 651.185.5zM2.218 5.497.5 15.49s29.842 5.461 56.719 16.356c34.606 12.492 37.072 19.765 42.9 42.353l63.51 244.832h85.138L379.927 5.497h-84.942L210.707 218.67l-34.39-180.696c-3.154-20.68-19.13-32.477-38.685-32.477H2.218zm411.865 0L347.449 319.03h80.999l66.4-313.534h-80.765zm451.759 0c-19.532 0-29.88 10.457-37.474 28.73L709.699 319.03h84.942l16.434-47.468h103.483l9.994 47.468H999.5L934.115 5.497h-68.273zm11.047 84.707 25.178 117.653h-67.454z"109 />110 </svg>111);112
113const MastercardLogo = (props: JSX.IntrinsicElements["svg"]) => (114 <svg115 xmlns="http://www.w3.org/2000/svg"116 width={2500}117 height={1524}118 viewBox="55.2 38.3 464.5 287.8"119 {...props}120 >121 <path122 fill="#f79f1a"123 d="M519.7 182.2c0 79.5-64.3 143.9-143.6 143.9s-143.6-64.4-143.6-143.9S296.7 38.3 376 38.3s143.7 64.4 143.7 143.9z"124 />125 <path126 fill="#ea001b"127 d="M342.4 182.2c0 79.5-64.3 143.9-143.6 143.9S55.2 261.7 55.2 182.2 119.5 38.3 198.8 38.3s143.6 64.4 143.6 143.9z"128 />129 <path130 fill="#ff5f01"131 d="M287.4 68.9c-33.5 26.3-55 67.3-55 113.3s21.5 87 55 113.3c33.5-26.3 55-67.3 55-113.3s-21.5-86.9-55-113.3z"132 />133 </svg>134);The event handler code is shown below. The demo registers contextmenu handlers on both
header and row cells. Since the browser natively opens its own menu on
right-click, call preventDefault to suppress it.
The handlers use LyteNyte Grid’s virtualFromXY utility to create a virtual
element and assign it to the anchor state. The menu uses the virtual element as
its positioning anchor, eliminating the need for a menu trigger.
1<Grid2 rowHeight={50}3 columns={columns}4 onColumnsChange={setColumns}5 ref={setApi}6 rowSource={ds}7 events={useMemo<Grid.Events<GridSpec>>(() => {8 return {9 headerCell: {10 contextMenu: ({ event, column }) => {11 event.preventDefault();12 event.stopPropagation();13 setAnchor(virtualFromXY(event.clientX, event.clientY));14
15 setMenu({ column, row: null });16 },17 },18 cell: {19 contextMenu: ({ event, row, column }) => {20 event.preventDefault();21 event.stopPropagation();22 setAnchor(virtualFromXY(event.clientX, event.clientY));23
24 setMenu({ row, column });25 },26 },27 };28 }, [])}29/>Next Steps
- Popover: Display anchored, on-demand content in an overlay.
- Dialog: Present critical information or request decisions in a focused modal.
- Menu Button: Display a list of actions or options in an accessible dropdown menu.
