{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "finder-window",
  "title": "Finder Window",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A working mini Finder: sidebar shortcuts, back and forward history, icon and list views, search, click, shift and command selection, keyboard navigation and a path bar, on a macOS window.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/mac-window.json",
    "https://ui.ballmac.com/r/mac-icons.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/finder-window.tsx",
      "content": "// Ballmac UI: Finder Window. https://ui.ballmac.com/components/finder-window\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, LayoutGrid, List } from \"lucide-react\"\n\nimport { DriveIcon, FileIcon, FolderIcon, type IconTone } from \"@/components/ballmac/mac-icons\"\nimport {\n  MacWindow,\n  MacWindowContent,\n  MacWindowControls,\n  MacWindowMain,\n  MacWindowSidebar,\n  MacWindowSidebarItem,\n  MacWindowTitleBar,\n  MacWindowToolbarButton,\n} from \"@/components/ballmac/mac-window\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype FinderNode = {\n  /** Unique id. */\n  id: string\n  /** Name shown under the icon. */\n  name: string\n  /** What it is. */\n  kind: \"folder\" | \"file\" | \"drive\"\n  /** Extension badge for files, such as \"PDF\". */\n  ext?: string\n  /** Icon color for folders and the badge of files. */\n  tone?: IconTone\n  /** Size text, such as \"2.4 MB\". */\n  size?: string\n  /** Modified text, such as \"Today, 9:41\". */\n  modified?: string\n  /** Contents of a folder or drive. */\n  children?: FinderNode[]\n}\n\ntype FinderSidebarSection = {\n  /** Section heading, such as \"Favorites\". */\n  title: string\n  items: { /** Id of the folder it opens. */ id: string; label: string; icon: React.ReactNode }[]\n}\n\nfunction findPath(node: FinderNode, id: string, trail: string[] = []): string[] | null {\n  const here = [...trail, node.id]\n  if (node.id === id) return here\n  for (const child of node.children ?? []) {\n    const hit = child.kind !== \"file\" ? findPath(child, id, here) : null\n    if (hit) return hit\n  }\n  return null\n}\n\nfunction resolve(root: FinderNode, path: string[]): FinderNode[] {\n  const nodes = [root]\n  for (const id of path.slice(1)) {\n    const next = nodes[nodes.length - 1]!.children?.find((c) => c.id === id)\n    if (!next) break\n    nodes.push(next)\n  }\n  return nodes\n}\n\ntype FinderWindowProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** The top of the file tree: a folder or drive whose children can be browsed. */\n  root: FinderNode\n  /** Sidebar shortcuts. Each item opens the folder with that id. */\n  sidebar?: FinderSidebarSection[]\n  /** Folder to start in, as the id of any folder in the tree. Defaults to the root. */\n  defaultFolder?: string\n  /** \"icons\" is a grid, \"list\" shows kind, size and date. */\n  view?: \"icons\" | \"list\"\n  /** Called when a file is opened (double-click or Return). */\n  onOpenFile?: (file: FinderNode) => void\n  /** Called whenever the selection changes. */\n  onSelectionChange?: (ids: string[]) => void\n}\n\nfunction Icon({ node, size }: { node: FinderNode; size: number }) {\n  if (node.kind === \"folder\") return <FolderIcon size={size} tone={node.tone ?? \"blue\"} />\n  if (node.kind === \"drive\") return <DriveIcon size={size} />\n  return <FileIcon size={size} tone={node.tone ?? \"graphite\"} label={node.ext} />\n}\n\n/** A working mini Finder: sidebar, back and forward, icon and list views, selection, keyboard navigation and a path bar. */\nfunction FinderWindow({ root, sidebar, defaultFolder, view: viewProp = \"icons\", onOpenFile, onSelectionChange, className, ...props }: FinderWindowProps) {\n  const msg = useMessages()\n  const uid = React.useId()\n  const [history, setHistory] = React.useState<{ stack: string[][]; index: number }>(() => {\n    const start = (defaultFolder && findPath(root, defaultFolder)) || [root.id]\n    return { stack: [start], index: 0 }\n  })\n  const [view, setView] = React.useState(viewProp)\n  const [query, setQuery] = React.useState(\"\")\n  const [selected, setSelected] = React.useState<string[]>([])\n  const [active, setActive] = React.useState(0)\n  const [columns, setColumns] = React.useState(1)\n  const gridRef = React.useRef<HTMLDivElement>(null)\n\n  const path = history.stack[history.index]!\n  const trail = resolve(root, path)\n  const folder = trail[trail.length - 1]!\n  const items = (folder.children ?? []).filter((c) => !query || c.name.toLowerCase().includes(query.toLowerCase()))\n  const canBack = history.index > 0\n  const canForward = history.index < history.stack.length - 1\n\n  React.useEffect(() => {\n    const el = gridRef.current\n    if (!el) return\n    const measure = () => setColumns(view === \"list\" ? 1 : Math.max(1, getComputedStyle(el).gridTemplateColumns.split(\" \").length))\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [view, folder.id])\n\n  const select = React.useCallback(\n    (ids: string[]) => {\n      setSelected(ids)\n      onSelectionChange?.(ids)\n    },\n    [onSelectionChange]\n  )\n\n  function go(target: string[]) {\n    setHistory((h) => ({ stack: [...h.stack.slice(0, h.index + 1), target], index: h.index + 1 }))\n    setQuery(\"\")\n    select([])\n    setActive(0)\n  }\n  function goBack() {\n    if (!canBack) return\n    setHistory((h) => ({ ...h, index: h.index - 1 }))\n    select([])\n  }\n  function goForward() {\n    if (!canForward) return\n    setHistory((h) => ({ ...h, index: h.index + 1 }))\n    select([])\n  }\n  function open(node: FinderNode) {\n    if (node.kind === \"file\") onOpenFile?.(node)\n    else go([...path, node.id])\n  }\n\n  function onKeyDown(event: React.KeyboardEvent) {\n    const last = items.length - 1\n    if (last < 0) return\n    const mod = event.metaKey || event.ctrlKey\n    if (mod && event.key === \"ArrowDown\") {\n      event.preventDefault()\n      const node = items[active]\n      if (node) open(node)\n      return\n    }\n    if (mod && event.key === \"ArrowUp\") {\n      event.preventDefault()\n      if (path.length > 1) go(path.slice(0, -1))\n      return\n    }\n    const step: Record<string, number> = { ArrowRight: view === \"icons\" ? 1 : 0, ArrowLeft: view === \"icons\" ? -1 : 0, ArrowDown: columns, ArrowUp: -columns }\n    if (event.key in step && step[event.key] !== 0) {\n      event.preventDefault()\n      const next = Math.min(last, Math.max(0, active + step[event.key]!))\n      setActive(next)\n      const id = items[next]!.id\n      if (event.shiftKey) {\n        const from = items.findIndex((i) => i.id === (selected[0] ?? items[active]!.id))\n        const [a, b] = [Math.min(from, next), Math.max(from, next)]\n        select(items.slice(a, b + 1).map((i) => i.id))\n      } else select([id])\n    } else if (event.key === \"Enter\") {\n      event.preventDefault()\n      const node = items[active]\n      if (node) open(node)\n    } else if (event.key === \"Escape\") select([])\n    else if (mod && event.key.toLowerCase() === \"a\") {\n      event.preventDefault()\n      select(items.map((i) => i.id))\n    }\n  }\n\n  function onItemClick(event: React.MouseEvent, index: number) {\n    const id = items[index]!.id\n    setActive(index)\n    if (event.shiftKey && selected.length) {\n      const from = items.findIndex((i) => i.id === selected[0])\n      const [a, b] = [Math.min(from, index), Math.max(from, index)]\n      select(items.slice(a, b + 1).map((i) => i.id))\n    } else if (event.metaKey || event.ctrlKey) {\n      select(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id])\n    } else select([id])\n  }\n\n  const optionId = (i: number) => `${uid}-opt-${i}`\n  const status = `${items.length} ${items.length === 1 ? \"item\" : \"items\"}${selected.length ? `, ${selected.length} selected` : \"\"}`\n\n  return (\n    <MacWindow className={cn(\"h-[28rem] w-full\", className)} {...props}>\n      <MacWindowSidebar className=\"max-sm:hidden\">\n        {sidebar?.map((section) => (\n          <div key={section.title} className=\"px-2.5 pb-3\">\n            <p className=\"px-2 pb-1 text-[11px] font-semibold text-muted-foreground\">{section.title}</p>\n            {section.items.map((item) => (\n              <MacWindowSidebarItem key={item.id} selected={folder.id === item.id} onClick={() => { const p = findPath(root, item.id); if (p) go(p) }}>\n                {item.icon}\n                <span className=\"truncate\">{item.label}</span>\n              </MacWindowSidebarItem>\n            ))}\n          </div>\n        ))}\n      </MacWindowSidebar>\n      <MacWindowMain>\n        <MacWindowTitleBar controls={false} title={undefined}>\n          <span className=\"me-auto flex min-w-0 items-center gap-1\">\n            <MacWindowControls className=\"me-2 pointer-events-auto sm:hidden\" />\n            <MacWindowToolbarButton aria-label={msg(\"finder-window.back\", \"Back\")} disabled={!canBack} onClick={goBack}>\n              <ChevronLeft  className=\"rtl:rotate-180\"/>\n            </MacWindowToolbarButton>\n            <MacWindowToolbarButton aria-label={msg(\"finder-window.forward\", \"Forward\")} disabled={!canForward} onClick={goForward}>\n              <ChevronRight  className=\"rtl:rotate-180\"/>\n            </MacWindowToolbarButton>\n            <span className=\"ms-2 truncate text-[15px] font-semibold text-foreground\">{folder.name}</span>\n          </span>\n          <span className=\"inline-flex items-center gap-px rounded-lg bg-foreground/[0.06] p-0.5\" role=\"group\" aria-label={msg(\"finder-window.view\", \"View\")}>\n            {([[\"icons\", LayoutGrid, msg(\"finder-window.asIcons\", \"as Icons\")], [\"list\", List, msg(\"finder-window.asList\", \"as List\")]] as const).map(([v, Glyph, label]) => (\n              <button\n                key={v}\n                type=\"button\"\n                aria-label={label}\n                aria-pressed={view === v}\n                onClick={() => setView(v)}\n                className=\"inline-flex h-6 w-8 items-center justify-center rounded-md text-foreground/70 outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-[0_0.5px_1.5px_rgb(0_0_0/0.25)] dark:aria-pressed:bg-foreground/[0.18]\"\n              >\n                <Glyph aria-hidden=\"true\" className=\"size-4\" />\n              </button>\n            ))}\n          </span>\n          <input\n            type=\"search\"\n            aria-label={msg(\"finder-window.searchThisFolder\", \"Search this folder\")}\n            placeholder={msg(\"finder-window.search\", \"Search\")}\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            className=\"ms-1.5 h-7 w-24 rounded-md transition-[width] duration-200 focus-visible:w-36 sm:w-36 motion-reduce:transition-none border border-foreground/10 bg-foreground/[0.05] px-2.5 text-[13px] text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n          />\n        </MacWindowTitleBar>\n\n        <MacWindowContent className=\"relative\" onClick={(e) => e.target === e.currentTarget && select([])}>\n          {view === \"list\" && (\n            <div aria-hidden=\"true\" className=\"sticky top-0 z-10 grid grid-cols-[minmax(0,1fr)_7.5rem_5rem] gap-3 border-b border-foreground/[0.08] bg-card/90 px-4 py-1 text-[11px] font-medium text-muted-foreground backdrop-blur @container\">\n              <span>{msg(\"finder-window.name\", \"Name\")}</span>\n              <span>{msg(\"finder-window.dateModified\", \"Date Modified\")}</span>\n              <span className=\"text-end\">{msg(\"finder-window.size\", \"Size\")}</span>\n            </div>\n          )}\n          <div\n            ref={gridRef}\n            role=\"listbox\"\n            aria-label={`${folder.name}, ${status}`}\n            aria-multiselectable=\"true\"\n            aria-activedescendant={items.length ? optionId(Math.min(active, items.length - 1)) : undefined}\n            tabIndex={0}\n            onKeyDown={onKeyDown}\n            className={cn(\"outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\", view === \"icons\" ? \"grid grid-cols-[repeat(auto-fill,minmax(5.75rem,1fr))] gap-1 p-3\" : \"px-2 py-1\")}\n          >\n            {items.map((node, i) => {\n              const isSel = selected.includes(node.id)\n              return (\n                <div\n                  key={node.id}\n                  id={optionId(i)}\n                  role=\"option\"\n                  aria-selected={isSel}\n                  aria-label={msg(\"finder-window.itemLabel\", \"{name}, {kind}\", { name: node.name, kind: node.kind === \"file\" ? (node.ext ?? msg(\"finder-window.document\", \"document\")) : node.kind })}\n                  onClick={(e) => onItemClick(e, i)}\n                  onDoubleClick={() => open(node)}\n                  className={cn(\n                    \"cursor-default select-none\",\n                    view === \"icons\"\n                      ? \"flex flex-col items-center gap-1 rounded-lg p-1.5 text-center\"\n                      : cn(\"grid grid-cols-[minmax(0,1fr)_7.5rem_5rem] items-center gap-3 rounded-md px-2 py-[3px] text-[13px]\", i % 2 === 1 && !isSel && \"bg-foreground/[0.03]\"),\n                    isSel && view === \"list\" && \"bg-[var(--mac-accent,oklch(0.53_0.2_258))] text-white\",\n                    active === i && \"outline-2 -outline-offset-2 outline-transparent [[role=listbox]:focus-visible_&]:outline-ring/60\"\n                  )}\n                >\n                  {view === \"icons\" ? (\n                    <>\n                      <span className={cn(\"rounded-lg p-1\", isSel && \"bg-foreground/[0.1]\")}>\n                        <Icon node={node} size={56} />\n                      </span>\n                      <span className={cn(\"line-clamp-2 max-w-full rounded-[5px] px-1.5 text-[12px] leading-4 break-words\", isSel && \"bg-[var(--mac-accent,oklch(0.53_0.2_258))] text-white\")} aria-hidden=\"true\">\n                        {node.name}\n                      </span>\n                    </>\n                  ) : (\n                    <>\n                      <span className=\"flex min-w-0 items-center gap-2\">\n                        <Icon node={node} size={18} />\n                        <span className=\"truncate\" aria-hidden=\"true\">{node.name}</span>\n                      </span>\n                      <span aria-hidden=\"true\" className={cn(\"truncate text-[12px]\", isSel ? \"text-white/80\" : \"text-muted-foreground\")}>{node.modified ?? \"—\"}</span>\n                      <span aria-hidden=\"true\" className={cn(\"truncate text-end text-[12px] tabular-nums\", isSel ? \"text-white/80\" : \"text-muted-foreground\")}>{node.size ?? \"—\"}</span>\n                    </>\n                  )}\n                </div>\n              )\n            })}\n            {items.length === 0 && <p className=\"col-span-full px-4 py-10 text-center text-[13px] text-muted-foreground\">{query ? `No items match “${query}”.` : \"This folder is empty.\"}</p>}\n          </div>\n        </MacWindowContent>\n\n        <nav aria-label={msg(\"finder-window.path\", \"Path\")} className=\"flex h-7 shrink-0 items-center gap-1 border-t border-foreground/[0.08] bg-foreground/[0.02] px-3 text-[11px] text-muted-foreground\">\n          <ol className=\"flex min-w-0 items-center gap-0.5\">\n            {trail.map((n, i) => (\n              <li key={n.id} className=\"flex min-w-0 items-center gap-0.5\">\n                {i > 0 && <ChevronRight aria-hidden=\"true\" className=\"size-3 shrink-0 opacity-60 rtl:rotate-180\" />}\n                <button\n                  type=\"button\"\n                  aria-current={i === trail.length - 1 ? \"location\" : undefined}\n                  onClick={() => i < trail.length - 1 && go(path.slice(0, i + 1))}\n                  className=\"flex min-w-0 items-center gap-1 rounded px-1 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-[current=location]:text-foreground\"\n                >\n                  <span aria-hidden=\"true\" className=\"shrink-0\"><Icon node={n} size={14} /></span>\n                  <span className=\"truncate\">{n.name}</span>\n                </button>\n              </li>\n            ))}\n          </ol>\n          <span className=\"ms-auto shrink-0 tabular-nums max-sm:sr-only\" role=\"status\">{status}</span>\n        </nav>\n      </MacWindowMain>\n    </MacWindow>\n  )\n}\n\nexport { FinderWindow, type FinderWindowProps, type FinderNode, type FinderSidebarSection }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/finder-window.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "finder",
      "files",
      "macos",
      "window",
      "browser"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<FinderWindow root={tree} sidebar defaultFolder view onOpenFile onSelectionChange />. root is a FinderNode tree (folder, file or drive). Double-click or Return opens; Cmd+Up goes to the parent folder.",
      "whenToUse": [
        "Product demos and docs that show a file system",
        "Mac-style apps"
      ],
      "whenNotToUse": [
        "A real file manager for your app (file-browser)",
        "A single window frame (mac-window)"
      ],
      "composesWith": [
        "mac-window",
        "toolbar",
        "desktop-icons",
        "mac-context-menu"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move the selection; Shift extends it"
        },
        {
          "keys": "Enter / Cmd+Down",
          "action": "Opens the item"
        },
        {
          "keys": "Cmd+Up",
          "action": "Opens the parent folder"
        },
        {
          "keys": "Screen readers",
          "action": "A multi-select listbox named by the folder; the path bar and item count are announced"
        }
      ],
      "customization": [
        "view: icons | list",
        "sidebar sections",
        "defaultFolder",
        "onOpenFile"
      ]
    },
    "examples": [
      "finder-window-demo",
      "finder-window-list"
    ],
    "url": "https://ui.ballmac.com/components/finder-window"
  },
  "categories": [
    "macos"
  ],
  "type": "registry:ui"
}