gridcn
blockdata-grid-context-menusource: ok · refreshed
Data Grid Context Menu
Right-click context menus for the gridcn data grid: cut/copy/paste/clear/row operations on cells, sort/pin/hide/autosize on headers.
npx shadcn add "https://gridcn.vercel.app/r/data-grid-context-menu.json"free
Where it goes
navbar
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
DammersCode/gridcn/data-gridcontext-menudropdown-menutooltip
Files11 · source cached 10/8/2026, 8:48:05 AM
registry/default/blocks/data-grid-context-menu/data-grid-context-menu.ts
/** Public entry point — the only module consumers/docs should import from. */
export { DataGridContextMenu, type DataGridContextMenuProps } from "./context-menu";
export { DataGridHeaderDropdown, type DataGridHeaderDropdownProps } from "./header-dropdown";
registry/default/blocks/data-grid-context-menu/context-menu.tsx
"use client";
import { useCallback, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import type { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useDataGridActions, useDataGridSelection } from "@/registry/default/components/data-grid/data-grid";
import { resolveContextMenuTarget, type ContextMenuTarget } from "./resolve-context-menu-target";
import { isCellInSelection } from "./selection-queries";
import { DataGridCellMenuContent } from "./cell-menu-content";
import { DataGridHeaderMenuContent } from "./header-menu-content";
import { useHasCreateRow, useHasDuplicateRow } from "./has-row-op";
/** Props for {@link DataGridContextMenu}. */
export type DataGridContextMenuProps = {
className?: string;
children?: ReactNode;
/**
* Replaces the cell-surface item set (Cut/Copy/Paste, Clear contents, Insert row above/below,
* Duplicate row(s), Delete row(s)) with the returned node. The output is the complete item set —
* it owns the items, their order, and their gating. Runs during the wrapper's render (a plain
* function call, not a component), so it must not call hooks; wrap hook-using items in a
* component. Omit for the built-in set.
*/
renderCellMenuItems?: (ctx: {
/** The right-clicked cell's view row index (the same index the built-in set anchors Insert row at). */
row: number;
/** The right-clicked cell's column id. */
columnId: string;
/** Whether the grid was given a `createRow` prop — the flag the built-in set gates its Insert items on. */
canInsertRow: boolean;
/** Whether the grid was given a `duplicateRow` prop — the flag the built-in set gates its Duplicate item on. */
canDuplicateRow: boolean;
}) => ReactNode;
/**
* Replaces the header-surface item set (Sort asc/desc/clear, Pin left/right/unpin, Hide column,
* Autosize column) with the returned node. Applies to the right-click header menu only; the
* `DataGridHeaderDropdown` keeps its built-in items. Runs during the wrapper's render, so it must
* not call hooks. Omit for the built-in set.
*/
renderHeaderMenuItems?: (ctx: {
/** The right-clicked column's id. */
columnId: string;
/** The grid's scroll-container element (for autosize measurement), or null when the event target has no grid ancestor. */
scrollRoot: HTMLElement | null;
}) => ReactNode;
};
/**
* Wraps `children` (the grid) with the shadcn `ContextMenu`. On `contextmenu`, inspects the event
* target to decide the surface (cell vs. header) and, for a cell outside
* the current selection, selects that cell first (Excel behavior) before the menu opens.
*
* A surface that resolves to neither (row markers, empty grid space below the last row, the
* scrollbar gutter, ...) has no menu content to show — `resolveContextMenuTarget` already returns
* `null` there. Left alone, Base UI's `ContextMenuRoot` still opens the (then childless) popup on
* that right-click, rendering a visibly empty rounded-card sliver.
* `onOpenChange` here cancels that open via `eventDetails.cancel()` — checked against `targetRef`
* (synchronous, unlike `target` state) since the "should this open" decision has to be made in the
* same tick as the triggering event, before React re-renders. The native browser context menu is
* deliberately NOT re-enabled for this case (no `event.preventDefault()` is skipped) — suppressing
* silently, matching how a plain click on empty grid space is already a quiet no-op elsewhere, is
* less surprising than a browser chrome menu popping up only sometimes depending on grid surface.
*/
export function DataGridContextMenu(props: DataGridContextMenuProps): ReactNode {
const { className, children, renderCellMenuItems, renderHeaderMenuItems } = props;
const actions = useDataGridActions();
const selection = useDataGridSelection();
const canInsertRow = useHasCreateRow();
const canDuplicateRow = useHasDuplicateRow();
const selectionRef = useRef(selection);
selectionRef.current = selection;
const [target, setTarget] = useState<ContextMenuTarget | null>(null);
const targetRef = useRef<ContextMenuTarget | null>(null);
// Lives here (not in the menu content) because the content portals and unmounts on close —
// a permission-denied paste result must survive the close/reopen cycle to keep showing the
// "use Ctrl+V" hint on the next open.
const [pasteBlocked, setPasteBlocked] = useState(false);
// Captured at contextmenu time (not via useDataGridContainer): ContextMenuContent portals to
// document.body, outside DataGridRoot's subtree, so its children can't reach the root context.
const scrollRootRef = useRef<HTMLElement | null>(null);
const onContextMenu = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
const resolved = resolveContextMenuTarget(event.target);
targetRef.current = resolved;
setTarget(resolved);
scrollRootRef.current = event.target instanceof Element ? event.target.closest('[role="grid"]') : null;
if (resolved?.kind === "cell" && !isCellInSelection(selectionRef.current, { row: resolved.row, col: resolved.col })) {
actions.selectCell({ row: resolved.row, col: resolved.col });
}
},
[actions],
);
const onOpenChange = useCallback((open: boolean, eventDetails: ContextMenuPrimitive.Root.ChangeEventDetails) => {
if (open && targetRef.current === null) eventDetails.cancel();
}, []);
return (
<ContextMenu onOpenChange={onOpenChange}>
<ContextMenuTrigger className={className} onContextMenu={onContextMenu}>
{children}
</ContextMenuTrigger>
<ContextMenuContent data-grid-context-menu="">
{target?.kind === "cell" &&
(renderCellMenuItems ? (
renderCellMenuItems({ row: target.row, columnId: target.columnId, canInsertRow, canDuplicateRow })
) : (
<DataGridCellMenuContent
row={target.row}
canInsertRow={canInsertRow}
canDuplicateRow={canDuplicateRow}
pasteBlocked={pasteBlocked}
onPasteBlocked={() => setPasteBlocked(true)}
/>
))}
{target?.kind === "header" &&
(renderHeaderMenuItems ? (
renderHeaderMenuItems({ columnId: target.columnId, scrollRoot: scrollRootRef.current })
) : (
<DataGridHeaderMenuContent columnId={target.columnId} scrollRoot={scrollRootRef.current} />
))}
</ContextMenuContent>
</ContextMenu>
);
}
registry/default/blocks/data-grid-context-menu/cell-menu-content.tsx
"use client";
import { useCallback, type ReactNode } from "react";
import { Clipboard, Copy, CopyPlus, Eraser, Plus, Scissors, Trash2 } from "lucide-react";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
useDataGridActions,
useDataGridClipboard,
useDataGridKeymap,
useDataGridLabels,
useDataGridReadOnly,
useDataGridSelection,
} from "@/registry/default/components/data-grid/data-grid";
import { MenuShortcut } from "./menu-shortcut";
import { selectedViewRows } from "./selection-queries";
/** Props for {@link DataGridCellMenuContent}. */
export type DataGridCellMenuContentProps = {
/** The right-clicked cell's view row, anchoring Insert row above/below. */
row: number;
/** Whether the installing app passed a `createRow` prop; hides the two Insert items when false. */
canInsertRow: boolean;
/** Whether the installing app passed a `duplicateRow` prop; hides Duplicate row(s) when false (store's `duplicateRows` would otherwise be a dev-warning no-op). */
canDuplicateRow: boolean;
/** True after a `pasteFromClipboard()` call resolved `permission-denied` (non-secure context / no `clipboard-read`) — disables Paste and shows the `pasteBlocked` hint tooltip. Owned by the persistent wrapper, not this content, so it survives the menu's unmount-on-close. */
pasteBlocked: boolean;
/** Reports a `permission-denied` paste outcome upward so the hint persists across menu close/reopen. */
onPasteBlocked: () => void;
};
/**
* Cell-surface context menu items: Cut/Copy/Paste, Clear contents,
* Insert row above/below (hidden without `createRow`), Duplicate/Delete row(s). Row-op items act
* on every view row covered by the current selection (falling back to the right-clicked `row`).
* Every mutating item (everything but Copy) is disabled on a `readOnly` grid, mirroring the
* keyboard/native-clipboard gating in `useGridInteraction`/`useGridClipboard`.
*/
export function DataGridCellMenuContent(props: DataGridCellMenuContentProps): ReactNode {
const { row, canInsertRow, canDuplicateRow, pasteBlocked, onPasteBlocked } = props;
const actions = useDataGridActions();
const selection = useDataGridSelection();
const clipboard = useDataGridClipboard();
const readOnly = useDataGridReadOnly();
const labels = useDataGridLabels();
const keymap = useDataGridKeymap();
const targetRows = selectedViewRows(selection);
const rows = targetRows.length > 0 ? targetRows : [row];
const onPaste = useCallback(() => {
if (pasteBlocked || readOnly) return;
clipboard.pasteFromClipboard().then((result) => {
if (result === "permission-denied") onPasteBlocked();
});
}, [clipboard, onPasteBlocked, pasteBlocked, readOnly]);
// Not the `disabled` prop: that sets `pointer-events-none`, which would also block the tooltip's
// own hover detection. Visually disabled + click-guarded instead, so hover still reaches the trigger.
const pasteItem = (
<ContextMenuItem
aria-disabled={pasteBlocked || readOnly || undefined}
className={pasteBlocked || readOnly ? "opacity-50" : undefined}
onClick={onPaste}
>
<Clipboard className="size-4 text-muted-foreground" />
{labels.contextMenu.paste}
<MenuShortcut binding="mod+v" />
</ContextMenuItem>
);
return (
<>
<ContextMenuItem disabled={readOnly} onClick={clipboard.cut}>
<Scissors className="size-4 text-muted-foreground" />
{labels.contextMenu.cut}
<MenuShortcut binding="mod+x" />
</ContextMenuItem>
<ContextMenuItem onClick={clipboard.copy}>
<Copy className="size-4 text-muted-foreground" />
{labels.contextMenu.copy}
<MenuShortcut binding="mod+c" />
</ContextMenuItem>
{pasteBlocked && !readOnly ? (
<Tooltip>
<TooltipTrigger render={pasteItem} />
<TooltipContent>{labels.contextMenu.pasteBlocked}</TooltipContent>
</Tooltip>
) : (
pasteItem
)}
<ContextMenuSeparator />
<ContextMenuItem disabled={readOnly} onClick={() => actions.deleteSelection()}>
<Eraser className="size-4 text-muted-foreground" />
{labels.contextMenu.clearContents}
<MenuShortcut binding={keymap.deleteContents?.[0]} />
</ContextMenuItem>
<ContextMenuSeparator />
{canInsertRow && (
<>
<ContextMenuItem disabled={readOnly} onClick={() => actions.insertRow(row, "above")}>
<Plus className="size-4 text-muted-foreground" />
{labels.contextMenu.insertRowAbove}
<MenuShortcut binding={keymap.insertRowAbove?.[0]} />
</ContextMenuItem>
<ContextMenuItem disabled={readOnly} onClick={() => actions.insertRow(row, "below")}>
<Plus className="size-4 text-muted-foreground" />
{labels.contextMenu.insertRowBelow}
<MenuShortcut binding={keymap.insertRowBelow?.[0]} />
</ContextMenuItem>
</>
)}
{canDuplicateRow && (
<ContextMenuItem disabled={readOnly} onClick={() => actions.duplicateRows(rows)}>
<CopyPlus className="size-4 text-muted-foreground" />
{labels.contextMenu.duplicateRows(rows.length)}
<MenuShortcut binding={keymap.duplicateRow?.[0]} />
</ContextMenuItem>
)}
<ContextMenuItem disabled={readOnly} variant="destructive" onClick={() => actions.deleteRows(rows)}>
<Trash2 className="size-4" />
{labels.contextMenu.deleteRows(rows.length)}
<MenuShortcut binding={keymap.deleteRows?.[0]} />
</ContextMenuItem>
</>
);
}
registry/default/blocks/data-grid-context-menu/header-menu-content.tsx
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, EyeOff, MoveHorizontal, Pin, PinOff, X } from "lucide-react";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import {
useDataGridActions,
useDataGridAllColumns,
useDataGridColumnFeatureFlags,
useDataGridLabels,
useDataGridSortState,
} from "@/registry/default/components/data-grid/data-grid";
import { autosizeColumn } from "./autosize-column";
/** Props for {@link DataGridHeaderMenuContent}. */
export type DataGridHeaderMenuContentProps = {
columnId: string;
/**
* The grid's scroll-container element, for Autosize's measurement pass. Passed down from
* `DataGridContextMenu` (captured at `contextmenu` time) rather than read via
* `useDataGridContainer()`: this component renders inside `ContextMenuContent`, which Base UI
* portals to `document.body` — outside `DataGridRoot`'s subtree, so that hook would throw here.
*/
scrollRoot: HTMLElement | null;
};
/**
* Header-surface context menu items: Sort asc/desc/Clear sort, Pin
* left/right/Unpin (respecting `pinnable`/`enableColumnPinning`), Hide column, Autosize column
* (respecting `resizable`/`enableColumnResize`).
*/
export function DataGridHeaderMenuContent(props: DataGridHeaderMenuContentProps): ReactNode {
const { columnId, scrollRoot } = props;
const actions = useDataGridActions();
const sortState = useDataGridSortState();
const columns = useDataGridAllColumns();
const { enableColumnPinning, enableColumnResize } = useDataGridColumnFeatureFlags();
const labels = useDataGridLabels();
const column = columns.find((c) => c.id === columnId);
if (!column) return null;
const sortable = column.sortable !== false;
const pinnable = enableColumnPinning && column.pinnable !== false;
// Mirrors the core double-click autosize's gate: with either flag off the action does not
// exist, so the item is hidden (not disabled) to match the absent resize handle.
const resizable = enableColumnResize && (column.resizable ?? true);
const currentSort = sortState.find((s) => s.columnId === columnId)?.direction;
return (
<>
{sortable && (
<>
<ContextMenuItem onClick={() => actions.setSorts([{ columnId, direction: "asc" }])}>
<ArrowUp className="size-4 text-muted-foreground" />
{labels.contextMenu.sortAsc}
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.setSorts([{ columnId, direction: "desc" }])}>
<ArrowDown className="size-4 text-muted-foreground" />
{labels.contextMenu.sortDesc}
</ContextMenuItem>
{currentSort && (
<ContextMenuItem onClick={() => actions.setSorts(sortState.filter((s) => s.columnId !== columnId))}>
<X className="size-4 text-muted-foreground" />
{labels.contextMenu.clearSort}
</ContextMenuItem>
)}
<ContextMenuSeparator />
</>
)}
<ContextMenuItem disabled={!pinnable} onClick={() => actions.setColumnPin(columnId, "left")}>
<Pin className="size-4 text-muted-foreground" />
{labels.contextMenu.pinLeft}
</ContextMenuItem>
<ContextMenuItem disabled={!pinnable} onClick={() => actions.setColumnPin(columnId, "right")}>
<Pin className="size-4 text-muted-foreground" />
{labels.contextMenu.pinRight}
</ContextMenuItem>
{column.pin != null && (
<ContextMenuItem disabled={!pinnable} onClick={() => actions.setColumnPin(columnId, null)}>
<PinOff className="size-4 text-muted-foreground" />
{labels.contextMenu.unpin}
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => actions.setColumnHidden(columnId, true)}>
<EyeOff className="size-4 text-muted-foreground" />
{labels.contextMenu.hideColumn}
</ContextMenuItem>
{resizable && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => autosizeColumn(scrollRoot, column, actions.commitColumnWidth)}>
<MoveHorizontal className="size-4 text-muted-foreground" />
{labels.contextMenu.autosize}
</ContextMenuItem>
</>
)}
</>
);
}
registry/default/blocks/data-grid-context-menu/header-dropdown.tsx
"use client";
import type { ReactElement, ReactNode } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useDataGridContainer, type AnyColumnDef } from "@/registry/default/components/data-grid/data-grid";
import { DataGridHeaderMenuContent } from "./header-menu-content";
/** Props for {@link DataGridHeaderDropdown} — matches the core's `renderHeaderMenu` render-prop ctx exactly. */
export type DataGridHeaderDropdownProps = {
column: AnyColumnDef;
index: number;
/** The core's own ghost-chevron trigger element (styling/aria-label owned by `header-cell.tsx`). */
trigger: ReactElement;
};
/**
* The primary per-column pin/sort surface: a `DropdownMenu` wrapping the core's chevron trigger,
* whose content reuses the exact same items as the header right-click context menu
* (`DataGridHeaderMenuContent`). Wire via
* `<DataGridRoot renderHeaderMenu={(ctx) => <DataGridHeaderDropdown {...ctx} />}>`.
*/
export function DataGridHeaderDropdown(props: DataGridHeaderDropdownProps): ReactNode {
const { column, trigger } = props;
// rendered inline inside DataGridRoot's subtree (not portaled like the context menu), so the
// container is reachable directly via the shared hook instead of a contextmenu-time ref capture.
const scrollRoot = useDataGridContainer();
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
{/* data-grid-header-menu-popup marks this a portal (React-tree-only bubble target) so
header-cell.tsx's own pointerdown/click handlers can ignore events that originate here
(its label press otherwise reassigns e.g. a "Hide column" click into a column-select). */}
<DropdownMenuContent data-grid-header-dropdown="" data-grid-header-menu-popup="" align="end">
<DataGridHeaderMenuContent columnId={column.id} scrollRoot={scrollRoot.current} />
</DropdownMenuContent>
</DropdownMenu>
);
}
registry/default/blocks/data-grid-context-menu/autosize-column.ts
import { measureColumnAutosizeWidth, type AnyColumnDef } from "@/registry/default/components/data-grid/data-grid";
const ABSOLUTE_MIN_WIDTH = 32;
/** Reads the rendered text of every currently-mounted gridcell for `columnId`, scoped to `root` — same DOM contract `use-column-resize.ts`'s double-click autosize reads (`role="gridcell"` + `data-column-id`). */
function readRenderedCellTexts(root: Element | null, columnId: string): string[] {
if (!root) return [];
const cells = root.querySelectorAll(`[role="gridcell"][data-column-id="${CSS.escape(columnId)}"]`);
return Array.from(cells, (cell) => cell.textContent ?? "");
}
/**
* Autosizes `column` to fit its header text plus every currently-rendered cell's text, using
* `root`'s computed font so measurement matches what's on screen. `commitWidth` must be the commit
* point (`commitColumnWidth`), not the per-frame drag write: autosize is one gesture and fires
* `onColumnLayoutChange` once.
*/
export function autosizeColumn(
root: Element | null,
column: AnyColumnDef,
commitWidth: (id: string, width: number) => void,
): void {
const font = root ? getComputedStyle(root).font : "";
const headerText = column.headerText ?? (typeof column.header === "string" ? column.header : "");
const cellTexts = readRenderedCellTexts(root, column.id);
const width = measureColumnAutosizeWidth({
headerText,
cellTexts,
font,
minWidth: Math.max(ABSOLUTE_MIN_WIDTH, column.minWidth ?? 0),
maxWidth: column.maxWidth,
});
commitWidth(column.id, width);
}
registry/default/blocks/data-grid-context-menu/menu-shortcut.tsx
import type { ReactNode } from "react";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
/** True when running on macOS (Cmd-labeled shortcuts); false everywhere else (Ctrl-labeled). SSR-safe (false when no `navigator`). */
function isMac(): boolean {
if (typeof navigator === "undefined") return false;
// ponytail: duplicated from core's use-grid-interaction.ts isMacPlatform — deliberate, no add-on may depend on another.
const uaData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData;
const platform = uaData?.platform ?? navigator.platform ?? navigator.userAgent;
// case-insensitive: userAgentData.platform reports "macOS" (lowercase 'ac'), unlike legacy "MacIntel".
return /mac|iphone|ipad/i.test(platform);
}
/** One binding segment ("mod", "shift", "z") to its display token, platform-aware for the modifiers. */
function displaySegment(segment: string): string {
if (segment === "mod") return isMac() ? "⌘" : "Ctrl";
if (segment === "ctrl") return "Ctrl";
if (segment === "shift") return isMac() ? "⇧" : "Shift";
if (segment === "alt") return isMac() ? "⌥" : "Alt";
if (segment === " ") return "Space";
return segment.length === 1 ? segment.toUpperCase() : segment;
}
/** One binding string ("mod+shift+z") as its display tokens, one per keycap ("Ctrl" "Shift" "Z" / "⌘" "⇧" "Z"). */
export function bindingTokens(binding: string): string[] {
return binding.split("+").map(displaySegment);
}
/** A menu item's shortcut hint as keycaps, pushed to the item's end; renders nothing for an unbound action. */
export function MenuShortcut({ binding }: { binding: string | undefined }): ReactNode {
if (!binding) return null;
return (
<KbdGroup className="ml-auto">
{bindingTokens(binding).map((token, i) => (
// tokens within one binding have no other stable identity; index is fine, this list never reorders
<Kbd key={i}>{token}</Kbd>
))}
</KbdGroup>
);
}
components/ui/kbd.tsx
import { cn } from "@/lib/utils"
/** Keycap treatment, owned by the component (not the prose typography plugin) so docs, the keybindings dialog, and consumer apps render identically. */
const KEYCAP =
"rounded-sm border bg-muted shadow-[inset_0_-2px_0_0_color-mix(in_oklab,_currentColor_15%,_transparent)]"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
// `not-prose`: the typography plugin's kbd rule (unlayered) would otherwise restyle component-owned keys
"pointer-events-none not-prose inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 px-1 font-sans text-xs font-medium text-muted-foreground",
KEYCAP,
"in-data-[slot=kbd-group]:min-w-0 in-data-[slot=kbd-group]:border-0 in-data-[slot=kbd-group]:bg-transparent in-data-[slot=kbd-group]:px-0.5 in-data-[slot=kbd-group]:shadow-none",
"in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10",
"[&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
/** One keycap for a whole binding; inner `Kbd` chips flatten via `in-data-[slot=kbd-group]` and stay separated by the gap. */
function KbdGroup({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="kbd-group"
className={cn(
"pointer-events-none inline-flex h-5 select-none items-center gap-1 px-1 font-sans text-xs font-medium text-muted-foreground",
KEYCAP,
className
)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
registry/default/blocks/data-grid-context-menu/has-row-op.ts
import { useStore } from "zustand";
import { useDataGridStoreApi, type DataGridStoreState } from "@/registry/default/components/data-grid/data-grid";
/** Whether the grid was given a `createRow` prop — Insert row above/below are hidden without one. */
export function useHasCreateRow(): boolean {
return useStore(useDataGridStoreApi(), (s: DataGridStoreState) => s.createRow != null);
}
/** Whether the grid was given a `duplicateRow` prop — Duplicate row(s) is hidden without one (the store's `duplicateRows` is a dev-warning no-op otherwise). */
export function useHasDuplicateRow(): boolean {
return useStore(useDataGridStoreApi(), (s: DataGridStoreState) => s.duplicateRow != null);
}
registry/default/blocks/data-grid-context-menu/resolve-context-menu-target.ts
import { GRID_ATTR, gridAttrSelector } from "@/registry/default/components/data-grid/data-grid";
/**
* Which surface a `contextmenu` event landed on, in view-space coordinates: a data cell (`row`
* from `[role=row]`'s `data-grid-row-index`, `col` from the gridcell's `aria-colindex`), a header
* (`col` from the columnheader's `aria-colindex`), or neither (e.g. empty-state, scrollbar gutter).
*/
export type ContextMenuTarget =
| { kind: "cell"; row: number; col: number; columnId: string }
| { kind: "header"; col: number; columnId: string };
/** Reads a numeric aria attribute off `element`, or null when absent/non-numeric. */
function ariaIndex(element: Element, attr: string): number | null {
const raw = element.getAttribute(attr);
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
/**
* The view row index for a data `[role=row]` element. Prefers `data-grid-row-index`, stamped
* directly by row.tsx and immune to the pinned-top band shifting `aria-rowindex`; falls back to
* `aria-rowindex - 2` (only correct with no pinned-top rows) for markup predating that attribute.
*/
function resolveViewRow(rowEl: Element): number | null {
const stamped = ariaIndex(rowEl, GRID_ATTR.rowIndex);
if (stamped != null) return stamped;
const ariaRow = ariaIndex(rowEl, "aria-rowindex");
return ariaRow == null ? null : ariaRow - 2;
}
/**
* Resolves the logical grid surface under `eventTarget`. Marker cells (`data-grid-marker-cell`)
* carry no `aria-colindex` by design (outside the data column index space) and pinned
* top/bottom row cells (`data-grid-pinned-row`, from the `data-grid-pinned-rows` add-on) aren't
* part of `data`/the selection model — both are excluded and resolve to `null` (no cell/header
* menu; add a marker- or pinned-row-specific menu separately if ever needed).
*/
export function resolveContextMenuTarget(eventTarget: EventTarget | null): ContextMenuTarget | null {
if (!(eventTarget instanceof Element)) return null;
const headerCell = eventTarget.closest('[role="columnheader"]');
if (headerCell) {
const col = ariaIndex(headerCell, "aria-colindex");
const columnId = headerCell.getAttribute("data-column-id");
if (col == null || columnId == null) return null;
return { kind: "header", col: col - 1, columnId };
}
const gridCell = eventTarget.closest(
`[role="gridcell"]:not(${gridAttrSelector("markerCell")}):not(${gridAttrSelector("pinnedRow")})`,
);
if (gridCell) {
const col = ariaIndex(gridCell, "aria-colindex");
const columnId = gridCell.getAttribute("data-column-id");
const rowEl = gridCell.closest('[role="row"]');
if (col == null || columnId == null || rowEl == null) return null;
const row = resolveViewRow(rowEl);
if (row == null) return null;
return { kind: "cell", row, col: col - 1, columnId };
}
return null;
}
registry/default/blocks/data-grid-context-menu/selection-queries.ts
import type { CellCoord, GridSelection } from "@/registry/default/components/data-grid/data-grid";
/** Whether `coord` falls inside `selection`'s primary range, any stacked range, or the row/column channels — used to decide the Excel "right-click outside selection selects that cell first" gesture. */
export function isCellInSelection(selection: GridSelection, coord: CellCoord): boolean {
if (selection.rows.hasIndex(coord.row)) return true;
if (selection.columns.hasIndex(coord.col)) return true;
if (!selection.current) return false;
const inRect = (r: { x: number; y: number; width: number; height: number }) =>
coord.col >= r.x && coord.col < r.x + r.width && coord.row >= r.y && coord.row < r.y + r.height;
return inRect(selection.current.range) || selection.current.rangeStack.some(inRect);
}
/** Row-op target set — the core's own derivation of the selected view rows (see `getSelectedViewRows`), re-exported so the cell menu shares one code path with every other consumer. */
export { getSelectedViewRows as selectedViewRows } from "@/registry/default/components/data-grid/data-grid";