{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-11",
  "title": "File explorer",
  "description": "A repository with an icon per file type, sorted again when a file is dropped on a folder.",
  "dependencies": [
    "@base-ui/react",
    "@headless-tree/core",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/kbd.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-presets.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/tree.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/tree/tree-11.tsx",
      "content": "'use client';\n\nimport {\n  dragAndDropFeature,\n  hotkeysCoreFeature,\n  keyboardDragAndDropFeature,\n  selectionFeature,\n  syncDataLoaderFeature,\n  type DragTarget,\n  type ItemInstance,\n} from '@headless-tree/core';\nimport { useEffect, useId, useState } from 'react';\n\nimport {\n  CodeIcon,\n  FileIcon,\n  FileTextIcon,\n  ImageIcon,\n  ReactBrandIcon,\n  SourceCodeIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { Kbd, KbdGroup } from '@/registry/ndeko/ui/kbd';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { Tree, TreeItemLabel, useTree } from '@/registry/ndeko/ui/tree';\n\nimport {\n  dragStatusOf,\n  moveNoticeOf,\n  spokenStatusOf,\n} from './_parts/drag-status';\nimport {\n  flattenBranches,\n  isFlatFolder,\n  moveNodes,\n  nameOf,\n  type Branch,\n  type FlatNode,\n  type FlatNodes,\n} from './_parts/tree-data';\nimport { TreeRow, TreeRows } from './_parts/tree-rows';\n\ntype FileKind = 'component' | 'script' | 'data' | 'image' | 'text' | 'other';\n\nconst REPOSITORY: Branch = {\n  id: 'repository',\n  name: 'Kettlebrook storefront',\n  children: [\n    {\n      id: 'src',\n      name: 'src',\n      children: [\n        {\n          id: 'src/checkout',\n          name: 'checkout',\n          children: [\n            { id: 'src/checkout/checkout-form.tsx', name: 'checkout-form.tsx' },\n            { id: 'src/checkout/use-cart.ts', name: 'use-cart.ts' },\n          ],\n        },\n        { id: 'src/storefront.tsx', name: 'storefront.tsx' },\n      ],\n    },\n    {\n      id: 'emails',\n      name: 'emails',\n      children: [\n        { id: 'emails/receipt.tsx', name: 'receipt.tsx' },\n        { id: 'emails/shipping-notice.tsx', name: 'shipping-notice.tsx' },\n      ],\n    },\n    {\n      id: 'assets',\n      name: 'assets',\n      children: [{ id: 'assets/wordmark.svg', name: 'wordmark.svg' }],\n    },\n    { id: 'prices.json', name: 'prices.json' },\n    { id: 'CHANGELOG.md', name: 'CHANGELOG.md' },\n  ],\n};\n\nconst FILES_AT_START = flattenBranches(REPOSITORY);\n\nconst OPEN_AT_START = ['src', 'src/checkout'];\n\nconst SELECTED_AT_START = ['emails'];\n\nconst INDENT = 20;\n\nconst KIND_OF_EXTENSION: Partial<Record<string, FileKind>> = {\n  tsx: 'component',\n  jsx: 'component',\n  ts: 'script',\n  js: 'script',\n  mjs: 'script',\n  json: 'data',\n  svg: 'image',\n  png: 'image',\n  webp: 'image',\n  md: 'text',\n};\n\nconst KIND_ICON: Record<FileKind, Icon> = {\n  component: ReactBrandIcon,\n  script: SourceCodeIcon,\n  data: CodeIcon,\n  image: ImageIcon,\n  text: FileTextIcon,\n  other: FileIcon,\n};\n\n/* A file takes the column of the chevron, so file names and folder names start on one line. */\nconst COMPACT_LABEL = 'py-1 leading-5 not-in-data-[folder=true]:ps-2';\n\nfunction kindOf(fileName: string): FileKind {\n  const parts = fileName.split('.');\n  const extension = parts.at(-1) ?? '';\n  return KIND_OF_EXTENSION[extension] ?? 'other';\n}\n\nfunction idOf(row: ItemInstance<FlatNode>) {\n  return row.getId();\n}\n\nfunction sortKeyOf(node: FlatNode) {\n  if (node.isFolder) {\n    return `0 ${node.name}`;\n  }\n  return `1 ${node.name}`;\n}\n\nfunction foldersFirstIn(nodes: FlatNodes) {\n  return function compareEntries(firstId: string, secondId: string) {\n    const first = nodes[firstId];\n    const second = nodes[secondId];\n    const firstKey = sortKeyOf(first);\n    const secondKey = sortKeyOf(second);\n    return firstKey.localeCompare(secondKey);\n  };\n}\n\ntype FileGlyphProps = {\n  name: string;\n};\n\nfunction FileGlyph({ name }: FileGlyphProps) {\n  const kind = kindOf(name);\n  const Glyph = KIND_ICON[kind];\n\n  return <Glyph className='text-muted-foreground size-4' />;\n}\n\nexport default function Tree11() {\n  const [nodes, setNodes] = useState(FILES_AT_START);\n  const [moveNotice, setMoveNotice] = useState('');\n  const hintId = useId();\n\n  function nodeOf(nodeId: string) {\n    return nodes[nodeId];\n  }\n\n  function sortedChildIdsOf(nodeId: string) {\n    const node = nodes[nodeId];\n    const childIds = [...node.childIds];\n    const order = foldersFirstIn(nodes);\n    return childIds.sort(order);\n  }\n\n  function dropRows(\n    rows: ItemInstance<FlatNode>[],\n    target: DragTarget<FlatNode>\n  ) {\n    const movedIds = rows.map(idOf);\n    const notice = moveNoticeOf(rows, target);\n    setNodes((current) => moveNodes(current, movedIds, target));\n    setMoveNotice(notice);\n  }\n\n  function forgetLastMove() {\n    setMoveNotice('');\n  }\n\n  const tree = useTree<FlatNode>({\n    initialState: {\n      expandedItems: OPEN_AT_START,\n      selectedItems: SELECTED_AT_START,\n    },\n    indent: INDENT,\n    rootItemId: REPOSITORY.id,\n    getItemName: nameOf,\n    isItemFolder: isFlatFolder,\n    canReorder: false,\n    onDrop: dropRows,\n    onStartKeyboardDrag: forgetLastMove,\n    dataLoader: { getItem: nodeOf, getChildren: sortedChildIdsOf },\n    features: [\n      syncDataLoaderFeature,\n      hotkeysCoreFeature,\n      selectionFeature,\n      dragAndDropFeature,\n      keyboardDragAndDropFeature,\n    ],\n  });\n  const dragStatus = dragStatusOf(tree);\n  const status = spokenStatusOf(dragStatus, moveNotice);\n\n  /* The tree lists its rows once and keeps the list: it reads the files again after every move, to sort them. */\n  useEffect(() => {\n    tree.rebuildTree();\n  }, [nodes, tree]);\n\n  return (\n    <div className='relative flex w-full flex-col gap-2'>\n      <Tree\n        aria-label={REPOSITORY.name}\n        aria-describedby={hintId}\n        indent={INDENT}\n        tree={tree}\n      >\n        <TreeRows>\n          {tree.getItems().map((item) => (\n            <TreeRow key={item.getId()} item={item} hasGuides travels>\n              <TreeItemLabel className={COMPACT_LABEL}>\n                {!item.isFolder() && <FileGlyph name={item.getItemName()} />}\n                <span className='truncate'>{item.getItemName()}</span>\n              </TreeItemLabel>\n            </TreeRow>\n          ))}\n        </TreeRows>\n      </Tree>\n      <p\n        id={hintId}\n        className='text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs'\n      >\n        Drop a file on a folder to move it, or press\n        <KbdGroup>\n          <Kbd>Ctrl</Kbd>\n          <Kbd>Shift</Kbd>\n          <Kbd>D</Kbd>\n        </KbdGroup>\n      </p>\n      <LiveRegion message={status} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/tree/tree-11.tsx"
    },
    {
      "path": "registry/ndeko/components/tree/_parts/drag-status.ts",
      "content": "import {\n  AssistiveDndState,\n  isOrderedDragTarget,\n  type DragTarget,\n  type ItemInstance,\n  type TreeInstance,\n} from '@headless-tree/core';\n\nfunction namesOf<T>(rows: ItemInstance<T>[]) {\n  const names = rows.map((row) => row.getItemName());\n  return names.join(', ');\n}\n\nfunction placeOf<T>(target: DragTarget<T>) {\n  const parentName = target.item.getItemName();\n  if (isOrderedDragTarget(target)) {\n    const position = target.childIndex + 1;\n    return `position ${position} in ${parentName}`;\n  }\n  return `inside ${parentName}`;\n}\n\nexport function moveNoticeOf<T>(\n  rows: ItemInstance<T>[],\n  target: DragTarget<T>\n) {\n  const names = namesOf(rows);\n  const parentName = target.item.getItemName();\n  return `Moved ${names} to ${parentName}`;\n}\n\n/* Only a drag from the keyboard is spoken: a drag with the pointer is already seen by the person doing it. */\nexport function dragStatusOf<T>(tree: TreeInstance<T>) {\n  const { dnd, assistiveDndState } = tree.getState();\n  const target = dnd?.dragTarget;\n  if (!target) {\n    return '';\n  }\n  const rows = dnd?.draggedItems ?? [];\n  const names = namesOf(rows);\n  const place = placeOf(target);\n  if (assistiveDndState === AssistiveDndState.Started) {\n    return `Picked up ${names}, ${place}. Arrow keys move, Enter drops, Escape cancels.`;\n  }\n  if (assistiveDndState === AssistiveDndState.Dragging) {\n    return `${names}, ${place}`;\n  }\n  return '';\n}\n\nexport function spokenStatusOf(dragStatus: string, moveNotice: string) {\n  if (dragStatus !== '') {\n    return dragStatus;\n  }\n  return moveNotice;\n}\n",
      "type": "registry:component",
      "target": "components/tree/_parts/drag-status.ts"
    },
    {
      "path": "registry/ndeko/components/tree/_parts/tree-data.ts",
      "content": "import {\n  isOrderedDragTarget,\n  type DragTarget,\n  type ItemInstance,\n} from '@headless-tree/core';\n\nexport type Branch = {\n  id: string;\n  name: string;\n  children?: Branch[];\n};\n\ntype BranchIndex = Record<string, Branch>;\n\nexport type FlatNode = {\n  name: string;\n  isFolder: boolean;\n  childIds: string[];\n};\n\nexport type FlatNodes = Record<string, FlatNode>;\n\nfunction idOf(branch: Branch) {\n  return branch.id;\n}\n\nfunction childIdsOf(branch: Branch) {\n  const children = branch.children ?? [];\n  return children.map(idOf);\n}\n\nfunction collectBranches(branch: Branch, index: BranchIndex) {\n  index[branch.id] = branch;\n  const children = branch.children ?? [];\n  children.forEach((child) => collectBranches(child, index));\n}\n\nexport function indexBranches(root: Branch) {\n  const index: BranchIndex = {};\n  collectBranches(root, index);\n  return index;\n}\n\nexport function branchLoaderOf(index: BranchIndex) {\n  function getItem(branchId: string) {\n    return index[branchId];\n  }\n\n  function getChildren(branchId: string) {\n    const branch = index[branchId];\n    return childIdsOf(branch);\n  }\n\n  return { getItem, getChildren };\n}\n\nexport function nameOf<Data extends { name: string }>(\n  item: ItemInstance<Data>\n) {\n  return item.getItemData().name;\n}\n\nexport function hasChildren(item: ItemInstance<Branch>) {\n  const children = item.getItemData().children ?? [];\n  return children.length > 0;\n}\n\nexport function leafIdsOf(index: BranchIndex) {\n  const branches = Object.values(index);\n  const leaves = branches.filter((branch) => branch.children === undefined);\n  return leaves.map(idOf);\n}\n\nexport function folderIdsOf(index: BranchIndex) {\n  const branches = Object.values(index);\n  const folders = branches.filter((branch) => branch.children !== undefined);\n  return folders.map(idOf);\n}\n\n/* A tree may list a checked id twice, so the ids are counted from the side that has each of them once. */\nexport function countIn(ids: string[], pickedIds: string[]) {\n  const picked = ids.filter((id) => pickedIds.includes(id));\n  return picked.length;\n}\n\nexport function parentIdsOf(index: BranchIndex) {\n  const parentIds: Record<string, string> = {};\n  for (const branch of Object.values(index)) {\n    for (const childId of childIdsOf(branch)) {\n      parentIds[childId] = branch.id;\n    }\n  }\n  return parentIds;\n}\n\nfunction flatNodeOf(branch: Branch): FlatNode {\n  const isFolder = branch.children !== undefined;\n  const childIds = childIdsOf(branch);\n  return { name: branch.name, isFolder, childIds };\n}\n\nexport function flattenBranches(root: Branch) {\n  const index = indexBranches(root);\n  const nodes: FlatNodes = {};\n  for (const [branchId, branch] of Object.entries(index)) {\n    nodes[branchId] = flatNodeOf(branch);\n  }\n  return nodes;\n}\n\nexport function isFlatFolder(item: ItemInstance<FlatNode>) {\n  return item.getItemData().isFolder;\n}\n\nfunction withoutIds(ids: string[], removedIds: string[]) {\n  return ids.filter((id) => !removedIds.includes(id));\n}\n\nfunction insertedAt(ids: string[], index: number, addedIds: string[]) {\n  const before = ids.slice(0, index);\n  const after = ids.slice(index);\n  return [...before, ...addedIds, ...after];\n}\n\nfunction slotOf(target: DragTarget<FlatNode>, siblingCount: number) {\n  if (isOrderedDragTarget(target)) {\n    return target.insertionIndex;\n  }\n  return siblingCount;\n}\n\n/** One update for the whole drop, so no render ever shows the moved rows missing from the tree. */\nexport function moveNodes(\n  nodes: FlatNodes,\n  movedIds: string[],\n  target: DragTarget<FlatNode>\n) {\n  const moved: FlatNodes = {};\n  for (const [nodeId, node] of Object.entries(nodes)) {\n    const childIds = withoutIds(node.childIds, movedIds);\n    moved[nodeId] = { ...node, childIds };\n  }\n  const parentId = target.item.getId();\n  const parent = moved[parentId];\n  const slot = slotOf(target, parent.childIds.length);\n  const childIds = insertedAt(parent.childIds, slot, movedIds);\n  moved[parentId] = { ...parent, childIds };\n  return moved;\n}\n",
      "type": "registry:component",
      "target": "components/tree/_parts/tree-data.ts"
    },
    {
      "path": "registry/ndeko/components/tree/_parts/tree-rows.tsx",
      "content": "'use client';\n\nimport type { useRender } from '@base-ui/react/use-render';\nimport type { ItemInstance } from '@headless-tree/core';\nimport { AnimatePresence, motion, useIsPresent } from 'motion/react';\nimport { createContext, useContext, useId, useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  EASE_IN_OUT_STRONG,\n  rowPresenceOf,\n  springOf,\n} from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { TreeItem } from '@/registry/ndeko/ui/tree';\n\n/*\n * The highlight is one element shared by every row of a tree. It glides to the row that mounts it\n * only when that row gives it another layout dependency than the row it left, hence the row id.\n */\nconst HighlightContext = createContext<string | undefined>(undefined);\n\nconst TRAVEL = { duration: 0.25, ease: EASE_IN_OUT_STRONG };\n\nconst NO_TRAVEL = { duration: 0 };\n\nconst FRAME = 'flex min-w-0 items-center gap-1.5';\n\nconst ITEM = 'relative min-w-0 flex-1 ps-(--row-indent) pb-0.5';\n\nconst HIGHLIGHT =\n  'bg-primary/[0.08] pointer-events-none absolute start-(--row-indent) end-0 top-0 bottom-0.5 -z-10 rounded-md';\n\n/* The highlight glides between rows, so the label stops drawing its own tint on a selected row. A search match keeps its colour. */\nconst LABEL_WITHOUT_TINT =\n  '[&[data-selected=true]:not([data-search-match=true])_[data-slot=tree-item-label]]:bg-transparent';\n\n/* One hairline per level of nesting, each under the icon of the folder it belongs to. */\nconst GUIDES = [\n  'bg-no-repeat [background-position:-0.25rem_0] [background-size:var(--row-indent)_100%]',\n  'bg-[repeating-linear-gradient(to_right,transparent_0_calc(var(--tree-indent)_-_var(--hairline)),var(--border)_calc(var(--tree-indent)_-_var(--hairline))_var(--tree-indent))]',\n].join(' ');\n\nfunction travelOf(shouldReduceMotion: boolean) {\n  if (shouldReduceMotion) {\n    return NO_TRAVEL;\n  }\n  return TRAVEL;\n}\n\nfunction layoutOf(travels: boolean) {\n  if (travels) {\n    return 'position' as const;\n  }\n  return false;\n}\n\n/* A tree set up without the selection feature keeps no list of selected rows. */\nfunction selectedIdsOf<T>(item: ItemInstance<T>) {\n  const { selectedItems } = item.getTree().getState();\n  return selectedItems ?? [];\n}\n\n/* A row that left the visible tree reports level -1 while it fades out: it keeps the indent it had. */\nfunction useShownLevel<T>(item: ItemInstance<T>) {\n  const { level } = item.getItemMeta();\n  const [shownLevel, setShownLevel] = useState(level);\n  const isInTree = level >= 0;\n  const hasMoved = level !== shownLevel;\n  if (isInTree && hasMoved) {\n    setShownLevel(level);\n    return level;\n  }\n  return shownLevel;\n}\n\ntype TreeRowsProps = {\n  children: React.ReactNode;\n};\n\n/* Rows that are there when the tree mounts do not animate: only the ones a folder opens or closes later do. */\nexport function TreeRows({ children }: TreeRowsProps) {\n  const highlightId = useId();\n\n  return (\n    <HighlightContext.Provider value={highlightId}>\n      <AnimatePresence initial={false}>{children}</AnimatePresence>\n    </HighlightContext.Provider>\n  );\n}\n\ntype TreeRowProps<T> = {\n  item: ItemInstance<T>;\n  children: React.ReactNode;\n  leading?: React.ReactNode;\n  trailing?: React.ReactNode;\n  render?: useRender.ComponentProps<'button'>['render'];\n  hasGuides?: boolean;\n  travels?: boolean;\n  className?: string;\n};\n\nexport function TreeRow<T>({\n  item,\n  children,\n  leading,\n  trailing,\n  render,\n  hasGuides = false,\n  travels = false,\n  className,\n}: TreeRowProps<T>) {\n  const highlightId = useContext(HighlightContext);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const isPresent = useIsPresent();\n  const [isResizing, setIsResizing] = useState(false);\n  const level = useShownLevel(item);\n  const presence = rowPresenceOf(shouldReduceMotion);\n  const travel = travelOf(shouldReduceMotion);\n  const transition = { ...presence.transition, layout: travel };\n  const glide = springOf(shouldReduceMotion);\n  const layout = layoutOf(travels);\n  const itemId = item.getId();\n  const position = item.getItemMeta().index;\n  const selectedIds = selectedIdsOf(item);\n  const isSelected = selectedIds.includes(itemId);\n  const isOnlySelected = isSelected && selectedIds.length === 1;\n  /* A row on its way out keeps a plain tint, so its removal never waits for the highlight to land elsewhere. */\n  const hasGlidingTint = isOnlySelected && isPresent;\n  const hasStillTint = isSelected && !hasGlidingTint;\n  const isClipped = isResizing || !isPresent;\n  const frameStyle = {\n    '--row-indent': `calc(var(--tree-indent) * ${level})`,\n  } as React.CSSProperties;\n\n  function startResizing() {\n    setIsResizing(true);\n  }\n\n  function stopResizing() {\n    setIsResizing(false);\n  }\n\n  return (\n    <motion.div\n      data-slot='tree-row'\n      style={frameStyle}\n      className={cn(FRAME, isClipped && 'overflow-hidden')}\n      layout={layout}\n      layoutDependency={position}\n      onAnimationStart={startResizing}\n      onAnimationComplete={stopResizing}\n      {...presence}\n      transition={transition}\n    >\n      {leading}\n      <TreeItem\n        item={item}\n        render={render}\n        className={cn(ITEM, LABEL_WITHOUT_TINT, hasGuides && GUIDES, className)}\n      >\n        {hasGlidingTint && (\n          <motion.span\n            layoutId={highlightId}\n            layoutDependency={itemId}\n            transition={glide}\n            aria-hidden='true'\n            className={HIGHLIGHT}\n          />\n        )}\n        {hasStillTint && <span aria-hidden='true' className={HIGHLIGHT} />}\n        {children}\n      </TreeItem>\n      {trailing}\n    </motion.div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/tree/_parts/tree-rows.tsx"
    }
  ],
  "categories": [
    "components",
    "tree"
  ],
  "type": "registry:component"
}