shadimage
gridcn
blockdata-grid-keybindingssource: ok · hit

Data Grid Keybindings

Keyboard shortcuts dialog for the gridcn data grid, generated at runtime from the grid's effective keymap, grouped by category with platform-aware key chips; opens via '?' or a trigger.

npx shadcn add "https://gridcn.vercel.app/r/data-grid-keybindings.json"
free

Where it goes

kanban

Interaction

modal

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-griddialog

Files7 · source cached 10/8/2026, 2:55:31 PM

registry/default/blocks/data-grid-keybindings/data-grid-keybindings.ts
/** Public entry point — the only module consumers/docs should import from. */

export { DataGridKeybindingsDialog, type DataGridKeybindingsDialogProps } from "./keybindings-dialog";
export { DataGridKeybindingsShortcut, type DataGridKeybindingsShortcutProps } from "./keybindings-shortcut";
export { BindingChips, bindingTokens } from "./binding-label";
export { KEYBINDING_CATEGORIES, ACTION_CATEGORY, categoryForAction, categoryLabel, type KeybindingCategory } from "./action-groups";
export { labelForAction } from "./action-labels";
registry/default/blocks/data-grid-keybindings/keybindings-dialog.tsx
"use client";

import { useMemo, type ReactElement, type ReactNode } from "react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogTrigger,
} from "@/components/ui/dialog";
import { useDataGridKeymap, useDataGridLabels, type DataGridLabels, type GridAction, type Keymap } from "@/registry/default/components/data-grid/data-grid";
import { KEYBINDING_CATEGORIES, categoryForAction, categoryLabel, type KeybindingCategory } from "./action-groups";
import { labelForAction } from "./action-labels";
import { BindingChips } from "./binding-label";

/** One always-on native clipboard shortcut not covered by the keymap (copy/cut/paste are native browser events, see use-grid-clipboard.ts); labels come from `labels.keybindings.native*` (i18n). */
function nativeClipboardBindings(labels: DataGridLabels): { label: string; binding: string }[] {
  return [
    { label: labels.keybindings.nativeCopy, binding: "mod+c" },
    { label: labels.keybindings.nativeCut, binding: "mod+x" },
    { label: labels.keybindings.nativePaste, binding: "mod+v" },
  ];
}

/** A dialog row: either a keymap-driven `GridAction` (key = action name) or a native binding (key = its label). */
type KeymapRow = { key: string; label: string; bindings: string[] };

/** Every bound action in `keymap` plus the native clipboard bindings, grouped by category in {@link KEYBINDING_CATEGORIES} order; categories with no rows are omitted. */
function groupKeymap(keymap: Keymap, labels: DataGridLabels): { category: KeybindingCategory; rows: KeymapRow[] }[] {
  const byCategory = new Map<KeybindingCategory, KeymapRow[]>();
  const pushRow = (category: KeybindingCategory, row: KeymapRow) => {
    const list = byCategory.get(category) ?? [];
    list.push(row);
    byCategory.set(category, list);
  };

  for (const [action, bindings] of Object.entries(keymap) as [GridAction, string[] | undefined][]) {
    if (!bindings || bindings.length === 0) continue;
    pushRow(categoryForAction(action), { key: action, label: labelForAction(action, labels), bindings });
  }
  // native clipboard events (copy/cut/paste) aren't GridActions/keymap entries — see use-grid-clipboard.ts
  for (const { label, binding } of nativeClipboardBindings(labels)) {
    pushRow("clipboardFill", { key: label, label, bindings: [binding] });
  }

  return KEYBINDING_CATEGORIES.map((category) => ({ category, rows: byCategory.get(category) ?? [] })).filter(
    (group) => group.rows.length > 0,
  );
}

/** Props for {@link DataGridKeybindingsDialog}. */
export type DataGridKeybindingsDialogProps = {
  /** Controlled open state; omit for uncontrolled (internal) state. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Uncontrolled initial open state; ignored when `open` is provided. */
  defaultOpen?: boolean;
  /** Rendered as the dialog's trigger (e.g. a `<Button>`); omit to drive `open` externally only. */
  trigger?: ReactElement;
};

/**
 * Lists every bound `GridAction` from the mounted grid's EFFECTIVE keymap (`DEFAULT_KEYMAP` merged
 * with the consumer's `keymap` prop via `useDataGridKeymap`) — never a hardcoded shortcut list, so
 * consumer overrides/extensions show up automatically. Grouped by category, platform-aware `<kbd>`
 * chips (⌘ on mac, Ctrl elsewhere; Space/arrow glyphs). Copy/Cut/Paste are appended separately since
 * they're native browser clipboard events, not part of the keymap.
 */
export function DataGridKeybindingsDialog(props: DataGridKeybindingsDialogProps): ReactNode {
  const { open, onOpenChange, defaultOpen, trigger } = props;
  const keymap = useDataGridKeymap();
  const labels = useDataGridLabels();
  const groups = useMemo(() => groupKeymap(keymap, labels), [keymap, labels]);

  return (
    <Dialog open={open} onOpenChange={onOpenChange} defaultOpen={defaultOpen}>
      {trigger && <DialogTrigger render={trigger} />}
      <DialogContent className="max-h-[80vh] max-w-lg overflow-y-auto sm:max-w-lg" data-slot="data-grid-keybindings-dialog">
        <DialogHeader>
          <DialogTitle>{labels.keybindings.title}</DialogTitle>
          <DialogDescription>{labels.keybindings.description}</DialogDescription>
        </DialogHeader>
        <div className="flex flex-col gap-4">
          {groups.map(({ category, rows }) => (
            <section key={category} data-keybindings-category={category}>
              <h3 className="mb-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">{categoryLabel(category, labels)}</h3>
              <ul className="flex flex-col gap-1">
                {rows.map((row) => (
                  <li key={row.key} data-keybindings-action={row.key} className="flex items-center justify-between gap-4 py-0.5">
                    <span className="text-foreground">{row.label}</span>
                    <div className="flex items-center gap-2">
                      {row.bindings.map((binding) => (
                        <BindingChips key={binding} binding={binding} />
                      ))}
                    </div>
                  </li>
                ))}
              </ul>
            </section>
          ))}
        </div>
      </DialogContent>
    </Dialog>
  );
}
registry/default/blocks/data-grid-keybindings/keybindings-shortcut.tsx
"use client";

import { useEffect, type ReactNode } from "react";
import { useDataGridContainer, useDataGridEditing } from "@/registry/default/components/data-grid/data-grid";

/** Props for {@link DataGridKeybindingsShortcut}. */
export type DataGridKeybindingsShortcutProps = {
  /** Called when the grid container is focused, no editor is open, and the user presses `?` (shift+/). */
  onOpen: () => void;
};

/**
 * Headless: mount INSIDE `DataGridRoot` (it reads the root's container element, so it throws
 * outside the root — the dialog itself only needs the provider) to open the keybindings dialog on
 * `?` (shift+/) while the grid container has focus and no cell editor is open — mirrors how
 * Excel/Notion reserve `?` for a shortcuts overlay only when it can't collide with typing into a
 * cell. `?` is a contested key: while this component is mounted and the grid has focus, it claims
 * `?` (it stops the native event before the grid's keymap and type-to-edit see it), shadowing
 * type-to-edit and any `keymap` binding on `?`.
 */
export function DataGridKeybindingsShortcut(props: DataGridKeybindingsShortcutProps): ReactNode {
  const { onOpen } = props;
  const containerRef = useDataGridContainer();
  const editing = useDataGridEditing();

  useEffect(() => {
    const container = containerRef.current;
    if (!container || editing) return;

    function onKeyDown(event: KeyboardEvent) {
      if (event.key !== "?" || event.ctrlKey || event.metaKey || event.altKey) return;
      event.preventDefault();
      // stop the native event before it reaches React's root-delegated onKeyDown, else the
      // grid's type-to-edit fallback (no keymap binding for "?") also fires and overwrites the cell
      event.stopImmediatePropagation();
      onOpen();
    }

    container.addEventListener("keydown", onKeyDown);
    return () => container.removeEventListener("keydown", onKeyDown);
  }, [containerRef, editing, onOpen]);

  return null;
}
registry/default/blocks/data-grid-keybindings/binding-label.tsx
import type { ReactNode } from "react";
import { Kbd, KbdGroup } from "@/components/ui/kbd";

/** True when running on macOS (⌘-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);
}

const ARROW_GLYPHS: Record<string, string> = {
  ArrowUp: "↑",
  ArrowDown: "↓",
  ArrowLeft: "←",
  ArrowRight: "→",
};

/** One binding segment ("mod", "shift", "ArrowUp") to its display token, platform-aware for "mod". */
function displaySegment(segment: string): string {
  if (segment === "mod") return isMac() ? "⌘" : "Ctrl";
  if (segment === "ctrl") return "Ctrl";
  if (segment === "shift") return "Shift";
  if (segment === "alt") return isMac() ? "⌥" : "Alt";
  if (segment === " ") return "Space";
  const arrowGlyph = ARROW_GLYPHS[segment];
  if (arrowGlyph !== undefined) return arrowGlyph;
  return segment.length === 1 ? segment.toUpperCase() : segment;
}

/** Renders one binding string ("mod+shift+ArrowUp") as an ordered list of display tokens, one per `<Kbd>` chip. */
export function bindingTokens(binding: string): string[] {
  return binding.split("+").map(displaySegment);
}

/** One binding string ("mod+c") rendered as a row of `<Kbd>` chips ("⌘" "C"). */
export function BindingChips({ binding }: { binding: string }): ReactNode {
  return (
    <KbdGroup>
      {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>
  );
}
registry/default/blocks/data-grid-keybindings/action-groups.ts
import type { DataGridLabels, GridAction } from "@/registry/default/components/data-grid/data-grid";

/** Section order for the dialog; 'other' catches consumer-added actions not in this map. Display strings live in `labels.keybindings.categories` (i18n). */
export const KEYBINDING_CATEGORIES = ["navigation", "selection", "editing", "clipboardFill", "history", "other"] as const;

export type KeybindingCategory = (typeof KEYBINDING_CATEGORIES)[number];

/** Display label for `category`, from {@link DataGridLabels.keybindings.categories}. */
export function categoryLabel(category: KeybindingCategory, labels: DataGridLabels): string {
  return labels.keybindings.categories[category];
}

/** Groups every built-in `GridAction` for the dialog; actions absent here fall back to 'other' so consumer-added actions still render. */
export const ACTION_CATEGORY: Partial<Record<GridAction, KeybindingCategory>> = {
  moveUp: "navigation",
  moveDown: "navigation",
  moveLeft: "navigation",
  moveRight: "navigation",
  retainMoveUp: "navigation",
  retainMoveDown: "navigation",
  retainMoveLeft: "navigation",
  retainMoveRight: "navigation",
  scrollActiveIntoView: "navigation",
  moveRowStart: "navigation",
  moveRowEnd: "navigation",
  jumpUp: "navigation",
  jumpDown: "navigation",
  jumpLeft: "navigation",
  jumpRight: "navigation",
  moveFirstCell: "navigation",
  moveLastCell: "navigation",
  pageUp: "navigation",
  pageDown: "navigation",

  extendUp: "selection",
  extendDown: "selection",
  extendLeft: "selection",
  extendRight: "selection",
  extendJumpUp: "selection",
  extendJumpDown: "selection",
  extendJumpLeft: "selection",
  extendJumpRight: "selection",
  extendFirstCell: "selection",
  extendLastCell: "selection",
  selectRow: "selection",
  selectColumn: "selection",
  selectAll: "selection",

  edit: "editing",
  editReplace: "editing",
  commitDown: "editing",
  commitUp: "editing",
  commitRight: "editing",
  commitLeft: "editing",
  cancel: "editing",
  deleteContents: "editing",
  insertRowBelow: "editing",
  insertRowAbove: "editing",
  duplicateRow: "editing",
  deleteRows: "editing",

  fillDown: "clipboardFill",
  fillRight: "clipboardFill",

  undo: "history",
  redo: "history",
};

/** Category for `action`, defaulting to 'other' for anything not in {@link ACTION_CATEGORY} (consumer-added actions). */
export function categoryForAction(action: GridAction): KeybindingCategory {
  return ACTION_CATEGORY[action] ?? "other";
}
registry/default/blocks/data-grid-keybindings/action-labels.ts
import type { DataGridLabels, GridAction } from "@/registry/default/components/data-grid/data-grid";

/** Splits "camelCaseAction" into "Camel case action" for actions missing from {@link DataGridLabels.keybindings.actions}. */
function humanizeActionName(action: string): string {
  const spaced = action.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}

/** Human label for `action` from {@link DataGridLabels.keybindings.actions}, falling back to a humanized form of its name for consumer-added actions. */
export function labelForAction(action: GridAction, labels: DataGridLabels): string {
  return labels.keybindings.actions[action] ?? humanizeActionName(action);
}
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 }

More from gridcn

Browse all components in this library →