{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-browser",
  "type": "registry:component",
  "title": "File browser",
  "description": "Explores a supplied file hierarchy through folders, breadcrumbs, search, and list or grid views.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/icons.json",
    "https://vlak.dev/r/input.json",
    "https://vlak.dev/r/tree-view.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/file-browser.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { Icon } from \"./icons\";\nimport { Input } from \"./input\";\nimport { TreeView, type TreeNode } from \"./tree-view\";\n\nexport interface BrowserEntry { id: string; name: string; kind: \"file\" | \"folder\"; children?: BrowserEntry[]; size?: string; modified?: string; disabled?: boolean }\nexport interface FileBrowserProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onSelect\"> {\n  entries: BrowserEntry[];\n  label?: string;\n  /** Visible root-folder name, separate from the accessible browser label. */\n  rootLabel?: string;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  folder?: string | null;\n  defaultFolder?: string | null;\n  onFolderChange?: (id: string | null) => void;\n  onOpen?: (entry: BrowserEntry) => void;\n}\nconst styles = stylex.create({\n  root: { display: \"flex\", flexDirection: \"column\", width: \"100%\", minWidth: 0, gap: \"1rem\", color: vlak.ink },\n  toolbar: { display: \"flex\", alignItems: \"center\", flexWrap: \"wrap\", gap: \"0.5rem\" },\n  breadcrumbs: { minWidth: 0, maxWidth: \"100%\" },\n  path: { display: \"flex\", alignItems: \"center\", flexWrap: \"wrap\", gap: \"0.25rem\", listStyleType: \"none\", padding: 0, margin: 0 },\n  crumbItem: { display: \"flex\", alignItems: \"center\", gap: \"0.25rem\", minWidth: 0, maxWidth: \"100%\" },\n  crumb: { display: \"inline-flex\", alignItems: \"center\", minWidth: vlak.hit, minHeight: vlak.hit, maxWidth: \"min(16rem, 100%)\", paddingBlock: 0, paddingInline: \"0.375rem\", boxSizing: \"border-box\", borderWidth: 0, borderRadius: vlak.radiusSm, backgroundColor: { default: \"transparent\", \":hover\": { default: null, [mq.hover]: vlak.controlFill } }, color: { default: vlak.gray, \":hover\": { default: null, [mq.hover]: vlak.ink } }, fontFamily: \"inherit\", fontSize: \"0.875rem\", fontWeight: 400, lineHeight: 1.45, cursor: \"pointer\", \":focus-visible\": { outlineWidth: 2, outlineStyle: \"solid\", outlineColor: vlak.ink, outlineOffset: -2 } },\n  crumbCurrent: { color: vlak.ink, fontWeight: 500 },\n  action: {\n    display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", gap: \"0.5rem\",\n    width: \"auto\", minWidth: vlak.hit, minHeight: vlak.hit, boxSizing: \"border-box\",\n    paddingBlock: \"0.5rem\", paddingInline: \"0.75rem\",\n    fontFamily: \"inherit\", fontSize: \"0.875rem\", fontWeight: 500, lineHeight: 1.45, whiteSpace: \"nowrap\",\n    borderWidth: vlak.hairline, borderStyle: \"solid\", borderRadius: vlak.radiusSm,\n    borderColor: { default: vlak.controlBorder, [mq.forcedColors]: \"ButtonText\" },\n    backgroundColor: { default: \"transparent\", \":hover:not(:disabled):not([aria-disabled='true'])\": { default: null, [mq.hover]: { default: vlak.controlFill, [mq.forcedColors]: \"ButtonFace\" } }, \":disabled\": \"transparent\", [mq.forcedColors]: \"ButtonFace\" },\n    color: { default: vlak.ink, \":disabled\": vlak.gray, [mq.forcedColors]: { default: \"ButtonText\", \":disabled\": \"GrayText\" } },\n    cursor: { default: \"pointer\", \":disabled\": \"not-allowed\" },\n    transition: { default: vlak.transition, [mq.reduce]: \"none\" },\n    outlineWidth: { default: 0, \":focus-visible\": 2 },\n    outlineStyle: { default: \"none\", \":focus-visible\": \"solid\" },\n    outlineColor: { default: vlak.ink, [mq.forcedColors]: \"Highlight\" },\n    outlineOffset: 2,\n  },\n  active: { backgroundColor: { default: vlak.controlFill, \":hover:not(:disabled):not([aria-disabled='true'])\": { default: null, [mq.hover]: { default: vlak.controlFill, [mq.forcedColors]: \"Highlight\" } }, [mq.forcedColors]: \"Highlight\" }, color: { default: vlak.ink, [mq.forcedColors]: \"HighlightText\" }, fontWeight: 600 },\n  body: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"flex-start\", gap: vlak.gutter, minWidth: 0 },\n  tree: { flexBasis: { default: \"11.5rem\", [mq.phone]: \"100%\" }, flexGrow: 0, flexShrink: 1, minWidth: 0, maxWidth: \"100%\" },\n  content: { flex: \"1 1 18rem\", minWidth: 0, display: \"flex\", flexDirection: \"column\", gap: \"0.75rem\" },\n  list: { listStyleType: \"none\", padding: 0, margin: 0, display: \"grid\", gridTemplateColumns: \"minmax(0, 1fr)\", gap: \"0.5rem\" },\n  grid: { gridTemplateColumns: \"repeat(auto-fill, minmax(8rem, 1fr))\" },\n  item: { display: \"flex\", gap: \"0.75rem\", alignItems: \"center\", width: \"100%\", minWidth: 0, minHeight: vlak.hit, borderWidth: vlak.hairline, borderStyle: \"solid\", borderColor: vlak.controlBorder, borderRadius: vlak.radiusSm, padding: \"0.75rem\", backgroundColor: \"transparent\", color: vlak.ink, textAlign: \"start\", fontFamily: \"inherit\", fontSize: \"0.875rem\", cursor: \"pointer\", \":focus-visible\": { outlineWidth: 2, outlineStyle: \"solid\", outlineColor: vlak.ink, outlineOffset: 2 }, \":disabled\": { color: vlak.gray, cursor: \"not-allowed\" } },\n  tile: { flexDirection: \"column\", alignItems: \"flex-start\", minHeight: \"7.5rem\" },\n  selected: { backgroundColor: vlak.controlFill, fontWeight: 600 },\n  name: { flex: \"1 1 auto\", minWidth: 0, maxWidth: \"100%\", overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\" },\n  meta: { color: vlak.gray, fontSize: \"0.75rem\", fontVariantNumeric: \"tabular-nums\", lineHeight: 1.45, whiteSpace: \"nowrap\", minWidth: 0, overflow: \"hidden\", textOverflow: \"ellipsis\", flexShrink: 1 },\n  empty: { margin: 0, paddingBlock: \"1rem\", color: vlak.gray, lineHeight: 1.45 },\n});\nfunction findPath(entries: BrowserEntry[], id: string | null | undefined): BrowserEntry[] | null {\n  if (id == null) return [];\n  for (const entry of entries) { if (entry.id === id) return [entry]; if (entry.children) { const child = findPath(entry.children, id); if (child) return [entry, ...child]; } }\n  return null;\n}\nconst folderNodes = (entries: BrowserEntry[]): TreeNode[] => entries.filter(entry => entry.kind === \"folder\").map(entry => ({ id: entry.id, label: entry.name, disabled: entry.disabled, children: folderNodes(entry.children ?? []) }));\n\n/** A controlled file collection with folder tree, breadcrumbs, search, and list or grid views. */\nexport const FileBrowser = React.forwardRef<HTMLDivElement, FileBrowserProps>(function FileBrowser({ entries, label = \"Files\", rootLabel = \"Files\", value, defaultValue, onValueChange, folder, defaultFolder = null, onFolderChange, onOpen, className, style, ...props }, ref) {\n  const [inner, setInner] = React.useState(defaultValue);\n  const [innerFolder, setInnerFolder] = React.useState<string | null>(defaultFolder);\n  const [view, setView] = React.useState<\"list\" | \"grid\">(\"list\");\n  const [query, setQuery] = React.useState(\"\");\n  const rootId = React.useId();\n  const folderId = folder === undefined ? innerFolder : folder;\n  const rawPath = findPath(entries, folderId) ?? [];\n  const path = rawPath.at(-1)?.kind === \"file\" ? rawPath.slice(0, -1) : rawPath;\n  const currentFolder = path.at(-1);\n  const pathKey = JSON.stringify(path.map(entry => entry.id));\n  const [expandedIds, setExpandedIds] = React.useState<string[]>(() => [rootId, ...path.map(entry => entry.id)]);\n  React.useEffect(() => {\n    const visibleAncestors = [rootId, ...JSON.parse(pathKey) as string[]];\n    setExpandedIds(previous => visibleAncestors.every(id => previous.includes(id)) ? previous : [...new Set([...previous, ...visibleAncestors])]);\n  }, [pathKey, rootId]);\n  const visible = (currentFolder?.children ?? entries).filter(entry => entry.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()));\n  const selected = value ?? inner;\n  const chosen = (currentFolder?.children ?? entries).find(entry => entry.id === selected);\n  const navigate = (next: string | null) => { if (folder === undefined) setInnerFolder(next); onFolderChange?.(next); setQuery(\"\"); };\n  const select = (entry: BrowserEntry) => { if (entry.disabled) return; if (entry.kind === \"folder\") navigate(entry.id); else { if (value === undefined) setInner(entry.id); onValueChange?.(entry.id); } };\n  const root = rs([\"rs-file-browser\", className], styles.root);\n  const toolbar = rs([\"rs-file-browser-toolbar\"], styles.toolbar);\n  const breadcrumbs = rs([\"rs-file-browser-breadcrumbs\"], styles.breadcrumbs);\n  const pathStyle = rs([\"rs-file-browser-path\"], styles.path);\n  const crumbItem = rs([\"rs-file-browser-crumb-item\"], styles.crumbItem);\n  const rootCrumb = rs([\"rs-file-browser-crumb\", path.length === 0 && \"rs-file-browser-crumb-current\"], styles.crumb, path.length === 0 && styles.crumbCurrent);\n  const action = rs([\"rs-file-browser-action\"], styles.action);\n  const body = rs([\"rs-file-browser-body\"], styles.body);\n  const tree = rs([\"rs-file-browser-tree\"], styles.tree);\n  const content = rs([\"rs-file-browser-content\"], styles.content);\n  const list = rs([\"rs-file-browser-list\", view === \"grid\" && \"rs-file-browser-grid\"], styles.list, view === \"grid\" && styles.grid);\n  const name = rs([\"rs-file-browser-name\"], styles.name);\n  const meta = rs([\"rs-file-browser-meta\"], styles.meta);\n  const empty = rs([\"rs-file-browser-empty\"], styles.empty);\n  return <div ref={ref} role=\"region\" aria-label={label} {...props} className={root.className} style={{ ...root.style, ...style }}>\n    <nav {...breadcrumbs} aria-label={`${label} breadcrumbs`}><ol {...pathStyle}><li {...crumbItem}><button {...rootCrumb} type=\"button\" aria-current={path.length === 0 ? \"location\" : undefined} onClick={() => navigate(null)}><span {...name} title={rootLabel}>{rootLabel}</span></button></li>{path.map((entry, index) => { const current = index === path.length - 1; const crumb = rs([\"rs-file-browser-crumb\", current && \"rs-file-browser-crumb-current\"], styles.crumb, current && styles.crumbCurrent); return <li key={entry.id} {...crumbItem}><Icon name=\"chevron-right\" size={12} /><button {...crumb} type=\"button\" aria-current={current ? \"location\" : undefined} onClick={() => navigate(entry.id)}><span {...name} title={entry.name}>{entry.name}</span></button></li>; })}</ol></nav>\n    <div {...body}>\n      <aside {...tree} aria-label={`${label} folders`}><TreeView nodes={[{ id: rootId, label: rootLabel, children: folderNodes(entries) }]} label={`${label} folder tree`} value={currentFolder?.id ?? rootId} expanded={expandedIds} onExpandedChange={setExpandedIds} onValueChange={id => navigate(id === rootId ? null : id)} /></aside>\n      <div {...content}>\n        <Input type=\"search\" aria-label=\"Search this folder\" placeholder=\"Search this folder\" value={query} onChange={event => setQuery(event.target.value)} />\n        <div {...toolbar} role=\"group\" aria-label=\"File view\">{([\"list\", \"grid\"] as const).map(mode => { const choice = rs([\"rs-file-browser-action\", view === mode && \"rs-file-browser-view-active\"], styles.action, view === mode && styles.active); return <button key={mode} {...choice} type=\"button\" aria-pressed={view === mode} onClick={() => setView(mode)}><Icon name={mode} />{mode === \"list\" ? \"List\" : \"Grid\"}</button>; })}{onOpen && <button {...action} type=\"button\" disabled={!chosen || chosen.disabled} onClick={() => { if (chosen && !chosen.disabled) onOpen(chosen); }}>Open selected</button>}</div>\n        <ul {...list} aria-label={currentFolder?.name ?? label}>{visible.map(entry => { const selectedFile = entry.kind === \"file\" && entry.id === selected; const row = rs([\"rs-file-browser-item\", view === \"grid\" && \"rs-file-browser-tile\", selectedFile && \"rs-file-browser-selected\"], styles.item, view === \"grid\" && styles.tile, selectedFile && styles.selected); return <li key={entry.id}><button {...row} type=\"button\" disabled={entry.disabled} aria-pressed={entry.kind === \"file\" ? selectedFile : undefined} onClick={() => select(entry)} onDoubleClick={() => { if (!entry.disabled && entry.kind === \"file\") onOpen?.(entry); }}><Icon name={entry.kind === \"folder\" ? \"folder\" : \"file\"} size={view === \"grid\" ? 24 : 16} /><span {...name} title={entry.name}>{entry.name}</span><span {...meta}>{entry.kind === \"folder\" ? \"Folder\" : entry.size ?? \"File\"}{entry.modified ? ` · ${entry.modified}` : \"\"}</span></button></li>; })}</ul>\n        {visible.length === 0 && <p {...empty} role=\"status\">{query ? \"No files match this search.\" : \"This folder is empty.\"}</p>}\n      </div>\n    </div>\n  </div>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/file-browser.tsx"
    },
    {
      "path": "vlak/styles/file-browser.css",
      "content": "/* ── file-browser: generated from packages/react/src/components/file-browser.tsx ── */\n.rs-file-browser{display:flex;flex-direction:column;width:100%;min-width:0;gap:1rem;color:var(--text)}\n.rs-file-browser-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:0.5rem}\n.rs-file-browser-breadcrumbs{min-width:0;max-width:100%}\n.rs-file-browser-path{display:flex;align-items:center;flex-wrap:wrap;gap:0.25rem;list-style-type:none;padding:0;margin:0}\n.rs-file-browser-crumb-item{display:flex;align-items:center;gap:0.25rem;min-width:0;max-width:100%}\n.rs-file-browser-crumb{display:inline-flex;align-items:center;min-width:var(--hit);min-height:var(--hit);max-width:min(16rem, 100%);padding-block:0;padding-inline:0.375rem;box-sizing:border-box;border-width:0;border-radius:var(--radius-sm);background-color:transparent;color:var(--text-secondary);font-family:inherit;font-size:0.875rem;font-weight:400;line-height:1.45;cursor:pointer}\n.rs-file-browser-crumb:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:-2px}\n@media (hover: hover) and (pointer: fine){.rs-file-browser-crumb:hover{background-color:var(--control-fill);color:var(--text)}}\n.rs-file-browser-crumb-current{color:var(--text);font-weight:500}\n.rs-file-browser-action{display:inline-flex;align-items:center;justify-content:center;gap:0.5rem;width:auto;min-width:var(--hit);min-height:var(--hit);box-sizing:border-box;padding-block:0.5rem;padding-inline:0.75rem;font-family:inherit;font-size:0.875rem;font-weight:500;line-height:1.45;white-space:nowrap;border-width:1px;border-style:solid;border-radius:var(--radius-sm);border-color:var(--control-border);background-color:transparent;color:var(--text);cursor:pointer;transition:var(--transition);outline-width:0;outline-style:none;outline-color:var(--text);outline-offset:2px}\n.rs-file-browser-action:disabled{background-color:transparent;color:var(--text-secondary);cursor:not-allowed}\n.rs-file-browser-action:focus-visible{outline-width:2px;outline-style:solid}\n@media (forced-colors: active){.rs-file-browser-action{border-color:ButtonText;background-color:ButtonFace;color:ButtonText;outline-color:Highlight}.rs-file-browser-action:disabled{color:GrayText}}\n@media (hover: hover) and (pointer: fine){.rs-file-browser-action:hover:not(:disabled):not([aria-disabled='true']){background-color:var(--control-fill)}}\n@media (hover: hover) and (pointer: fine) and (forced-colors: active){.rs-file-browser-action:hover:not(:disabled):not([aria-disabled='true']){background-color:ButtonFace}}\n@media (prefers-reduced-motion: reduce){.rs-file-browser-action{transition:none}}\n.rs-file-browser-view-active{background-color:var(--control-fill);color:var(--text);font-weight:600}\n@media (hover: hover) and (pointer: fine){.rs-file-browser-view-active:hover:not(:disabled):not([aria-disabled='true']){background-color:var(--control-fill)}}\n@media (hover: hover) and (pointer: fine) and (forced-colors: active){.rs-file-browser-view-active:hover:not(:disabled):not([aria-disabled='true']){background-color:Highlight}}\n@media (forced-colors: active){.rs-file-browser-view-active{background-color:Highlight;color:HighlightText}}\n.rs-file-browser-body{display:flex;flex-wrap:wrap;align-items:flex-start;gap:var(--gutter);min-width:0}\n.rs-file-browser-tree{flex-basis:11.5rem;flex-grow:0;flex-shrink:1;min-width:0;max-width:100%}\n@media (max-width: 640px){.rs-file-browser-tree{flex-basis:100%}}\n.rs-file-browser-content{flex:1 1 18rem;min-width:0;display:flex;flex-direction:column;gap:0.75rem}\n.rs-file-browser-list{list-style-type:none;padding:0;margin:0;display:grid;grid-template-columns:minmax(0, 1fr);gap:0.5rem}\n.rs-file-browser-grid{grid-template-columns:repeat(auto-fill, minmax(8rem, 1fr))}\n.rs-file-browser-item{display:flex;gap:0.75rem;align-items:center;width:100%;min-width:0;min-height:var(--hit);border-width:1px;border-style:solid;border-color:var(--control-border);border-radius:var(--radius-sm);padding:0.75rem;background-color:transparent;color:var(--text);text-align:start;font-family:inherit;font-size:0.875rem;cursor:pointer}\n.rs-file-browser-item:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:2px}\n.rs-file-browser-item:disabled{color:var(--text-secondary);cursor:not-allowed}\n.rs-file-browser-tile{flex-direction:column;align-items:flex-start;min-height:7.5rem}\n.rs-file-browser-selected{background-color:var(--control-fill);font-weight:600}\n.rs-file-browser-name{flex:1 1 auto;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.rs-file-browser-meta{color:var(--text-secondary);font-size:0.75rem;font-variant-numeric:tabular-nums;line-height:1.45;white-space:nowrap;min-width:0;overflow:hidden;text-overflow:ellipsis;flex-shrink:1}\n.rs-file-browser-empty{margin:0;padding-block:1rem;color:var(--text-secondary);line-height:1.45}\n",
      "type": "registry:file",
      "target": "styles/vlak/file-browser.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "patterns",
      "classes": [
        "rs-file-browser",
        "rs-file-browser-toolbar",
        "rs-file-browser-breadcrumbs",
        "rs-file-browser-path",
        "rs-file-browser-crumb-item",
        "rs-file-browser-crumb",
        "rs-file-browser-crumb-current",
        "rs-file-browser-action",
        "rs-file-browser-body",
        "rs-file-browser-tree",
        "rs-file-browser-content",
        "rs-file-browser-list",
        "rs-file-browser-grid",
        "rs-file-browser-name",
        "rs-file-browser-meta",
        "rs-file-browser-empty",
        "rs-file-browser-view-active",
        "rs-file-browser-item",
        "rs-file-browser-tile",
        "rs-file-browser-selected"
      ],
      "snippet": "<div class=\"rs-file-browser\" role=\"region\" aria-label=\"Files\"><nav aria-label=\"Folder breadcrumbs\">Files</nav><ul class=\"rs-file-browser-list\"><li><button class=\"rs-file-browser-item\" type=\"button\" aria-pressed=\"false\">Cover.pdf</button></li></ul></div>",
      "cssOnly": false,
      "registryDependencies": [
        "icons",
        "input",
        "tree-view",
        "vlak-lib"
      ],
      "aliases": [
        "FileBrowser",
        "File explorer",
        "Asset browser"
      ],
      "example": "import { FileBrowser } from \"@noorddev/vlak-react\";\n\n<FileBrowser entries={[{ id: \"design\", name: \"Design\", kind: \"folder\", children: [{ id: \"cover\", name: \"Cover.pdf\", kind: \"file\", size: \"1.2 MB\" }] }]} onOpen={openFile} />",
      "usage": {
        "use": [
          "Browsing a supplied file tree and choosing a file.",
          "Folder-local search with consistent selection in list and grid views."
        ],
        "avoid": [
          "Direct filesystem access or cloud storage sync; the application supplies data and actions."
        ]
      },
      "keyboard": [
        {
          "keys": "Arrow keys, Home, End, type-ahead",
          "does": "Navigates the folder tree using its roving focus behavior."
        },
        {
          "keys": "Tab, Enter, Space",
          "does": "Uses breadcrumbs, selects files, switches view, and activates Open selected."
        }
      ],
      "a11y": [
        "Folder tree, breadcrumbs, search, and file collection each have names.",
        "label names the region and its landmarks; rootLabel supplies the visible root folder name.",
        "Selected files use aria-pressed and a full surface change.",
        "Files can be opened by an explicit keyboard-operable action as well as double click."
      ]
    }
  }
}
