{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-browser",
  "title": "File Browser",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A file manager for apps: breadcrumb folders, search, sortable columns, row checkboxes with select-all, a bulk-action bar, list and grid views and full keyboard control.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/file-browser.tsx",
      "content": "// Ballmac UI: File Browser. https://ui.ballmac.com/components/file-browser\n\"use client\"\n\nimport * as React from \"react\"\nimport { Archive, ArrowDown, ArrowUp, ChevronRight, File, FileCode2, FileText, Film, Folder, Image as ImageIcon, LayoutGrid, List, Music, Search, Trash2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\n\ntype FileEntry = {\n  /** Unique id. */\n  id: string\n  /** File or folder name. */\n  name: string\n  /** What it is. */\n  kind: \"folder\" | \"file\"\n  /** Size in bytes (files). */\n  size?: number\n  /** Last change, as an ISO string. Shown in UTC. */\n  modified?: string\n  /** Who owns it. */\n  owner?: string\n  /** Contents of a folder. */\n  children?: FileEntry[]\n}\n\ntype SortKey = \"name\" | \"modified\" | \"size\"\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n\nfunction formatDate(iso?: string) {\n  if (!iso) return \"—\"\n  const d = new Date(iso)\n  if (Number.isNaN(d.getTime())) return \"—\"\n  return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`\n}\n\nfunction itemCount(n: number) {\n  return `${n} ${n === 1 ? \"item\" : \"items\"}`\n}\n\nfunction formatSize(bytes?: number) {\n  if (bytes === undefined) return \"—\"\n  if (bytes < 1024) return `${bytes} B`\n  const units = [\"KB\", \"MB\", \"GB\"]\n  let v = bytes / 1024\n  let u = 0\n  while (v >= 1024 && u < units.length - 1) {\n    v /= 1024\n    u++\n  }\n  return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[u]}`\n}\n\nconst EXT_ICON: [RegExp, React.ComponentType<{ className?: string }>, string][] = [\n  [/\\.(png|jpe?g|gif|webp|svg|avif)$/i, ImageIcon, \"bg-chart-2/15 text-chart-2\"],\n  [/\\.(mp4|mov|webm|mkv)$/i, Film, \"bg-chart-5/15 text-chart-5\"],\n  [/\\.(mp3|wav|flac|m4a)$/i, Music, \"bg-chart-4/15 text-chart-4\"],\n  [/\\.(zip|gz|tar|rar|7z)$/i, Archive, \"bg-chart-3/15 text-chart-3\"],\n  [/\\.(ts|tsx|js|jsx|json|py|go|rs|css|html|sh)$/i, FileCode2, \"bg-chart-1/15 text-chart-1\"],\n  [/\\.(pdf|docx?|txt|md|rtf)$/i, FileText, \"bg-destructive/10 text-destructive\"],\n]\n\nfunction TypeIcon({ entry, className }: { entry: FileEntry; className?: string }) {\n  const hit = entry.kind === \"file\" ? EXT_ICON.find(([re]) => re.test(entry.name)) : null\n  const Glyph = entry.kind === \"folder\" ? Folder : (hit?.[1] ?? File)\n  return (\n    <span aria-hidden=\"true\" className={cn(\"flex size-8 shrink-0 items-center justify-center rounded-lg\", entry.kind === \"folder\" ? \"bg-chart-1/15 text-chart-1\" : (hit?.[2] ?? \"bg-muted text-muted-foreground\"), className)}>\n      <Glyph className=\"size-4\" />\n    </span>\n  )\n}\n\nfunction sortEntries(list: FileEntry[], key: SortKey, dir: \"asc\" | \"desc\") {\n  const sign = dir === \"asc\" ? 1 : -1\n  return [...list].sort((a, b) => {\n    if (a.kind !== b.kind) return a.kind === \"folder\" ? -1 : 1\n    const cmp =\n      key === \"name\" ? a.name.localeCompare(b.name, \"en\", { numeric: true }) : key === \"size\" ? (a.size ?? 0) - (b.size ?? 0) : (a.modified ?? \"\").localeCompare(b.modified ?? \"\")\n    return cmp * sign || a.name.localeCompare(b.name, \"en\")\n  })\n}\n\ntype FileBrowserProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** The top-level entries. Folders can hold more through `children`. */\n  entries: FileEntry[]\n  /** Name of the top level in the breadcrumb. */\n  rootLabel?: string\n  /** \"list\" is a sortable table, \"grid\" is a card grid. */\n  view?: \"list\" | \"grid\"\n  /** Called when a file is opened (double-click or Enter). */\n  onOpenFile?: (file: FileEntry) => void\n  /** Adds a Delete button to the selection bar. */\n  onDelete?: (ids: string[]) => void\n  /** Adds a Download button to the selection bar. */\n  onDownload?: (ids: string[]) => void\n}\n\n/** A file manager: breadcrumb navigation, search, sortable columns, multi-select with a bulk-action bar, and list or grid views. */\nfunction FileBrowser({ entries, rootLabel, view: viewProp = \"list\", onOpenFile, onDelete, onDownload, className, ...props }: FileBrowserProps) {\n  const dir = useDirection()\n  const msg = useMessages()\n  rootLabel ??= msg(\"file-browser.rootLabel\", \"All files\")\n  const uid = React.useId()\n  const [trail, setTrail] = React.useState<FileEntry[]>([])\n  const [view, setView] = React.useState(viewProp)\n  const [query, setQuery] = React.useState(\"\")\n  const [sort, setSort] = React.useState<{ key: SortKey; dir: \"asc\" | \"desc\" }>({ key: \"name\", dir: \"asc\" })\n  const [selected, setSelected] = React.useState<Set<string>>(new Set())\n  const [focusIndex, setFocusIndex] = React.useState(0)\n  const rowRefs = React.useRef<(HTMLElement | null)[]>([])\n  const allRef = React.useRef<HTMLInputElement>(null)\n\n  const current = trail.length ? (trail[trail.length - 1]!.children ?? []) : entries\n  const visible = sortEntries(current.filter((e) => !query || e.name.toLowerCase().includes(query.toLowerCase())), sort.key, sort.dir)\n  const allSelected = visible.length > 0 && visible.every((e) => selected.has(e.id))\n  const someSelected = visible.some((e) => selected.has(e.id))\n\n  React.useEffect(() => {\n    if (allRef.current) allRef.current.indeterminate = someSelected && !allSelected\n  }, [someSelected, allSelected])\n\n  function enter(entry: FileEntry) {\n    if (entry.kind === \"folder\") {\n      setTrail((t) => [...t, entry])\n      setSelected(new Set())\n      setQuery(\"\")\n      setFocusIndex(0)\n    } else onOpenFile?.(entry)\n  }\n  function toggle(id: string, on?: boolean) {\n    setSelected((s) => {\n      const next = new Set(s)\n      if (on ?? !next.has(id)) next.add(id)\n      else next.delete(id)\n      return next\n    })\n  }\n  function setSortKey(key: SortKey) {\n    setSort((s) => (s.key === key ? { key, dir: s.dir === \"asc\" ? \"desc\" : \"asc\" } : { key, dir: \"asc\" }))\n  }\n  function move(next: number) {\n    const i = Math.min(visible.length - 1, Math.max(0, next))\n    setFocusIndex(i)\n    rowRefs.current[i]?.focus()\n  }\n  function onRowKey(event: React.KeyboardEvent, entry: FileEntry, i: number) {\n    const cols = view === \"grid\" ? Math.max(1, Math.floor((event.currentTarget.parentElement?.clientWidth ?? 1) / ((event.currentTarget as HTMLElement).offsetWidth || 1))) : 1\n    if (event.key === \"ArrowDown\") { event.preventDefault(); move(i + cols) }\n    else if (event.key === \"ArrowUp\") { event.preventDefault(); move(i - cols) }\n    else if (view === \"grid\" && event.key === (dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\")) { event.preventDefault(); move(i + 1) }\n    else if (view === \"grid\" && event.key === (dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\")) { event.preventDefault(); move(i - 1) }\n    else if (event.key === \"Home\") { event.preventDefault(); move(0) }\n    else if (event.key === \"End\") { event.preventDefault(); move(visible.length - 1) }\n    else if (event.key === \" \") { event.preventDefault(); toggle(entry.id) }\n    else if (event.key === \"Enter\") { event.preventDefault(); enter(entry) }\n  }\n\n  const count = selected.size\n\n  return (\n    <div data-slot=\"file-browser\" className={cn(\"@container flex w-full flex-col overflow-hidden rounded-xl border bg-card text-card-foreground\", className)} {...props}>\n      <div className=\"flex flex-wrap items-center gap-2 border-b px-3 py-2.5\">\n        <nav aria-label={msg(\"file-browser.folderPath\", \"Folder path\")} className=\"min-w-0 flex-1\">\n          <ol className=\"flex min-w-0 flex-wrap items-center gap-1 text-sm\">\n            {[{ id: \"root\", name: rootLabel } as FileEntry, ...trail].map((crumb, i, all) => (\n              <li key={crumb.id} className=\"flex min-w-0 items-center gap-1\">\n                {i > 0 && <ChevronRight aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground rtl:rotate-180\" />}\n                <button\n                  type=\"button\"\n                  aria-current={i === all.length - 1 ? \"location\" : undefined}\n                  onClick={() => { if (i < all.length - 1) { setTrail(trail.slice(0, i)); setSelected(new Set()); setQuery(\"\") } }}\n                  className=\"truncate rounded-md px-1.5 py-0.5 text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-[current=location]:font-semibold aria-[current=location]:text-foreground\"\n                >\n                  {crumb.name}\n                </button>\n              </li>\n            ))}\n          </ol>\n        </nav>\n        <div className=\"relative w-full @md:w-52\">\n          <Search aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 start-2.5 size-4 -translate-y-1/2 text-muted-foreground\" />\n          <input type=\"search\" aria-label={msg(\"file-browser.searchFiles\", \"Search files\")} placeholder={msg(\"file-browser.searchFiles\", \"Search files\")} value={query} onChange={(e) => setQuery(e.target.value)} className=\"h-8 w-full rounded-md border bg-background pe-2 ps-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\" />\n        </div>\n        <div role=\"group\" aria-label={msg(\"file-browser.view\", \"View\")} className=\"inline-flex rounded-lg bg-muted p-0.5\">\n          {([[\"list\", List, \"List view\"], [\"grid\", LayoutGrid, \"Grid view\"]] as const).map(([v, Glyph, label]) => (\n            <button key={v} type=\"button\" aria-label={label} aria-pressed={view === v} onClick={() => setView(v)} className=\"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs\">\n              <Glyph aria-hidden=\"true\" className=\"size-4\" />\n            </button>\n          ))}\n        </div>\n      </div>\n\n      {count > 0 && (\n        <div role=\"region\" aria-label={msg(\"file-browser.selectionActions\", \"Selection actions\")} className=\"flex items-center gap-2 border-b bg-accent/50 px-3 py-1.5 text-sm\">\n          <span className=\"font-medium tabular-nums\">{msg(\"file-browser.selectedCount\", \"{count} selected\", { count })}</span>\n          <span className=\"ms-auto flex items-center gap-1\">\n            {onDownload && <button type=\"button\" onClick={() => onDownload([...selected])} className=\"h-7 rounded-md border bg-background px-2.5 text-[13px] font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\">{msg(\"file-browser.download\", \"Download\")}</button>}\n            {onDelete && (\n              <button type=\"button\" onClick={() => { onDelete([...selected]); setSelected(new Set()) }} className=\"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2.5 text-[13px] font-medium outline-none hover:bg-destructive/10 focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n                <Trash2 aria-hidden=\"true\" className=\"size-3.5\" />\n                {msg(\"file-browser.delete\", \"Delete\")}\n              </button>\n            )}\n            <button type=\"button\" onClick={() => setSelected(new Set())} className=\"h-7 rounded-md px-2.5 text-[13px] text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\">{msg(\"file-browser.clear\", \"Clear\")}</button>\n          </span>\n        </div>\n      )}\n\n      {view === \"list\" ? (\n        <div className=\"max-h-96 overflow-auto\" tabIndex={0} role=\"region\" aria-label={msg(\"file-browser.files\", \"Files\")}>\n          <table className=\"w-full min-w-[30rem] border-collapse text-sm\">\n            <thead className=\"sticky top-0 z-10 bg-card text-start text-xs text-muted-foreground\">\n              <tr className=\"border-b\">\n                <th className=\"w-10 py-2 ps-3 font-normal\">\n                  <input ref={allRef} type=\"checkbox\" aria-label={msg(\"file-browser.selectAllFiles\", \"Select all files\")} checked={allSelected} onChange={() => setSelected(allSelected ? new Set() : new Set(visible.map((e) => e.id)))} className=\"size-4 rounded accent-primary\" />\n                </th>\n                {([[\"name\", \"Name\"], [\"modified\", \"Modified\"], [\"size\", \"Size\"]] as const).map(([key, label]) => (\n                  <th key={key} aria-sort={sort.key === key ? (sort.dir === \"asc\" ? \"ascending\" : \"descending\") : \"none\"} className={cn(\"py-2 pe-3 font-normal\", key === \"size\" && \"text-end\", key === \"modified\" && \"hidden @md:table-cell\")}>\n                    <button type=\"button\" onClick={() => setSortKey(key)} className=\"inline-flex items-center gap-1 rounded px-1 py-0.5 outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n                      {label}\n                      {sort.key === key && (sort.dir === \"asc\" ? <ArrowUp aria-hidden=\"true\" className=\"size-3\" /> : <ArrowDown aria-hidden=\"true\" className=\"size-3\" />)}\n                    </button>\n                  </th>\n                ))}\n              </tr>\n            </thead>\n            <tbody>\n              {visible.map((e, i) => (\n                <tr\n                  key={e.id}\n                  ref={(el) => { rowRefs.current[i] = el }}\n                  tabIndex={focusIndex === i ? 0 : -1}\n                  aria-selected={selected.has(e.id)}\n                  onFocus={() => setFocusIndex(i)}\n                  onKeyDown={(ev) => onRowKey(ev, e, i)}\n                  onDoubleClick={() => enter(e)}\n                  className={cn(\"border-b outline-none last:border-0 hover:bg-accent/40 focus-visible:bg-accent/60 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\", selected.has(e.id) && \"bg-accent/70\")}\n                >\n                  <td className=\"py-2 ps-3\">\n                    <input type=\"checkbox\" aria-label={msg(\"file-browser.select\", \"Select {name}\", { name: e.name })} checked={selected.has(e.id)} onChange={(ev) => toggle(e.id, ev.target.checked)} className=\"size-4 rounded accent-primary\" />\n                  </td>\n                  <td className=\"py-2 pe-3\">\n                    <span className=\"flex min-w-0 items-center gap-3\">\n                      <TypeIcon entry={e} />\n                      {e.kind === \"folder\" ? (\n                        <button type=\"button\" onClick={() => enter(e)} className=\"truncate rounded text-start font-medium outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50\">{e.name}</button>\n                      ) : (\n                        <span className=\"truncate font-medium\">{e.name}</span>\n                      )}\n                    </span>\n                  </td>\n                  <td className=\"hidden py-2 pe-3 text-muted-foreground @md:table-cell\">{formatDate(e.modified)}</td>\n                  <td className=\"py-2 pe-3 text-end text-muted-foreground tabular-nums\">{e.kind === \"folder\" ? itemCount(e.children?.length ?? 0) : formatSize(e.size)}</td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      ) : (\n        <div role=\"region\" aria-label={msg(\"file-browser.files\", \"Files\")} tabIndex={0} className=\"max-h-96 overflow-auto p-3\">\n          <div className=\"grid grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2.5\">\n            {visible.map((e, i) => (\n              <div\n                key={e.id}\n                ref={(el) => { rowRefs.current[i] = el }}\n                role=\"button\"\n                tabIndex={focusIndex === i ? 0 : -1}\n                aria-pressed={selected.has(e.id)}\n                aria-label={msg(\"file-browser.rowLabel\", \"{name}, {detail}\", { name: e.name, detail: e.kind === \"folder\" ? msg(\"file-browser.folder\", \"folder\") : formatSize(e.size) })}\n                onFocus={() => setFocusIndex(i)}\n                onKeyDown={(ev) => onRowKey(ev, e, i)}\n                onClick={() => toggle(e.id)}\n                onDoubleClick={() => enter(e)}\n                className={cn(\"flex flex-col gap-2 rounded-xl border p-3 outline-none hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50\", selected.has(e.id) && \"border-primary bg-accent/60\")}\n              >\n                <TypeIcon entry={e} className=\"size-10 rounded-xl [&_svg]:size-5\" />\n                <span className=\"truncate text-sm font-medium\" aria-hidden=\"true\">{e.name}</span>\n                <span className=\"text-xs text-muted-foreground\" aria-hidden=\"true\">{e.kind === \"folder\" ? itemCount(e.children?.length ?? 0) : formatSize(e.size)}</span>\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n      {visible.length === 0 && (\n        <p className=\"px-4 py-12 text-center text-sm text-muted-foreground\">{query ? `Nothing matches “${query}”.` : \"This folder is empty.\"}</p>\n      )}\n      <p id={`${uid}-status`} role=\"status\" className=\"border-t px-3 py-1.5 text-xs text-muted-foreground tabular-nums\">\n        {visible.length} {visible.length === 1 ? \"item\" : \"items\"}\n        {count ? `, ${count} selected` : \"\"}\n      </p>\n    </div>\n  )\n}\n\nexport { FileBrowser, formatSize, type FileBrowserProps, type FileEntry }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/file-browser.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "files",
      "browser",
      "table",
      "upload",
      "folders"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<FileBrowser entries rootLabel view onOpenFile onDelete onDownload />. entries is a FileEntry tree. Sort by name, modified or size (aria-sort). Space selects, Enter opens, arrow keys move.",
      "whenToUse": [
        "Drive-style pages inside an app",
        "Choosing files for download or deletion"
      ],
      "whenNotToUse": [
        "A Mac-styled window (finder-window)",
        "Plain tables (data-table)"
      ],
      "composesWith": [
        "data-table",
        "breadcrumb",
        "file-dropzone"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move between rows or cards"
        },
        {
          "keys": "Space",
          "action": "Toggles selection"
        },
        {
          "keys": "Enter",
          "action": "Opens a folder or file"
        },
        {
          "keys": "Screen readers",
          "action": "Sort state, selection count and item count are announced"
        }
      ],
      "customization": [
        "view",
        "rootLabel",
        "onDelete and onDownload",
        "sorting"
      ]
    },
    "examples": [
      "file-browser-demo",
      "file-browser-grid"
    ],
    "url": "https://ui.ballmac.com/components/file-browser"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}