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 · lightRegistry 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 }