{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-grid",
  "title": "Data Grid",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A spreadsheet-style grid for thousands of rows: only visible rows are drawn, cells edit in place, headings sort and resize, columns pin, and it follows the ARIA grid keyboard pattern with undo and paste.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/checkbox.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/data-grid.tsx",
      "content": "// Ballmac UI: Data Grid. https://ui.ballmac.com/components/data-grid\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, Check, ChevronsUpDown } from \"lucide-react\"\n\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Checkbox } from \"@/components/ballmac/checkbox\"\nimport { StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { useDirection, type TextDirection } from \"@/lib/ballmac/direction\"\nimport { useLocale, useMessages, type Msg } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\ntype DataGridTone = \"neutral\" | \"success\" | \"warning\" | \"error\"\ntype DataGridOption = { value: string; label: string; tone?: DataGridTone }\ntype DataGridColumnType = \"text\" | \"number\" | \"boolean\" | \"select\" | \"date\"\n\ntype DataGridColumn<T> = {\n  /** A stable id. Also the key edits and sorting report. */\n  id: string\n  /** The column heading. */\n  header: string\n  /** Read the value from this key of the row. */\n  accessorKey?: keyof T & string\n  /** Or compute it. Then give `setValue` if the column is editable. */\n  accessorFn?: (row: T) => unknown\n  /** How the value is shown, edited, sorted and pasted. */\n  type?: DataGridColumnType\n  /** Width in pixels. People can drag it. */\n  width?: number\n  minWidth?: number\n  align?: \"start\" | \"center\" | \"end\"\n  /** Whether cells can be edited. A function decides per row. */\n  editable?: boolean | ((row: T) => boolean)\n  sortable?: boolean\n  resizable?: boolean\n  /** Keep the column at the start edge while the rest scrolls sideways. */\n  pin?: \"start\"\n  /** The choices of a `select` column. */\n  options?: DataGridOption[]\n  /** Write the value for display. Numbers and dates are formatted for the locale when you leave it out. */\n  format?: (value: unknown, row: T) => React.ReactNode\n  /** Replace the display of a cell entirely. */\n  cell?: (info: { value: unknown; row: T; rowIndex: number }) => React.ReactNode\n  /** Return a message to refuse an edit; it is shown on the cell and read out. */\n  validate?: (value: unknown, row: T) => string | undefined\n  /** Turn typed or pasted text into the value. */\n  parse?: (text: string) => unknown\n  /** Return the row with the value written. Defaults to `{ ...row, [accessorKey]: value }`. */\n  setValue?: (row: T, value: unknown) => T\n  /** What to sort by. Defaults to the value. */\n  sortValue?: (row: T) => string | number | boolean | null | undefined\n  /** `Intl.NumberFormat` options for number columns. */\n  numberFormat?: Intl.NumberFormatOptions\n}\n\ntype DataGridSort = { id: string; desc: boolean }\ntype DataGridChange<T> = { rowId: string; columnId: string; value: unknown; previous: unknown; row: T }\n\ntype DataGridProps<T> = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> &\n  StateProps & {\n    /** The grid's name, read by screen readers. */\n    label: string\n    columns: DataGridColumn<T>[]\n    /** The rows, when you hold them in state. Pair with `onDataChange`. */\n    data?: T[]\n    /** The rows to start with, when the grid holds them. */\n    defaultData?: T[]\n    /** Called with the new rows and the cells that changed, after an edit, a paste or an undo. */\n    onDataChange?: (rows: T[], changes: DataGridChange<T>[]) => void\n    /** Called for every cell that changes. */\n    onCellChange?: (change: DataGridChange<T>) => void\n    /** A stable id for each row. Defaults to `row.id`, then the position. */\n    getRowId?: (row: T, index: number) => string\n    /** The height of the scrolling area. Rows beyond it are not drawn until you scroll to them. */\n    height?: number | string\n    /** Row height. */\n    density?: \"compact\" | \"default\" | \"comfortable\"\n    /** Show a checkbox on each row. */\n    selectable?: boolean\n    selectedRowIds?: string[]\n    defaultSelectedRowIds?: string[]\n    onSelectedRowIdsChange?: (ids: string[]) => void\n    /** Sorting, when you hold it in state. */\n    sorting?: DataGridSort[]\n    defaultSorting?: DataGridSort[]\n    onSortingChange?: (sorting: DataGridSort[]) => void\n    /** Show only rows with a cell that contains this text. */\n    search?: string\n    /** The grid reads right to left. Defaults to the page. */\n    dir?: TextDirection\n    /** Rows drawn beyond the visible ones, for smooth scrolling. */\n    overscan?: number\n    /** Shown in the bar under the grid, beside the row count. */\n    footer?: React.ReactNode\n  }\n\nconst ROW_HEIGHT = { compact: 32, default: 40, comfortable: 52 } as const\nconst HEADER_HEIGHT = 40\nconst SELECT_WIDTH = 44\nconst SELECT_ID = \"__select\"\nconst MAX_HISTORY = 200\n\nconst keyOf = <T,>(col: DataGridColumn<T>) => col.accessorKey ?? col.id\n\nfunction readValue<T>(col: DataGridColumn<T>, row: T): unknown {\n  if (col.accessorFn) return col.accessorFn(row)\n  const key = col.accessorKey\n  return key ? (row as Record<string, unknown>)[key] : undefined\n}\n\nfunction writeValue<T>(col: DataGridColumn<T>, row: T, value: unknown): T {\n  if (col.setValue) return col.setValue(row, value)\n  const key = col.accessorKey\n  return key ? ({ ...(row as object), [key]: value } as T) : row\n}\n\nconst isEditable = <T,>(col: DataGridColumn<T>, row: T) => (typeof col.editable === \"function\" ? col.editable(row) : !!col.editable)\n\nfunction defaultParse<T>(col: DataGridColumn<T>, text: string): unknown {\n  const t = col.type ?? \"text\"\n  if (col.parse) return col.parse(text)\n  if (t === \"number\") {\n    const cleaned = text.replace(/[\\s,]/g, \"\").replace(/^[$€£¥₹]/, \"\")\n    if (cleaned === \"\") return null\n    const n = Number(cleaned)\n    return Number.isFinite(n) ? n : text\n  }\n  if (t === \"boolean\") return /^(true|yes|y|1|on|✓)$/i.test(text.trim())\n  if (t === \"select\") {\n    const byLabel = col.options?.find((o) => o.label.toLowerCase() === text.trim().toLowerCase() || o.value.toLowerCase() === text.trim().toLowerCase())\n    return byLabel ? byLabel.value : text.trim()\n  }\n  return text\n}\n\nfunction toText<T>(col: DataGridColumn<T>, value: unknown): string {\n  if (value === null || value === undefined) return \"\"\n  if (col.type === \"select\") return col.options?.find((o) => o.value === value)?.label ?? String(value)\n  if (col.type === \"boolean\") return value ? \"true\" : \"false\"\n  return String(value)\n}\n\nfunction compare(a: unknown, b: unknown, locale: string) {\n  const empty = (v: unknown) => v === null || v === undefined || v === \"\"\n  if (empty(a) && empty(b)) return 0\n  if (empty(a)) return 1\n  if (empty(b)) return -1\n  if (typeof a === \"number\" && typeof b === \"number\") return a - b\n  if (typeof a === \"boolean\" && typeof b === \"boolean\") return Number(a) - Number(b)\n  return String(a).localeCompare(String(b), locale, { numeric: true, sensitivity: \"base\" })\n}\n\n/** Clamp without NaN surprises. */\nconst clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))\n\n/** What every column of a kind refuses, before the column's own `validate`. */\nfunction builtInProblem<T>(column: DataGridColumn<T>, value: unknown, msg: Msg): string | undefined {\n  if (value === null || value === undefined || value === \"\") return undefined\n  if (column.type === \"number\" && typeof value !== \"number\") return msg(\"data-grid.notNumber\", \"Enter a number.\")\n  if (column.type === \"date\" && !/^\\d{4}-\\d{2}-\\d{2}$/.test(String(value))) return msg(\"data-grid.notDate\", \"Enter a date as year-month-day.\")\n  if (column.type === \"select\" && column.options && !column.options.some((o) => o.value === value)) return msg(\"data-grid.notOption\", \"Choose one of the options.\")\n  return undefined\n}\n\ntype Edit = { row: number; col: number; draft: string; error?: string }\n\ntype Cell<T> = { column: DataGridColumn<T> | null; id: string }\n\n/**\n * A spreadsheet-style grid for thousands of rows. Only the rows in view are drawn. Move with the arrow keys, press Enter or start typing to edit,\n * sort and resize the headings, pin a column, select rows, paste from a spreadsheet and undo with Ctrl+Z. It follows the ARIA grid pattern and\n * mirrors in right-to-left.\n */\nfunction DataGrid<T>({\n  label,\n  columns,\n  data,\n  defaultData,\n  onDataChange,\n  onCellChange,\n  getRowId,\n  height = 420,\n  density = \"default\",\n  selectable = false,\n  selectedRowIds,\n  defaultSelectedRowIds,\n  onSelectedRowIdsChange,\n  sorting,\n  defaultSorting,\n  onSortingChange,\n  search = \"\",\n  dir: dirProp,\n  overscan = 6,\n  footer,\n  state,\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: DataGridProps<T>) {\n  const msg = useMessages()\n  const locale = useLocale()\n  const dir = useDirection(dirProp)\n  const rtl = dir === \"rtl\"\n  const rowHeight = ROW_HEIGHT[density]\n  const gridId = React.useId()\n\n  // ---------- the rows, held here or by the caller\n  const [innerData, setInnerData] = React.useState<T[]>(defaultData ?? [])\n  const rows = data ?? innerData\n  const idOf = React.useCallback(\n    (row: T, index: number) => (getRowId ? getRowId(row, index) : String((row as { id?: unknown })?.id ?? index)),\n    [getRowId]\n  )\n\n  // ---------- sorting and selection, controlled or not\n  const [innerSort, setInnerSort] = React.useState<DataGridSort[]>(defaultSorting ?? [])\n  const sort = sorting ?? innerSort\n  const setSort = (next: DataGridSort[]) => {\n    if (sorting === undefined) setInnerSort(next)\n    onSortingChange?.(next)\n  }\n  const [innerSel, setInnerSel] = React.useState<string[]>(defaultSelectedRowIds ?? [])\n  const selectedIds = selectedRowIds ?? innerSel\n  const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds])\n  const setSelected = (next: string[]) => {\n    if (selectedRowIds === undefined) setInnerSel(next)\n    onSelectedRowIdsChange?.(next)\n  }\n\n  // ---------- widths\n  const [widths, setWidths] = React.useState<Record<string, number>>({})\n  const widthOf = React.useCallback((c: DataGridColumn<T>) => widths[c.id] ?? c.width ?? 160, [widths])\n\n  // pinned columns first, in the order given\n  const ordered = React.useMemo(() => [...columns.filter((c) => c.pin === \"start\"), ...columns.filter((c) => c.pin !== \"start\")], [columns])\n  const cells: Cell<T>[] = React.useMemo(\n    () => [...(selectable ? [{ column: null, id: SELECT_ID }] : []), ...ordered.map((c) => ({ column: c, id: c.id }))],\n    [selectable, ordered]\n  )\n  const offsets = React.useMemo(() => {\n    let x = 0\n    return cells.map((c) => {\n      const start = x\n      x += c.column ? widthOf(c.column) : SELECT_WIDTH\n      return start\n    })\n  }, [cells, widthOf])\n  const totalWidth = cells.reduce((sum, c) => sum + (c.column ? widthOf(c.column) : SELECT_WIDTH), 0)\n  const pinnedCount = cells.filter((c) => !c.column || c.column.pin === \"start\").length\n  const pinnedWidth = pinnedCount ? offsets[pinnedCount - 1]! + (cells[pinnedCount - 1]!.column ? widthOf(cells[pinnedCount - 1]!.column!) : SELECT_WIDTH) : 0\n\n  // ---------- the rows in view: filtered, then sorted\n  const view = React.useMemo(() => {\n    const q = search.trim().toLowerCase()\n    let list = rows.map((row, index) => ({ row, index, id: idOf(row, index) }))\n    if (q) {\n      list = list.filter(({ row }) =>\n        ordered.some((c) => {\n          const v = readValue(c, row)\n          return v !== null && v !== undefined && toText(c, v).toLowerCase().includes(q)\n        })\n      )\n    }\n    if (sort.length) {\n      const keys = sort\n        .map((s) => ({ s, col: ordered.find((c) => c.id === s.id) }))\n        .filter((k): k is { s: DataGridSort; col: DataGridColumn<T> } => !!k.col)\n      list = [...list].sort((a, b) => {\n        for (const { s, col } of keys) {\n          const va = col.sortValue ? col.sortValue(a.row) : readValue(col, a.row)\n          const vb = col.sortValue ? col.sortValue(b.row) : readValue(col, b.row)\n          const c = compare(va, vb, locale)\n          if (c !== 0) return s.desc ? -c : c\n        }\n        return a.index - b.index\n      })\n    }\n    return list\n  }, [rows, ordered, sort, search, idOf, locale])\n\n  // ---------- scrolling and the window of rows drawn\n  const scroller = React.useRef<HTMLDivElement>(null)\n  const [scrollTop, setScrollTop] = React.useState(0)\n  const [scrolledX, setScrolledX] = React.useState(false)\n  const [viewport, setViewport] = React.useState(typeof height === \"number\" ? height : 420)\n  React.useEffect(() => {\n    const el = scroller.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const ro = new ResizeObserver(() => el.clientHeight && setViewport(el.clientHeight))\n    ro.observe(el)\n    if (el.clientHeight) setViewport(el.clientHeight)\n    return () => ro.disconnect()\n  }, [])\n  const frame = React.useRef(0)\n  const onScroll = (e: React.UIEvent<HTMLDivElement>) => {\n    const el = e.currentTarget\n    cancelAnimationFrame(frame.current)\n    frame.current = requestAnimationFrame(() => {\n      setScrollTop(el.scrollTop)\n      setScrolledX(Math.abs(el.scrollLeft) > 1)\n    })\n  }\n  React.useEffect(() => () => cancelAnimationFrame(frame.current), [])\n\n  // ---------- the active cell, editing, history\n  const [active, setActive] = React.useState<{ row: number; col: number } | null>(null)\n  const [edit, setEdit] = React.useState<Edit | null>(null)\n  const focusNext = React.useRef(false)\n  // each entry is one edit, or one paste: undo takes back the whole thing\n  const undo = React.useRef<DataGridChange<T>[][]>([])\n  const redo = React.useRef<DataGridChange<T>[][]>([])\n  const [announce, setAnnounce] = React.useState(\"\")\n\n  const rowCount = view.length\n  const colCount = cells.length\n  const first = clamp(Math.floor(scrollTop / rowHeight) - overscan, 0, Math.max(0, rowCount - 1))\n  const last = clamp(Math.ceil((scrollTop + viewport) / rowHeight) + overscan, 0, rowCount)\n  const drawn = new Set<number>()\n  for (let i = first; i < last; i++) drawn.add(i)\n  // the cell with the keyboard stays in the document even when scrolled away, so focus is never lost\n  if (active && active.row < rowCount) drawn.add(active.row)\n  const drawnRows = [...drawn].sort((a, b) => a - b)\n\n  const move = (row: number, col: number) => {\n    if (!rowCount || !colCount) return\n    focusNext.current = true\n    setActive({ row: clamp(row, 0, rowCount - 1), col: clamp(col, 0, colCount - 1) })\n  }\n\n  React.useLayoutEffect(() => {\n    if (!focusNext.current || !active) return\n    focusNext.current = false\n    const el = scroller.current?.querySelector<HTMLElement>(`[data-row=\"${active.row}\"][data-col=\"${active.col}\"]`)\n    el?.focus()\n  }, [active, drawnRows.length])\n\n  // ---------- writing values: one or many cells at once, so a paste is a single change and a single undo\n  const applyBatch = React.useCallback(\n    (items: { row: number; col: number; value: unknown }[], options: { record?: boolean; strict?: boolean } = {}): { problem?: string; applied: number } => {\n      const { record = true, strict = true } = options\n      let working = rows\n      const changes: DataGridChange<T>[] = []\n      let problem: string | undefined\n      for (const { row, col, value } of items) {\n        const item = view[row]\n        const column = cells[col]?.column\n        if (!item || !column) continue\n        const current = working[item.index]!\n        const previous = readValue(column, current)\n        if (Object.is(previous, value)) continue\n        const bad = builtInProblem(column, value, msg) ?? column.validate?.(value, current)\n        if (bad) {\n          if (strict) return { problem: bad, applied: 0 }\n          problem ??= bad\n          continue\n        }\n        if (working === rows) working = [...rows]\n        const nextRow = writeValue(column, current, value)\n        working[item.index] = nextRow\n        changes.push({ rowId: item.id, columnId: column.id, value, previous, row: nextRow })\n      }\n      if (!changes.length) return { problem, applied: 0 }\n      if (data === undefined) setInnerData(working)\n      onDataChange?.(working, changes)\n      for (const c of changes) onCellChange?.(c)\n      if (record) {\n        undo.current = [...undo.current.slice(-(MAX_HISTORY - 1)), changes]\n        redo.current = []\n      }\n      return { problem, applied: changes.length }\n    },\n    [view, cells, rows, data, onDataChange, onCellChange, msg]\n  )\n\n  const applyHistory = (from: React.MutableRefObject<DataGridChange<T>[][]>, to: React.MutableRefObject<DataGridChange<T>[][]>, direction: \"undo\" | \"redo\") => {\n    const batch = from.current.at(-1)\n    if (!batch) return\n    const ordered = direction === \"undo\" ? [...batch].reverse() : batch\n    const items = ordered\n      .map((c) => ({ row: view.findIndex((v) => v.id === c.rowId), col: cells.findIndex((x) => x.id === c.columnId), value: direction === \"undo\" ? c.previous : c.value }))\n      .filter((i) => i.row >= 0 && i.col >= 0)\n    from.current = from.current.slice(0, -1)\n    if (!items.length) return\n    const result = applyBatch(items, { record: false, strict: false })\n    if (!result.applied) return\n    to.current = [...to.current, batch]\n    move(items[0]!.row, items[0]!.col)\n    setAnnounce(\n      direction === \"undo\"\n        ? msg(\"data-grid.undone\", { one: \"Undid {count} change\", other: \"Undid {count} changes\" }, { count: result.applied })\n        : msg(\"data-grid.redone\", { one: \"Redid {count} change\", other: \"Redid {count} changes\" }, { count: result.applied })\n    )\n  }\n\n  // ---------- editing\n  const startEdit = (row: number, col: number, seed?: string) => {\n    const item = view[row]\n    const column = cells[col]?.column\n    if (!item || !column || !isEditable(column, item.row)) return\n    const value = readValue(column, item.row)\n    setEdit({ row, col, draft: seed ?? (column.type === \"select\" ? String(value ?? \"\") : toText(column, value)) })\n  }\n\n  const finishEdit = (apply: boolean, then?: \"down\" | \"up\" | \"next\" | \"previous\") => {\n    if (!edit) return\n    const column = cells[edit.col]?.column\n    const item = view[edit.row]\n    if (!column || !item) return setEdit(null)\n    if (apply) {\n      const value = column.type === \"select\" ? (edit.draft === \"\" ? null : edit.draft) : defaultParse(column, edit.draft)\n      const { problem } = applyBatch([{ row: edit.row, col: edit.col, value }])\n      if (problem) {\n        setEdit({ ...edit, error: problem })\n        setAnnounce(problem)\n        return\n      }\n    }\n    setEdit(null)\n    const { row, col } = edit\n    if (then === \"down\") move(row + 1, col)\n    else if (then === \"up\") move(row - 1, col)\n    else if (then === \"next\") move(col + 1 < colCount ? row : row + 1, col + 1 < colCount ? col + 1 : 0)\n    else if (then === \"previous\") move(col > 0 ? row : row - 1, col > 0 ? col - 1 : colCount - 1)\n    else move(row, col)\n  }\n\n  // ---------- selection\n  const toggleRow = (id: string, on?: boolean) => {\n    const has = selectedSet.has(id)\n    const want = on ?? !has\n    if (want === has) return\n    setSelected(want ? [...selectedIds, id] : selectedIds.filter((s) => s !== id))\n  }\n  const allSelected = view.length > 0 && view.every((v) => selectedSet.has(v.id))\n  const someSelected = view.some((v) => selectedSet.has(v.id))\n  const toggleAll = () => {\n    if (allSelected) setSelected(selectedIds.filter((id) => !view.some((v) => v.id === id)))\n    else setSelected([...new Set([...selectedIds, ...view.map((v) => v.id)])])\n  }\n\n  // ---------- sorting\n  const sortBy = (column: DataGridColumn<T>, additive: boolean) => {\n    const current = sort.find((s) => s.id === column.id)\n    const rest = additive ? sort.filter((s) => s.id !== column.id) : []\n    if (!current) return setSort([...rest, { id: column.id, desc: false }])\n    if (!current.desc) return setSort([...rest, { id: column.id, desc: true }])\n    setSort(rest)\n  }\n\n  // ---------- keyboard\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (edit) return // the input handles its own keys\n    const target = e.target as HTMLElement\n    if (target.closest(\"input, select, textarea, [role=separator]\")) return\n    const cellEl = target.closest<HTMLElement>(\"[data-row][data-col]\")\n    if (!cellEl) return\n    // a control inside the cell (its checkbox) keeps Space and Enter; movement still works from it\n    const inner = target !== cellEl\n    const row = Number(cellEl.dataset.row)\n    const col = Number(cellEl.dataset.col)\n    const start = rtl ? \"ArrowRight\" : \"ArrowLeft\"\n    const end = rtl ? \"ArrowLeft\" : \"ArrowRight\"\n    const page = Math.max(1, Math.floor((viewport - HEADER_HEIGHT) / rowHeight) - 1)\n    const mod = e.ctrlKey || e.metaKey\n    const column = cells[col]?.column\n    const item = view[row]\n    const flip = (r: number, c: number) => applyBatch([{ row: r, col: c, value: !readValue(cells[c]!.column!, view[r]!.row) }])\n    let handled = true\n    if (mod && !e.altKey && [\"a\", \"z\", \"y\"].includes(e.key.toLowerCase())) {\n      const k = e.key.toLowerCase()\n      if (k === \"a\" && selectable) toggleAll()\n      else if (k === \"z\") applyHistory(e.shiftKey ? redo : undo, e.shiftKey ? undo : redo, e.shiftKey ? \"redo\" : \"undo\")\n      else if (k === \"y\") applyHistory(redo, undo, \"redo\")\n      else return\n      e.preventDefault()\n      return\n    }\n    switch (e.key) {\n      case \"ArrowDown\": move(row + 1, col); break\n      case \"ArrowUp\": move(row - 1, col); break\n      case end: move(row, col + 1); break\n      case start: move(row, col - 1); break\n      case \"Home\": move(mod ? 0 : row, 0); break\n      case \"End\": move(mod ? rowCount - 1 : row, colCount - 1); break\n      case \"PageDown\": move(row + page, col); break\n      case \"PageUp\": move(row - page, col); break\n      case \"Enter\":\n      case \"F2\":\n        if (inner || !column || !item || !isEditable(column, item.row)) handled = false\n        else if (column.type === \"boolean\") flip(row, col)\n        else startEdit(row, col)\n        break\n      case \" \":\n        if (inner || !item) handled = false\n        else if (!column) toggleRow(item.id)\n        else if (column.type === \"boolean\" && isEditable(column, item.row)) flip(row, col)\n        else if (e.shiftKey && selectable) toggleRow(item.id)\n        else handled = false\n        break\n      case \"Delete\":\n      case \"Backspace\":\n        if (column && item && isEditable(column, item.row)) applyBatch([{ row, col, value: column.type === \"boolean\" ? false : null }])\n        else handled = false\n        break\n      default:\n        if (!inner && !mod && !e.altKey && e.key.length === 1 && column && item && isEditable(column, item.row) && column.type !== \"boolean\" && column.type !== \"select\") startEdit(row, col, e.key)\n        else handled = false\n    }\n    if (handled) e.preventDefault()\n  }\n\n  // ---------- clipboard: copy the cell, paste rows and columns from a spreadsheet\n  const onCopy = (e: React.ClipboardEvent<HTMLDivElement>) => {\n    if (edit || !active) return\n    const item = view[active.row]\n    const column = cells[active.col]?.column\n    if (!item || !column) return\n    e.clipboardData.setData(\"text/plain\", toText(column, readValue(column, item.row)))\n    e.preventDefault()\n  }\n  const onPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {\n    if (edit || !active) return\n    const text = e.clipboardData.getData(\"text/plain\")\n    if (!text) return\n    e.preventDefault()\n    const lines = text.replace(/\\r\\n?/g, \"\\n\").replace(/\\n$/, \"\").split(\"\\n\").map((l) => l.split(\"\\t\"))\n    const items: { row: number; col: number; value: unknown }[] = []\n    lines.forEach((line, dr) =>\n      line.forEach((text, dc) => {\n        const item = view[active.row + dr]\n        const column = cells[active.col + dc]?.column\n        if (item && column && isEditable(column, item.row)) items.push({ row: active.row + dr, col: active.col + dc, value: defaultParse(column, text) })\n      })\n    )\n    const { applied, problem } = applyBatch(items, { strict: false })\n    setAnnounce(\n      problem\n        ? msg(\"data-grid.pastedSome\", \"Pasted {count} cells. Some were refused: {problem}\", { count: applied, problem })\n        : msg(\"data-grid.pasted\", { one: \"Pasted {count} cell\", other: \"Pasted {count} cells\" }, { count: applied })\n    )\n  }\n\n  // ---------- resizing\n  const resize = (column: DataGridColumn<T>, next: number) => setWidths((w) => ({ ...w, [column.id]: clamp(Math.round(next), column.minWidth ?? 64, 800) }))\n  const startResize = (column: DataGridColumn<T>) => (e: React.PointerEvent<HTMLDivElement>) => {\n    e.preventDefault()\n    e.stopPropagation()\n    const el = e.currentTarget\n    el.setPointerCapture(e.pointerId)\n    const startX = e.clientX\n    const startW = widthOf(column)\n    const sign = rtl ? -1 : 1\n    const mv = (ev: PointerEvent) => resize(column, startW + sign * (ev.clientX - startX))\n    const up = () => {\n      el.removeEventListener(\"pointermove\", mv)\n      el.removeEventListener(\"pointerup\", up)\n      el.removeEventListener(\"pointercancel\", up)\n    }\n    el.addEventListener(\"pointermove\", mv)\n    el.addEventListener(\"pointerup\", up)\n    el.addEventListener(\"pointercancel\", up)\n  }\n\n  // ---------- drawing\n  const numberFormatters = React.useMemo(() => new Map<string, Intl.NumberFormat>(), [])\n  const display = (column: DataGridColumn<T>, row: T, value: unknown, rowIndex: number): React.ReactNode => {\n    if (column.cell) return column.cell({ value, row, rowIndex })\n    if (column.format) return column.format(value, row)\n    if (value === null || value === undefined || value === \"\") return <span className=\"text-muted-foreground/60\" aria-hidden=\"true\">–</span>\n    switch (column.type) {\n      case \"number\": {\n        const k = JSON.stringify(column.numberFormat ?? {})\n        let nf = numberFormatters.get(k)\n        if (!nf) numberFormatters.set(k, (nf = new Intl.NumberFormat(locale, column.numberFormat)))\n        return typeof value === \"number\" ? nf.format(value) : String(value)\n      }\n      case \"boolean\":\n        return value ? (\n          <span data-boolean=\"\" className=\"inline-flex size-4 items-center justify-center rounded-[4px] border border-primary bg-primary text-primary-foreground\" role=\"img\" aria-label={msg(\"data-grid.yes\", \"Yes\")}>\n            <Check className=\"size-3\" strokeWidth={3} aria-hidden=\"true\" />\n          </span>\n        ) : (\n          <span data-boolean=\"\" className=\"inline-flex size-4 rounded-[4px] border border-input bg-background\" role=\"img\" aria-label={msg(\"data-grid.no\", \"No\")} />\n        )\n      case \"select\": {\n        const o = column.options?.find((opt) => opt.value === value)\n        return (\n          <Badge status={o?.tone ?? \"neutral\"} className=\"max-w-full truncate\">\n            {o?.label ?? String(value)}\n          </Badge>\n        )\n      }\n      case \"date\": {\n        const d = new Date(`${String(value)}T00:00:00`)\n        return Number.isNaN(d.getTime()) ? String(value) : new Intl.DateTimeFormat(locale, { dateStyle: \"medium\" }).format(d)\n      }\n      default:\n        return String(value)\n    }\n  }\n\n  const gridCols = cells.map((c) => `${c.column ? widthOf(c.column) : SELECT_WIDTH}px`).join(\" \")\n  const alignClass = (a?: \"start\" | \"center\" | \"end\", type?: DataGridColumnType) => {\n    const eff = a ?? (type === \"number\" ? \"end\" : \"start\")\n    return eff === \"end\" ? \"justify-end text-end\" : eff === \"center\" ? \"justify-center text-center\" : \"justify-start text-start\"\n  }\n  const pinStyle = (i: number, z: number): React.CSSProperties => ({ position: \"sticky\", insetInlineStart: offsets[i], zIndex: z })\n  const isPinned = (i: number) => i < pinnedCount\n  const lastPinned = (i: number) => i === pinnedCount - 1 && pinnedCount > 0\n  const pinnedEdge = (i: number) => lastPinned(i) && \"border-e border-border\"\n  const pinnedShadow = (i: number) =>\n    lastPinned(i) && scrolledX && \"after:pointer-events-none after:absolute after:inset-y-0 after:-end-3 after:w-3 after:bg-gradient-to-r after:from-foreground/10 after:to-transparent rtl:after:bg-gradient-to-l\"\n\n  const sortOf = (column: DataGridColumn<T>) => sort.find((s) => s.id === column.id)\n  const effective: StateProps[\"state\"] = state ?? (rows.length === 0 ? \"empty\" : \"ready\")\n\n  const body = (\n    <div\n      role=\"grid\"\n      aria-label={label}\n      aria-rowcount={rowCount + 1}\n      aria-colcount={colCount}\n      aria-multiselectable={selectable || undefined}\n      dir={dir}\n      onKeyDown={onKeyDown}\n      onCopy={onCopy}\n      onPaste={onPaste}\n      data-scrolled={scrolledX || undefined}\n      style={{ width: totalWidth, minWidth: \"100%\" }}\n      className=\"group/grid relative text-sm outline-none\"\n    >\n      <div role=\"rowgroup\" className=\"sticky top-0 z-30\">\n        <div role=\"row\" aria-rowindex={1} className=\"grid bg-muted/80 backdrop-blur-sm\" style={{ gridTemplateColumns: gridCols, height: HEADER_HEIGHT }}>\n          {cells.map((c, i) => {\n            const column = c.column\n            const sorted = column ? sortOf(column) : undefined\n            const sortable = !!column && column.sortable !== false\n            return (\n              <div\n                key={c.id}\n                role=\"columnheader\"\n                aria-colindex={i + 1}\n                aria-sort={sorted ? (sorted.desc ? \"descending\" : \"ascending\") : sortable ? \"none\" : undefined}\n                style={isPinned(i) ? pinStyle(i, 31) : undefined}\n                className={cn(\n                  \"relative flex items-center border-b border-border bg-muted px-3 font-medium text-muted-foreground\",\n                  column ? alignClass(column.align, column.type) : \"justify-center px-0\",\n                  isPinned(i) && \"bg-muted\",\n                  pinnedEdge(i),\n                  pinnedShadow(i)\n                )}\n              >\n                {!column ? (\n                  <Checkbox\n                    aria-label={msg(\"data-grid.selectAll\", \"Select all rows\")}\n                    checked={allSelected ? true : someSelected ? \"indeterminate\" : false}\n                    onCheckedChange={toggleAll}\n                  />\n                ) : sortable ? (\n                  <button\n                    type=\"button\"\n                    onClick={(e) => sortBy(column, e.shiftKey)}\n                    className=\"group/sort -mx-1.5 flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-[pressed=true]:text-foreground\"\n                    aria-pressed={!!sorted}\n                    title={msg(\"data-grid.sortHint\", \"Sort by {column}. Hold Shift to add a second sort.\", { column: column.header })}\n                  >\n                    <span className=\"truncate\">{column.header}</span>\n                    {sorted ? (\n                      sorted.desc ? <ArrowDown className=\"size-3.5 shrink-0\" aria-hidden=\"true\" /> : <ArrowUp className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n                    ) : (\n                      <ChevronsUpDown className=\"size-3.5 shrink-0 opacity-0 transition-opacity group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60 motion-reduce:transition-none\" aria-hidden=\"true\" />\n                    )}\n                    {sorted && sort.length > 1 ? <span className=\"text-[10px] tabular-nums text-muted-foreground\" aria-hidden=\"true\">{sort.findIndex((s) => s.id === column.id) + 1}</span> : null}\n                  </button>\n                ) : (\n                  <span className=\"truncate\">{column.header}</span>\n                )}\n                {column && column.resizable !== false ? (\n                  <div\n                    role=\"separator\"\n                    aria-orientation=\"vertical\"\n                    aria-label={msg(\"data-grid.resize\", \"Resize {column}\", { column: column.header })}\n                    aria-valuenow={widthOf(column)}\n                    aria-valuemin={column.minWidth ?? 64}\n                    aria-valuemax={800}\n                    tabIndex={0}\n                    onPointerDown={startResize(column)}\n                    onDoubleClick={() => setWidths((w) => { const { [column.id]: _, ...rest } = w; return rest })}\n                    onKeyDown={(e) => {\n                      const step = e.shiftKey ? 48 : 16\n                      const grow = rtl ? \"ArrowLeft\" : \"ArrowRight\"\n                      const shrink = rtl ? \"ArrowRight\" : \"ArrowLeft\"\n                      if (e.key === grow) resize(column, widthOf(column) + step)\n                      else if (e.key === shrink) resize(column, widthOf(column) - step)\n                      else return\n                      e.preventDefault()\n                      e.stopPropagation()\n                    }}\n                    className=\"absolute inset-y-1.5 -end-1 z-10 flex w-2 cursor-col-resize touch-none justify-center outline-none after:h-full after:w-px after:bg-border after:transition-colors hover:after:w-0.5 hover:after:bg-primary focus-visible:after:w-0.5 focus-visible:after:bg-ring motion-reduce:after:transition-none\"\n                  />\n                ) : null}\n              </div>\n            )\n          })}\n        </div>\n      </div>\n\n      <div role=\"rowgroup\" className=\"relative\" style={{ height: Math.max(rowCount, 1) * rowHeight }}>\n        {rowCount === 0 ? (\n          <div className=\"sticky start-0 flex h-24 w-[100cqw] items-center justify-center px-4 text-sm text-muted-foreground\">\n            {search.trim() ? msg(\"data-grid.noMatches\", \"No rows match “{search}”.\", { search: search.trim() }) : msg(\"data-grid.noRows\", \"No rows.\")}\n          </div>\n        ) : null}\n        {drawnRows.map((ri) => {\n          const item = view[ri]\n          if (!item) return null\n          const selected = selectedSet.has(item.id)\n          return (\n            <div\n              key={item.id}\n              role=\"row\"\n              aria-rowindex={ri + 2}\n              aria-selected={selectable ? selected : undefined}\n              data-selected={selected || undefined}\n              className={cn(\n                \"group/row absolute inset-x-0 grid border-b border-border/60 bg-card transition-colors hover:bg-muted/40 motion-reduce:transition-none\",\n                selected && \"bg-primary/5 hover:bg-primary/[0.08]\"\n              )}\n              style={{ top: ri * rowHeight, height: rowHeight, gridTemplateColumns: gridCols }}\n            >\n              {cells.map((c, ci) => {\n                const column = c.column\n                const isActive = active?.row === ri && active.col === ci\n                const tabStop = active ? isActive : ri === 0 && ci === (cells[0]?.column ? 0 : Math.min(1, colCount - 1))\n                const editing = edit?.row === ri && edit.col === ci\n                const value = column ? readValue(column, item.row) : undefined\n                const editable = !!column && isEditable(column, item.row)\n                const invalid = editing && edit?.error\n                return (\n                  <div\n                    key={c.id}\n                    role=\"gridcell\"\n                    aria-colindex={ci + 1}\n                    aria-readonly={column && !editable ? true : undefined}\n                    aria-invalid={invalid ? true : undefined}\n                    data-row={ri}\n                    data-col={ci}\n                    data-active={isActive || undefined}\n                    tabIndex={tabStop && !editing ? 0 : -1}\n                    onFocus={() => !editing && setActive((a) => (a && a.row === ri && a.col === ci ? a : { row: ri, col: ci }))}\n                    onMouseDown={() => !editing && !isActive && setActive({ row: ri, col: ci })}\n                    onClick={(e) => {\n                      // the check box in a yes/no cell works with one click, as it does in a spreadsheet\n                      if (column?.type === \"boolean\" && editable && (e.target as HTMLElement).closest(\"[data-boolean]\")) applyBatch([{ row: ri, col: ci, value: !value }])\n                    }}\n                    onDoubleClick={() => column && column.type !== \"boolean\" && startEdit(ri, ci)}\n                    style={isPinned(ci) ? pinStyle(ci, 10) : undefined}\n                    className={cn(\n                      \"relative flex min-w-0 items-center overflow-hidden px-3 outline-none\",\n                      column ? alignClass(column.align, column.type) : \"justify-center px-0\",\n                      column?.type === \"number\" && \"tabular-nums\",\n                      isPinned(ci) && (selected ? \"bg-[color-mix(in_oklab,var(--card),var(--primary)_5%)]\" : \"bg-card group-hover/row:bg-muted\"),\n                      pinnedEdge(ci),\n                      pinnedShadow(ci),\n                      isActive && \"z-20 ring-2 ring-inset ring-ring\",\n                      editing && \"bg-background ring-primary\",\n                      invalid && \"ring-destructive\"\n                    )}\n                  >\n                    {!column ? (\n                      <Checkbox\n                        tabIndex={-1}\n                        aria-label={msg(\"data-grid.selectRow\", \"Select row {n}\", { n: ri + 1 })}\n                        checked={selected}\n                        onCheckedChange={(v) => toggleRow(item.id, v === true)}\n                      />\n                    ) : editing ? (\n                      <EditorInput\n                        column={column}\n                        edit={edit!}\n                        onChange={(draft) => setEdit((cur) => (cur ? { ...cur, draft, error: undefined } : cur))}\n                        onFinish={finishEdit}\n                        label={msg(\"data-grid.editCell\", \"Edit {column}, row {n}\", { column: column.header, n: ri + 1 })}\n                        locale={locale}\n                        rtl={rtl}\n                      />\n                    ) : (\n                      <span className=\"min-w-0 truncate\">{display(column, item.row, value, item.index)}</span>\n                    )}\n                    {invalid ? (\n                      <span role=\"alert\" className=\"pointer-events-none absolute inset-x-0 top-full z-40 mt-1 w-max max-w-64 rounded-md border border-destructive/30 bg-popover px-2 py-1 text-xs text-destructive shadow-md\">\n                        {edit?.error}\n                      </span>\n                    ) : null}\n                  </div>\n                )\n              })}\n            </div>\n          )\n        })}\n      </div>\n    </div>\n  )\n\n  const skeleton = (\n    <div className=\"overflow-hidden rounded-xl border bg-card\" aria-hidden=\"true\">\n      <div className=\"grid border-b bg-muted\" style={{ gridTemplateColumns: gridCols, height: HEADER_HEIGHT }}>\n        {cells.map((c) => (\n          <div key={c.id} className=\"flex items-center px-3\">\n            {c.column ? <Skeleton className=\"h-3.5 w-2/3\" /> : null}\n          </div>\n        ))}\n      </div>\n      {Array.from({ length: Math.max(3, Math.floor((typeof height === \"number\" ? height : 420) / rowHeight) - 2) }, (_, i) => (\n        <div key={i} className=\"grid border-b border-border/60 last:border-0\" style={{ gridTemplateColumns: gridCols, height: rowHeight }}>\n          {cells.map((c, j) => (\n            <div key={c.id} className=\"flex items-center px-3\">\n              {c.column ? <Skeleton className={cn(\"h-3.5\", [\"w-4/5\", \"w-3/5\", \"w-2/3\", \"w-1/2\"][(i + j) % 4])} /> : null}\n            </div>\n          ))}\n        </div>\n      ))}\n    </div>\n  )\n\n  // numbers are written for the locale (\"20,000\"), while the plural form still follows the real count\n  const nf = new Intl.NumberFormat(locale)\n  const countText = msg(\"data-grid.rowCount\", { one: \"{n} row\", other: \"{n} rows\" }, { count: view.length, n: nf.format(view.length) })\n  const filteredText = view.length !== rows.length ? msg(\"data-grid.rowsOfTotal\", \"{n} of {total} rows\", { count: view.length, n: nf.format(view.length), total: nf.format(rows.length) }) : countText\n\n  return (\n    <div data-slot=\"data-grid\" data-density={density} className={cn(\"min-w-0\", className)} {...props}>\n      <StateView state={effective} loading={skeleton} empty={emptyState ?? {}} error={errorState ?? {}} onRetry={onRetry} label={msg(\"data-grid.loading\", \"Loading rows\")} bare className=\"rounded-xl\">\n        <div className=\"overflow-hidden rounded-xl border bg-card shadow-xs\">\n          <div\n            ref={scroller}\n            onScroll={onScroll}\n            data-slot=\"data-grid-scroller\"\n            style={{ height, scrollPaddingTop: HEADER_HEIGHT, scrollPaddingInlineStart: pinnedWidth }}\n            className=\"@container relative overflow-auto overscroll-contain focus-visible:outline-none\"\n          >\n            {body}\n          </div>\n          <div className=\"flex min-h-9 items-center justify-between gap-3 border-t bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground\">\n            <span data-slot=\"data-grid-status\" role=\"status\" aria-live=\"polite\" className=\"tabular-nums\">\n              {filteredText}\n              {selectable && selectedIds.length ? ` · ${msg(\"data-grid.selectedCount\", \"{n} selected\", { n: nf.format(selectedIds.length) })}` : \"\"}\n            </span>\n            {footer ? <span className=\"min-w-0 truncate\">{footer}</span> : null}\n          </div>\n        </div>\n      </StateView>\n      <span className=\"sr-only\" aria-live=\"polite\" role=\"status\" id={`${gridId}-announce`}>\n        {announce}\n      </span>\n    </div>\n  )\n}\n\n/** The input a cell turns into while it is edited. */\nfunction EditorInput<T>({\n  column,\n  edit,\n  onChange,\n  onFinish,\n  label,\n  locale,\n  rtl,\n}: {\n  column: DataGridColumn<T>\n  edit: Edit\n  onChange: (draft: string) => void\n  onFinish: (apply: boolean, then?: \"down\" | \"up\" | \"next\" | \"previous\") => void\n  label: string\n  locale: string\n  rtl: boolean\n}) {\n  const ref = React.useRef<HTMLInputElement & HTMLSelectElement>(null)\n  const done = React.useRef(false)\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    el.focus()\n    // typing into a cell replaces what is there; Enter and F2 edit it, with the caret at the end\n    if (\"setSelectionRange\" in el && (el as HTMLInputElement).type === \"text\") {\n      const n = el.value.length\n      try { el.setSelectionRange(n, n) } catch {}\n    }\n  }, [])\n  const finish = (apply: boolean, then?: \"down\" | \"up\" | \"next\" | \"previous\") => {\n    done.current = true\n    onFinish(apply, then)\n  }\n  const keys = (e: React.KeyboardEvent) => {\n    if (e.key === \"Enter\") finish(true, e.shiftKey ? \"up\" : \"down\")\n    else if (e.key === \"Tab\") finish(true, e.shiftKey ? \"previous\" : \"next\")\n    else if (e.key === \"Escape\") finish(false)\n    else {\n      e.stopPropagation()\n      return\n    }\n    e.preventDefault()\n    e.stopPropagation()\n  }\n  const common = {\n    ref,\n    \"aria-label\": label,\n    \"aria-invalid\": edit.error ? true : undefined,\n    onKeyDown: keys,\n    onBlur: () => {\n      if (!done.current) onFinish(true)\n    },\n    className: \"h-full w-full min-w-0 bg-transparent text-sm outline-none\",\n  } as const\n  if (column.type === \"select\") {\n    return (\n      <select {...common} value={edit.draft} onChange={(e) => onChange(e.target.value)} className={cn(common.className, \"cursor-pointer\")}>\n        <option value=\"\" />\n        {column.options?.map((o) => (\n          <option key={o.value} value={o.value}>\n            {o.label}\n          </option>\n        ))}\n      </select>\n    )\n  }\n  return (\n    <input\n      {...common}\n      type={column.type === \"date\" ? \"date\" : \"text\"}\n      inputMode={column.type === \"number\" ? \"decimal\" : undefined}\n      value={edit.draft}\n      onChange={(e) => onChange(e.target.value)}\n      dir={column.type === \"number\" || column.type === \"date\" ? \"ltr\" : undefined}\n      lang={locale}\n      className={cn(common.className, column.type === \"number\" && (rtl ? \"text-start\" : \"text-end\"), column.type === \"number\" && \"tabular-nums\")}\n    />\n  )\n}\n\nexport {\n  DataGrid,\n  type DataGridChange,\n  type DataGridColumn,\n  type DataGridColumnType,\n  type DataGridOption,\n  type DataGridProps,\n  type DataGridSort,\n  type DataGridTone,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/data-grid.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "grid",
      "table",
      "spreadsheet",
      "virtualized",
      "editable",
      "sorting",
      "keyboard"
    ],
    "version": "1.0.0",
    "updated": "2026-10-19",
    "tests": true,
    "ai": {
      "summary": "Pass columns ({ id, header, accessorKey, type: text | number | boolean | select | date, editable, width, pin }) and rows with data or defaultData. Set height; rows outside it are not drawn. Listen with onDataChange or onCellChange.",
      "whenToUse": [
        "Hundreds to tens of thousands of rows people edit like a spreadsheet",
        "Admin tools, inventories, budgets and review queues with keyboard-heavy users",
        "Wide data that needs pinned columns and resizable headings"
      ],
      "whenNotToUse": [
        "A small read-only table; use table",
        "Search, filters, column menus and pagination out of the box; use data-table",
        "Formulas, merged cells or multi-sheet workbooks"
      ],
      "composesWith": [
        "data-table",
        "badge",
        "state-view"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move between cells (left and right swap in right-to-left)"
        },
        {
          "keys": "Home / End / Ctrl+Home / Ctrl+End",
          "action": "First and last cell of the row or the grid"
        },
        {
          "keys": "Page Up / Page Down",
          "action": "Move a screenful of rows"
        },
        {
          "keys": "Enter / F2",
          "action": "Edit the cell; Enter saves and moves down, Tab saves and moves right, Escape cancels"
        },
        {
          "keys": "Type a character",
          "action": "Start editing a text, number or date cell with that character"
        },
        {
          "keys": "Space",
          "action": "Toggle a checkbox cell or the row checkbox; Shift+Space selects the row"
        },
        {
          "keys": "Delete",
          "action": "Clear an editable cell"
        },
        {
          "keys": "Ctrl+Z / Ctrl+Shift+Z",
          "action": "Undo and redo, one edit or paste at a time"
        },
        {
          "keys": "Ctrl+C / Ctrl+V",
          "action": "Copy the cell; paste cells copied from a spreadsheet"
        },
        {
          "keys": "Screen readers",
          "action": "role=grid with aria-rowcount, aria-rowindex, aria-colindex and aria-sort, so virtualized rows keep their place; edits, undo and paste are announced"
        }
      ],
      "customization": [
        "columns: type, width, pin, editable, options, validate, format, cell, parse, setValue",
        "density: compact | default | comfortable",
        "selectable with selectedRowIds, sorting with onSortingChange",
        "search to filter rows; footer for your own status text",
        "controlled data with onDataChange(rows, changes)"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "data-grid-loading",
        "trigger": "state=\"loading\"",
        "note": "Header and rows drawn as skeletons in the same columns, announced as loading; nothing can be edited."
      },
      {
        "id": "empty",
        "title": "No rows",
        "example": "data-grid-empty",
        "trigger": "state=\"empty\" or data={[]}",
        "note": "A clear message that there are no rows yet; emptyState sets the copy and can add an action. A search with no match says so inside the grid."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "data-grid-error",
        "trigger": "state=\"error\"",
        "note": "An error with a Try again button (onRetry), announced as an alert."
      }
    ],
    "examples": [
      "data-grid-demo",
      "data-grid-virtualized",
      "data-grid-pinned",
      "data-grid-validation",
      "data-grid-controlled",
      "data-grid-loading",
      "data-grid-empty",
      "data-grid-error"
    ],
    "url": "https://ui.ballmac.com/components/data-grid"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}