shadimage
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 · light

Registry 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";

More from gridcn

Browse all components in this library →