{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dashboard-grid",
  "type": "registry:ui",
  "dependencies": [
    "@dnd-kit/core",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "grid-layout.ts",
      "target": "components/ui/grid-layout.ts",
      "type": "registry:ui",
      "content": "export interface GridItem {\n  id: string;\n  /** Zero-based column of the left edge. */\n  x: number;\n  /** Zero-based row of the top edge. */\n  y: number;\n  /** Width in columns. */\n  w: number;\n  /** Height in rows. */\n  h: number;\n  minW?: number;\n  minH?: number;\n  maxW?: number;\n  maxH?: number;\n}\n\nexport interface GridBounds {\n  columns: number;\n  rows: number;\n}\n\nconst clampRange = (value: number, min: number, max: number): number =>\n  Math.min(Math.max(value, min), max);\n\n/**\n * Squeezes an item inside the grid and inside its own min/max. Size is settled\n * before position, because how far left an item may sit depends on how wide it\n * ended up being.\n */\nexport function clampItem(item: GridItem, bounds: GridBounds): GridItem {\n  const w = clampRange(\n    item.w,\n    Math.max(item.minW ?? 1, 1),\n    Math.min(item.maxW ?? bounds.columns, bounds.columns),\n  );\n  const h = clampRange(\n    item.h,\n    Math.max(item.minH ?? 1, 1),\n    Math.min(item.maxH ?? bounds.rows, bounds.rows),\n  );\n  return {\n    ...item,\n    w,\n    h,\n    x: clampRange(item.x, 0, bounds.columns - w),\n    y: clampRange(item.y, 0, bounds.rows - h),\n  };\n}\n\nexport function itemsOverlap(first: GridItem, second: GridItem): boolean {\n  return (\n    first.x < second.x + second.w &&\n    first.x + first.w > second.x &&\n    first.y < second.y + second.h &&\n    first.y + first.h > second.y\n  );\n}\n\n/** Whether `candidate` fits, ignoring the item it came from. */\nexport function canPlace(\n  layout: GridItem[],\n  candidate: GridItem,\n  bounds: GridBounds,\n): boolean {\n  const clamped = clampItem(candidate, bounds);\n  return !layout.some(\n    (item) => item.id !== candidate.id && itemsOverlap(item, clamped),\n  );\n}\n\n/** Scans row by row for the first position `item` fits in. */\nexport function findOpenSlot(\n  layout: GridItem[],\n  item: GridItem,\n  bounds: GridBounds,\n): GridItem | null {\n  const candidate = clampItem(item, bounds);\n  for (let y = 0; y <= bounds.rows - candidate.h; y += 1) {\n    for (let x = 0; x <= bounds.columns - candidate.w; x += 1) {\n      const next = { ...candidate, x, y };\n      if (canPlace(layout, next, bounds)) return next;\n    }\n  }\n  return null;\n}\n\n/**\n * Returns the layout with `id` patched, or the same array reference when the\n * move is refused. Callers compare by reference to know whether to shake.\n */\nexport function applyPatch(\n  layout: GridItem[],\n  id: string,\n  patch: Partial<Omit<GridItem, \"id\">>,\n  bounds: GridBounds,\n): GridItem[] {\n  const current = layout.find((item) => item.id === id);\n  if (!current) return layout;\n\n  const candidate = clampItem({ ...current, ...patch }, bounds);\n  if (\n    candidate.x === current.x &&\n    candidate.y === current.y &&\n    candidate.w === current.w &&\n    candidate.h === current.h\n  ) {\n    return layout;\n  }\n  if (!canPlace(layout, candidate, bounds)) return layout;\n\n  return layout.map((item) => (item.id === id ? candidate : item));\n}\n\nexport function addItem(\n  layout: GridItem[],\n  item: GridItem,\n  bounds: GridBounds,\n): GridItem[] {\n  if (layout.some((existing) => existing.id === item.id)) return layout;\n  const placed = findOpenSlot(layout, item, bounds);\n  return placed ? [...layout, placed] : layout;\n}\n\nexport function removeItem(layout: GridItem[], id: string): GridItem[] {\n  return layout.filter((item) => item.id !== id);\n}\n\nexport interface GridStep {\n  column: number;\n  row: number;\n}\n\n/**\n * The pixel distance from one cell's left edge to the next, gaps included.\n * Read off the live element rather than the props, because the column width is\n * whatever the grid resolved to.\n */\nexport function measureStep(\n  grid: HTMLElement,\n  columns: number,\n  rowHeight: number,\n): GridStep {\n  const styles = window.getComputedStyle(grid);\n  const columnGap = Number.parseFloat(styles.columnGap || styles.gap) || 0;\n  const rowGap = Number.parseFloat(styles.rowGap || styles.gap) || 0;\n  const width = grid.getBoundingClientRect().width;\n  const columnWidth = (width - columnGap * (columns - 1)) / columns;\n  return { column: columnWidth + columnGap, row: rowHeight + rowGap };\n}\n\n// `|| 0` folds away the negative zero that Math.round returns for small negative\n// deltas, which otherwise survives into layout state and trips Object.is.\nconst toCells = (distance: number, step: number): number =>\n  step > 0 ? Math.round(distance / step) || 0 : 0;\n\n/** Turns a pixel drag delta into a whole number of cells. */\nexport function deltaToCells(\n  delta: { x: number; y: number },\n  step: GridStep,\n): { x: number; y: number } {\n  return {\n    x: toCells(delta.x, step.column),\n    y: toCells(delta.y, step.row),\n  };\n}\n"
    },
    {
      "path": "dashboard-grid.tsx",
      "target": "components/ui/dashboard-grid.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\nimport {\n  DndContext,\n  type DragEndEvent,\n  type DragMoveEvent,\n  type KeyboardCoordinateGetter,\n  KeyboardSensor,\n  PointerSensor,\n  useDraggable,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  applyPatch,\n  canPlace,\n  clampItem,\n  deltaToCells,\n  type GridBounds,\n  type GridItem,\n  type GridStep,\n  measureStep,\n} from \"./grid-layout\";\n\ninterface GridContextValue {\n  bounds: GridBounds;\n  editing: boolean;\n  layout: GridItem[];\n  stepRef: React.RefObject<GridStep>;\n  patch: (id: string, patch: Partial<Omit<GridItem, \"id\">>) => boolean;\n  remove: (id: string) => void;\n  announce: (message: string) => void;\n  registerLabel: (id: string, label: string) => () => void;\n  draggingId: string | null;\n}\n\nconst GridContext = React.createContext<GridContextValue | null>(null);\n\nfunction useGrid(name: string): GridContextValue {\n  const context = React.useContext(GridContext);\n  if (!context)\n    throw new Error(`${name} must be rendered inside a DashboardGrid`);\n  return context;\n}\n\nexport interface DashboardGridProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  layout: GridItem[];\n  onLayoutChange: (layout: GridItem[]) => void;\n  columns?: number;\n  rows?: number;\n  /** Height of one row, in pixels. Rows never grow to fit their content. */\n  rowHeight?: number;\n  /** Gutter between cells, in pixels. */\n  gap?: number;\n  /** Drag, resize and remove are only possible while this is true. */\n  editing?: boolean;\n  /** Read to a screen reader when a drag starts. */\n  keyboardInstructions?: string;\n}\n\nconst DEFAULT_INSTRUCTIONS =\n  \"Press space to lift the widget, then use the arrow keys to move it one cell at a time. Press space to drop it, or escape to cancel.\";\n\nexport function DashboardGrid({\n  className,\n  style,\n  layout,\n  onLayoutChange,\n  columns = 10,\n  rows = 8,\n  rowHeight = 72,\n  gap = 16,\n  editing = false,\n  keyboardInstructions = DEFAULT_INSTRUCTIONS,\n  children,\n  ...props\n}: DashboardGridProps) {\n  const gridRef = React.useRef<HTMLDivElement>(null);\n  const stepRef = React.useRef<GridStep>({ column: 0, row: 0 });\n  const [preview, setPreview] = React.useState<GridItem | null>(null);\n  const [draggingId, setDraggingId] = React.useState<string | null>(null);\n  const [message, setMessage] = React.useState(\"\");\n\n  const bounds = React.useMemo<GridBounds>(\n    () => ({ columns, rows }),\n    [columns, rows],\n  );\n\n  React.useEffect(() => {\n    const grid = gridRef.current;\n    if (!grid) return;\n    const measure = () => {\n      stepRef.current = measureStep(grid, columns, rowHeight);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(grid);\n    return () => observer.disconnect();\n  }, [columns, rowHeight]);\n\n  const labels = React.useRef(new Map<string, string>());\n  const registerLabel = React.useCallback((id: string, label: string) => {\n    labels.current.set(id, label);\n    return () => {\n      labels.current.delete(id);\n    };\n  }, []);\n\n  const announce = React.useCallback((next: string) => {\n    // The text has to change for a live region to fire again, so an identical\n    // message gets a zero-width space appended to make it a different string.\n    setMessage((current) => (current === next ? `${next}​` : next));\n  }, []);\n\n  const patch = React.useCallback(\n    (id: string, change: Partial<Omit<GridItem, \"id\">>) => {\n      const next = applyPatch(layout, id, change, bounds);\n      if (next === layout) return false;\n      onLayoutChange(next);\n      return true;\n    },\n    [layout, bounds, onLayoutChange],\n  );\n\n  const remove = React.useCallback(\n    (id: string) => onLayoutChange(layout.filter((item) => item.id !== id)),\n    [layout, onLayoutChange],\n  );\n\n  const candidateFor = React.useCallback(\n    (id: string, delta: { x: number; y: number }): GridItem | null => {\n      const current = layout.find((entry) => entry.id === id);\n      if (!current) return null;\n      const cells = deltaToCells(delta, stepRef.current);\n      return clampItem(\n        { ...current, x: current.x + cells.x, y: current.y + cells.y },\n        bounds,\n      );\n    },\n    [layout, bounds],\n  );\n\n  const coordinateGetter = React.useCallback<KeyboardCoordinateGetter>(\n    (event, { currentCoordinates }) => {\n      const step = stepRef.current;\n      const moves: Record<string, { x: number; y: number }> = {\n        ArrowRight: { x: step.column, y: 0 },\n        ArrowLeft: { x: -step.column, y: 0 },\n        ArrowDown: { x: 0, y: step.row },\n        ArrowUp: { x: 0, y: -step.row },\n      };\n      const move = moves[event.code];\n      if (!move) return undefined;\n      event.preventDefault();\n      return {\n        x: currentCoordinates.x + move.x,\n        y: currentCoordinates.y + move.y,\n      };\n    },\n    [],\n  );\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),\n    useSensor(KeyboardSensor, { coordinateGetter }),\n  );\n\n  const handleDragMove = (event: DragMoveEvent) => {\n    setPreview(candidateFor(String(event.active.id), event.delta));\n  };\n\n  const handleDragEnd = (event: DragEndEvent) => {\n    setPreview(null);\n    setDraggingId(null);\n    const id = String(event.active.id);\n    const candidate = candidateFor(id, event.delta);\n    if (!candidate) return;\n    const label = labels.current.get(id) ?? id;\n    if (patch(id, { x: candidate.x, y: candidate.y })) {\n      announce(\n        `${label} moved to column ${candidate.x + 1}, row ${candidate.y + 1}.`,\n      );\n    } else {\n      announce(`${label} cannot move there. That spot is taken.`);\n    }\n  };\n\n  const previewValid = preview != null && canPlace(layout, preview, bounds);\n\n  return (\n    <DndContext\n      sensors={sensors}\n      // No droppables are registered: the drop position is the drag delta\n      // rounded to whole cells, so there is nothing for a collision pass to do.\n      collisionDetection={() => []}\n      accessibility={{\n        screenReaderInstructions: { draggable: keyboardInstructions },\n        // dnd-kit keeps its own live region. Ours says more, because it knows the\n        // cell the widget landed on, so dnd-kit's is silenced rather than doubled.\n        announcements: {\n          onDragStart: () => undefined,\n          onDragMove: () => undefined,\n          onDragOver: () => undefined,\n          onDragEnd: () => undefined,\n          onDragCancel: () => undefined,\n        },\n      }}\n      onDragStart={(event) => setDraggingId(String(event.active.id))}\n      onDragMove={handleDragMove}\n      onDragEnd={handleDragEnd}\n      onDragCancel={() => {\n        setPreview(null);\n        setDraggingId(null);\n        announce(\"Move cancelled.\");\n      }}\n    >\n      <GridContext.Provider\n        value={{\n          bounds,\n          editing,\n          layout,\n          stepRef,\n          patch,\n          remove,\n          announce,\n          registerLabel,\n          draggingId,\n        }}\n      >\n        <div\n          ref={gridRef}\n          data-dashboard-grid=\"\"\n          data-editing={editing ? \"\" : undefined}\n          className={cn(\"relative grid w-full\", className)}\n          style={\n            {\n              gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n              gridTemplateRows: `repeat(${rows}, ${rowHeight}px)`,\n              gap: `${gap}px`,\n              // The edit-mode cell outlines are painted, not built from\n              // `columns * rows` elements. A 10 by 8 grid would be 80 nodes,\n              // every one of them a layout and paint the browser has to do.\n              ...(editing\n                ? {\n                    backgroundImage:\n                      \"linear-gradient(to right, var(--color-border) 1px, transparent 1px), linear-gradient(to bottom, var(--color-border) 1px, transparent 1px)\",\n                    backgroundSize: `calc((100% + ${gap}px) / ${columns}) ${rowHeight + gap}px`,\n                    backgroundPosition: `${-gap / 2}px ${-gap / 2}px`,\n                  }\n                : null),\n              ...style,\n            } as React.CSSProperties\n          }\n          {...props}\n        >\n          {preview != null && (\n            <div\n              data-dashboard-grid-preview={previewValid ? \"valid\" : \"invalid\"}\n              aria-hidden=\"true\"\n              className={cn(\n                \"pointer-events-none z-0 rounded-xl border-2 border-dashed\",\n                previewValid\n                  ? \"border-accent/70 bg-accent/10\"\n                  : \"border-danger/70 bg-danger/10\",\n              )}\n              style={{\n                gridColumn: `${preview.x + 1} / span ${preview.w}`,\n                gridRow: `${preview.y + 1} / span ${preview.h}`,\n              }}\n            />\n          )}\n          {children}\n        </div>\n\n        <div\n          data-dashboard-grid-status=\"\"\n          aria-live=\"polite\"\n          aria-atomic=\"true\"\n          className=\"sr-only\"\n        >\n          {message}\n        </div>\n      </GridContext.Provider>\n    </DndContext>\n  );\n}\n\nexport interface DashboardGridItemProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"id\"> {\n  id: string;\n  /** Names the widget in every control's label and in the live region. */\n  label: string;\n  /** Hides the remove button for a widget the user must not drop. */\n  removable?: boolean;\n}\n\nexport function DashboardGridItem({\n  className,\n  id,\n  label,\n  removable = true,\n  children,\n  ...props\n}: DashboardGridItemProps) {\n  const {\n    bounds,\n    editing,\n    layout,\n    stepRef,\n    patch,\n    remove,\n    announce,\n    registerLabel,\n  } = useGrid(\"DashboardGridItem\");\n  const item = layout.find((entry) => entry.id === id);\n\n  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform } =\n    useDraggable({ id, disabled: !editing });\n\n  React.useEffect(() => registerLabel(id, label), [registerLabel, id, label]);\n\n  if (!item) return null;\n\n  const resize = (w: number, h: number) => {\n    if (patch(id, { w, h })) {\n      const next = clampItem({ ...item, w, h }, bounds);\n      announce(`${label} resized to ${next.w} by ${next.h}.`);\n    } else {\n      announce(`${label} cannot grow there.`);\n    }\n  };\n\n  const startPointerResize =\n    (axis: \"x\" | \"y\" | \"both\"): React.PointerEventHandler<HTMLButtonElement> =>\n    (event) => {\n      event.preventDefault();\n      event.stopPropagation();\n      const start = { x: event.clientX, y: event.clientY };\n      const origin = item;\n      let lastW = origin.w;\n      let lastH = origin.h;\n\n      const onMove = (move: PointerEvent) => {\n        const cells = deltaToCells(\n          { x: move.clientX - start.x, y: move.clientY - start.y },\n          stepRef.current,\n        );\n        const w = axis === \"y\" ? origin.w : origin.w + cells.x;\n        const h = axis === \"x\" ? origin.h : origin.h + cells.y;\n        if (w === lastW && h === lastH) return;\n        lastW = w;\n        lastH = h;\n        patch(id, { w, h });\n      };\n      const onUp = () => {\n        window.removeEventListener(\"pointermove\", onMove);\n        window.removeEventListener(\"pointerup\", onUp);\n        window.removeEventListener(\"pointercancel\", onUp);\n        announce(`${label} resized to ${lastW} by ${lastH}.`);\n      };\n\n      window.addEventListener(\"pointermove\", onMove);\n      window.addEventListener(\"pointerup\", onUp);\n      window.addEventListener(\"pointercancel\", onUp);\n    };\n\n  // Arrow keys resize along whichever axis the focused handle owns, so a keyboard\n  // user can widen a widget without also making it taller. sisu's only keyboard\n  // affordance changes both at once.\n  const resizeKeys =\n    (axis: \"x\" | \"y\" | \"both\"): React.KeyboardEventHandler<HTMLButtonElement> =>\n    (event) => {\n      const horizontal = { ArrowRight: 1, ArrowLeft: -1 }[event.key];\n      const vertical = { ArrowDown: 1, ArrowUp: -1 }[event.key];\n      if (horizontal !== undefined && axis !== \"y\") {\n        event.preventDefault();\n        resize(item.w + horizontal, item.h);\n      } else if (vertical !== undefined && axis !== \"x\") {\n        event.preventDefault();\n        resize(item.w, item.h + vertical);\n      }\n    };\n\n  return (\n    <div\n      ref={setNodeRef}\n      data-dashboard-grid-item=\"\"\n      className={cn(\n        \"relative z-10 min-h-0\",\n        transform && \"z-30 opacity-50\",\n        className,\n      )}\n      style={{\n        gridColumn: `${item.x + 1} / span ${item.w}`,\n        gridRow: `${item.y + 1} / span ${item.h}`,\n        transform: transform\n          ? `translate3d(${transform.x}px, ${transform.y}px, 0)`\n          : undefined,\n      }}\n      {...(editing ? listeners : null)}\n      {...props}\n    >\n      {children}\n\n      {editing && (\n        <>\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 rounded-2xl border border-accent/45\"\n          />\n          <span\n            aria-hidden=\"true\"\n            className=\"-right-2 -bottom-2 absolute z-20 rounded-full bg-accent px-2.5 py-1 font-mono font-semibold text-[10px] text-on-accent\"\n          >\n            {item.w}×{item.h}\n          </span>\n\n          <div className=\"absolute top-2 right-2 z-20 flex items-center gap-1\">\n            <button\n              ref={setActivatorNodeRef}\n              type=\"button\"\n              aria-label={`Move ${label}`}\n              className=\"grid size-8 cursor-grab place-items-center rounded-lg bg-ink/[0.06] text-muted outline-none hover:bg-ink/10 hover:text-ink focus-visible:ring-focus active:cursor-grabbing\"\n              {...attributes}\n              {...listeners}\n            >\n              <GripIcon />\n            </button>\n            {removable && (\n              <button\n                type=\"button\"\n                aria-label={`Remove ${label}`}\n                onPointerDown={(event) => event.stopPropagation()}\n                onClick={() => {\n                  remove(id);\n                  announce(`${label} removed.`);\n                }}\n                className=\"grid size-8 place-items-center rounded-lg bg-danger/15 text-danger outline-none hover:bg-danger/25 focus-visible:ring-focus\"\n              >\n                <CloseIcon />\n              </button>\n            )}\n          </div>\n\n          <button\n            type=\"button\"\n            aria-label={`Resize ${label} horizontally`}\n            onPointerDown={startPointerResize(\"x\")}\n            onKeyDown={resizeKeys(\"x\")}\n            className=\"-right-2 absolute top-14 bottom-10 z-20 w-4 cursor-ew-resize rounded-full outline-none hover:bg-accent/25 focus-visible:ring-focus\"\n          />\n          <button\n            type=\"button\"\n            aria-label={`Resize ${label} vertically`}\n            onPointerDown={startPointerResize(\"y\")}\n            onKeyDown={resizeKeys(\"y\")}\n            className=\"-bottom-2 absolute right-10 left-10 z-20 h-4 cursor-ns-resize rounded-full outline-none hover:bg-accent/25 focus-visible:ring-focus\"\n          />\n          <button\n            type=\"button\"\n            aria-label={`Resize ${label}`}\n            onPointerDown={startPointerResize(\"both\")}\n            onKeyDown={resizeKeys(\"both\")}\n            className=\"-right-2 -bottom-2 absolute z-30 size-7 cursor-nwse-resize rounded-full border border-accent/50 bg-accent/25 outline-none hover:bg-accent/40 focus-visible:ring-focus\"\n          />\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction GripIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      className=\"size-3.5\"\n      aria-hidden=\"true\"\n    >\n      <circle cx=\"9\" cy=\"6\" r=\"1.6\" />\n      <circle cx=\"15\" cy=\"6\" r=\"1.6\" />\n      <circle cx=\"9\" cy=\"12\" r=\"1.6\" />\n      <circle cx=\"15\" cy=\"12\" r=\"1.6\" />\n      <circle cx=\"9\" cy=\"18\" r=\"1.6\" />\n      <circle cx=\"15\" cy=\"18\" r=\"1.6\" />\n    </svg>\n  );\n}\n\nfunction CloseIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      className=\"size-3.5\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n"
    }
  ]
}