{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "desktop-icons",
  "title": "Desktop Icons",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A macOS desktop of icons: box-select with the pointer, drag to move with snapping to grid cells, double-click or Return to open, arrow keys to move focus and Alt plus arrows to move an icon.",
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/mac-icons.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/desktop-icons.tsx",
      "content": "// Ballmac UI: Desktop Icons. https://ui.ballmac.com/components/desktop-icons\n\"use client\"\n\nimport * as React from \"react\"\n\nimport { AppIcon, DriveIcon, FileIcon, FolderIcon, type IconTone } from \"@/components/ballmac/mac-icons\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype DesktopItem = {\n  /** Unique id. */\n  id: string\n  /** Label under the icon. */\n  name: string\n  /** What it is. */\n  kind: \"folder\" | \"file\" | \"drive\" | \"app\"\n  /** Column on the grid, counted from the left starting at 0. */\n  col: number\n  /** Row on the grid, counted from the top starting at 0. */\n  row: number\n  /** Extension badge for files. */\n  ext?: string\n  /** Icon color. */\n  tone?: IconTone\n  /** Glyph for an app icon. */\n  glyph?: React.ReactNode\n}\n\ntype DesktopIconsProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"children\"> & {\n  /** The icons and where they sit. */\n  items: DesktopItem[]\n  /** Called with the new list after icons are dragged to other cells. */\n  onItemsChange?: (items: DesktopItem[]) => void\n  /** Called when an icon is opened with a double-click or Return. */\n  onOpen?: (item: DesktopItem) => void\n  /** Size of one grid cell in pixels. */\n  cell?: number\n  /** Accessible name of the desktop. */\n  label?: string\n}\n\nfunction Glyph({ item }: { item: DesktopItem }) {\n  if (item.kind === \"folder\") return <FolderIcon size={56} tone={item.tone ?? \"blue\"} />\n  if (item.kind === \"drive\") return <DriveIcon size={56} />\n  if (item.kind === \"app\") return <AppIcon size={52} tone={item.tone ?? \"blue\"}>{item.glyph}</AppIcon>\n  return <FileIcon size={56} tone={item.tone ?? \"graphite\"} label={item.ext} />\n}\n\n/**\n * A macOS desktop: icons on a grid. Click or drag a box to select, drag icons to move them (they snap to cells),\n * double-click or press Return to open. Arrow keys move focus; Alt plus an arrow key moves the focused icon.\n */\nfunction DesktopIcons({ items: initial, onItemsChange, onOpen, cell = 96, label, className, style, ...props }: DesktopIconsProps) {\n  const msg = useMessages()\n  label ??= msg(\"desktop-icons.label\", \"Desktop\")\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const [items, setItems] = React.useState(initial)\n  const [selected, setSelected] = React.useState<string[]>([])\n  const [focusId, setFocusId] = React.useState(initial[0]?.id)\n  const [drag, setDrag] = React.useState<{ dx: number; dy: number } | null>(null)\n  const [band, setBand] = React.useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null)\n  const refs = React.useRef(new Map<string, HTMLElement>())\n  const gesture = React.useRef<{ kind: \"item\" | \"band\"; startX: number; startY: number; moved: boolean; base: string[]; ids: string[]; originals: Map<string, DesktopItem> } | null>(null)\n\n  React.useEffect(() => setItems(initial), [initial])\n\n  const commit = React.useCallback(\n    (next: DesktopItem[]) => {\n      setItems(next)\n      onItemsChange?.(next)\n    },\n    [onItemsChange]\n  )\n\n  const bounds = () => {\n    const el = rootRef.current\n    return { cols: Math.max(1, Math.floor((el?.clientWidth ?? cell) / cell)), rows: Math.max(1, Math.floor((el?.clientHeight ?? cell) / cell)) }\n  }\n\n  function relative(e: { clientX: number; clientY: number }) {\n    const box = rootRef.current!.getBoundingClientRect()\n    return { x: e.clientX - box.left, y: e.clientY - box.top }\n  }\n\n  function onItemDown(e: React.PointerEvent, item: DesktopItem) {\n    if (e.button !== 0) return\n    e.stopPropagation()\n    const additive = e.metaKey || e.ctrlKey || e.shiftKey\n    let ids = selected\n    if (!selected.includes(item.id)) ids = additive ? [...selected, item.id] : [item.id]\n    else if (additive) ids = selected.filter((s) => s !== item.id)\n    setSelected(ids)\n    setFocusId(item.id)\n    gesture.current = { kind: \"item\", startX: e.clientX, startY: e.clientY, moved: false, base: ids, ids: ids.includes(item.id) ? ids : [item.id], originals: new Map(items.map((i) => [i.id, i])) }\n    ;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)\n  }\n\n  function onRootDown(e: React.PointerEvent) {\n    if (e.button !== 0 || e.target !== e.currentTarget) return\n    const p = relative(e)\n    const additive = e.metaKey || e.ctrlKey || e.shiftKey\n    gesture.current = { kind: \"band\", startX: p.x, startY: p.y, moved: false, base: additive ? selected : [], ids: [], originals: new Map() }\n    if (!additive) setSelected([])\n    e.currentTarget.setPointerCapture?.(e.pointerId)\n  }\n\n  function onMove(e: React.PointerEvent) {\n    const g = gesture.current\n    if (!g) return\n    if (g.kind === \"item\") {\n      const dx = e.clientX - g.startX\n      const dy = e.clientY - g.startY\n      if (!g.moved && Math.hypot(dx, dy) < 4) return\n      g.moved = true\n      setDrag({ dx, dy })\n    } else {\n      const p = relative(e)\n      if (!g.moved && Math.hypot(p.x - g.startX, p.y - g.startY) < 4) return\n      g.moved = true\n      const rect = { x1: Math.min(g.startX, p.x), y1: Math.min(g.startY, p.y), x2: Math.max(g.startX, p.x), y2: Math.max(g.startY, p.y) }\n      setBand({ ...rect })\n      const hit = items.filter((it) => {\n        const l = it.col * cell + 8\n        const t = it.row * cell + 4\n        return l < rect.x2 && l + cell - 16 > rect.x1 && t < rect.y2 && t + cell - 8 > rect.y1\n      })\n      setSelected([...new Set([...g.base, ...hit.map((h) => h.id)])])\n    }\n  }\n\n  function onUp() {\n    const g = gesture.current\n    gesture.current = null\n    if (!g) return\n    if (g.kind === \"band\") return setBand(null)\n    if (!g.moved || !drag) return setDrag(null)\n    const { cols, rows } = bounds()\n    const taken = new Set(items.filter((i) => !g.ids.includes(i.id)).map((i) => `${i.col},${i.row}`))\n    const moved = new Map<string, DesktopItem>()\n    for (const id of g.ids) {\n      const o = g.originals.get(id)!\n      let col = Math.min(cols - 1, Math.max(0, Math.round(o.col + drag.dx / cell)))\n      let row = Math.min(rows - 1, Math.max(0, Math.round(o.row + drag.dy / cell)))\n      // If the cell is taken, look outward for the nearest free one.\n      for (let ring = 1; taken.has(`${col},${row}`) && ring < 12; ring++) {\n        const options: [number, number][] = []\n        for (let dc = -ring; dc <= ring; dc++) for (let dr = -ring; dr <= ring; dr++) if (Math.max(Math.abs(dc), Math.abs(dr)) === ring) options.push([col + dc, row + dr])\n        const free = options.find(([c, r]) => c >= 0 && r >= 0 && c < cols && r < rows && !taken.has(`${c},${r}`))\n        if (free) [col, row] = free\n      }\n      taken.add(`${col},${row}`)\n      moved.set(id, { ...o, col, row })\n    }\n    setDrag(null)\n    commit(items.map((i) => moved.get(i.id) ?? i))\n  }\n\n  function onKeyDown(e: React.KeyboardEvent, item: DesktopItem) {\n    const dirs: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }\n    if (e.key in dirs) {\n      e.preventDefault()\n      const [dc, dr] = dirs[e.key]!\n      if (e.altKey) {\n        const { cols, rows } = bounds()\n        const col = item.col + dc\n        const row = item.row + dr\n        if (col < 0 || row < 0 || col >= cols || row >= rows || items.some((i) => i.id !== item.id && i.col === col && i.row === row)) return\n        commit(items.map((i) => (i.id === item.id ? { ...i, col, row } : i)))\n        return\n      }\n      // Move focus to the nearest icon in that direction.\n      const candidates = items\n        .filter((i) => i.id !== item.id && (dc ? Math.sign(i.col - item.col) === dc : Math.sign(i.row - item.row) === dr))\n        .sort((a, b) => Math.hypot((a.col - item.col) * (dc ? 1 : 3), (a.row - item.row) * (dr ? 1 : 3)) - Math.hypot((b.col - item.col) * (dc ? 1 : 3), (b.row - item.row) * (dr ? 1 : 3)))\n      const next = candidates[0]\n      if (next) {\n        setFocusId(next.id)\n        setSelected([next.id])\n        refs.current.get(next.id)?.focus()\n      }\n    } else if (e.key === \"Enter\") {\n      e.preventDefault()\n      onOpen?.(item)\n    } else if (e.key === \" \") {\n      e.preventDefault()\n      setSelected((s) => (s.includes(item.id) ? s.filter((x) => x !== item.id) : [...s, item.id]))\n    } else if (e.key === \"Escape\") setSelected([])\n  }\n\n  const moving = drag && gesture.current?.kind === \"item\" ? new Set(gesture.current.ids) : null\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"desktop-icons\"\n      role=\"listbox\"\n      aria-label={label}\n      aria-multiselectable=\"true\"\n      onPointerDown={onRootDown}\n      onPointerMove={onMove}\n      onPointerUp={onUp}\n      onPointerCancel={onUp}\n      className={cn(\"relative touch-none overflow-hidden select-none [--mac-accent:oklch(0.53_0.2_258)]\", className)}\n      style={style}\n      {...props}\n    >\n      {items.map((item) => {\n        const isSel = selected.includes(item.id)\n        const shift = moving?.has(item.id) && drag ? { x: drag.dx, y: drag.dy } : { x: 0, y: 0 }\n        return (\n          <div\n            key={item.id}\n            ref={(el) => {\n              if (el) refs.current.set(item.id, el)\n              else refs.current.delete(item.id)\n            }}\n            role=\"option\"\n            aria-selected={isSel}\n            aria-label={`${item.name}, ${item.kind}`}\n            tabIndex={focusId === item.id ? 0 : -1}\n            onPointerDown={(e) => onItemDown(e, item)}\n            onDoubleClick={() => onOpen?.(item)}\n            onFocus={() => setFocusId(item.id)}\n            onKeyDown={(e) => onKeyDown(e, item)}\n            className={cn(\n              \"absolute flex flex-col items-center gap-1 rounded-lg px-0 pt-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/60\",\n              moving?.has(item.id) ? \"z-20 cursor-grabbing\" : \"cursor-default transition-[left,top] duration-150 motion-reduce:transition-none\"\n            )}\n            style={{ left: item.col * cell + 8, top: item.row * cell + 4, width: cell - 16, transform: `translate(${shift.x}px, ${shift.y}px)` }}\n          >\n            <span className={cn(\"rounded-lg p-0.5\", isSel && \"bg-white/25\")}>\n              <Glyph item={item} />\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className={cn(\"line-clamp-2 max-w-full rounded-[5px] px-1.5 text-center text-[12px] leading-[14px] font-medium break-words text-white [text-shadow:0_1px_3px_rgb(0_0_0/0.7)]\", isSel && \"bg-(--mac-accent) [text-shadow:none]\")}\n            >\n              {item.name}\n            </span>\n          </div>\n        )\n      })}\n      {band && (\n        <span aria-hidden=\"true\" className=\"pointer-events-none absolute z-30 rounded-[3px] border border-white/70 bg-white/20\" style={{ left: band.x1, top: band.y1, width: band.x2 - band.x1, height: band.y2 - band.y1 }} />\n      )}\n    </div>\n  )\n}\n\nexport { DesktopIcons, type DesktopIconsProps, type DesktopItem }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/desktop-icons.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "desktop",
      "icons",
      "drag",
      "selection",
      "macos"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<DesktopIcons items={[{ id, name, kind, col, row }]} onItemsChange onOpen cell />. Positions are grid cells. Icons that land on an occupied cell move to the nearest free one.",
      "whenToUse": [
        "Desktop-style demos and playgrounds",
        "Launch surfaces with draggable shortcuts"
      ],
      "whenNotToUse": [
        "Ordinary file lists (file-browser)"
      ],
      "composesWith": [
        "window-manager",
        "finder-window",
        "mac-icons",
        "mac-context-menu"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move focus to the nearest icon in that direction"
        },
        {
          "keys": "Alt+Arrow",
          "action": "Moves the focused icon one cell"
        },
        {
          "keys": "Enter",
          "action": "Opens"
        },
        {
          "keys": "Space",
          "action": "Toggles selection"
        }
      ],
      "customization": [
        "cell size",
        "items and positions",
        "onItemsChange"
      ]
    },
    "examples": [
      "desktop-icons-demo",
      "desktop-icons-window"
    ],
    "url": "https://ui.ballmac.com/components/desktop-icons"
  },
  "categories": [
    "macos"
  ],
  "type": "registry:ui"
}