{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tree-view","type":"registry:ui","title":"Tree view","description":"Navigate nested folders and structured content.","categories":["data"],"dependencies":["lucide-react","motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json"],"files":[{"path":"registry/components/tree-view/tree-view.tsx","type":"registry:component","target":"@components/silicon-ui/tree-view/tree-view.tsx","content":"\"use client\";\n\nimport { useId, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent, ReactNode } from \"react\";\nimport { ChevronRight, File, Folder, FolderOpen } from \"lucide-react\";\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { FluidHoverHighlight, useFluidHover } from \"../lib/fluid-hover\";\nimport styles from \"./tree-view.module.css\";\n\nexport interface TreeNode {\n  id: string;\n  label: string;\n  children?: TreeNode[];\n  icon?: ReactNode;\n}\n\nexport interface TreeViewProps {\n  nodes: TreeNode[];\n  defaultExpandedIds?: string[];\n  expandedIds?: string[];\n  onExpandedChange?: (expandedIds: string[]) => void;\n  onSelect?: (node: TreeNode) => void;\n  \"aria-label\"?: string;\n}\n\ntype VisibleNode = {\n  node: TreeNode;\n  depth: number;\n  parentId?: string;\n  position: number;\n  setSize: number;\n};\n\n/** A row is 36px plus the 2px gap above it; both collapse together, so nothing snaps when a row leaves. */\nconst rowHeight = 38;\nconst still = { duration: 0 } as const;\nconst fadeIn = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } as const;\nconst fadeOut = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } as const;\nconst blur = (px: number) => `blur(${px}px)`;\n\nfunction flatten(nodes: TreeNode[], expanded: ReadonlySet<string>, depth = 1, parentId?: string): VisibleNode[] {\n  return nodes.flatMap((node, index) => [\n    { node, depth, parentId, position: index + 1, setSize: nodes.length },\n    ...(node.children && expanded.has(node.id) ? flatten(node.children, expanded, depth + 1, node.id) : []),\n  ]);\n}\n\nfunction fileTone(label: string, classNames: Record<string, string>) {\n  const name = label.toLowerCase();\n  if (name.endsWith(\".tsx\") || name.endsWith(\".ts\")) return classNames.typescript;\n  if (name.endsWith(\".css\")) return classNames.stylesheet;\n  if (name.endsWith(\".md\")) return classNames.markdown;\n  if (name.endsWith(\".json\")) return classNames.json;\n  return classNames.file;\n}\n\n/** Same markup with or without reduced motion, so the server render always hydrates cleanly; only the transition changes. */\nfunction FolderIcon({ open, reduced }: { open: boolean; reduced: boolean }) {\n  return <AnimatePresence initial={false} mode=\"popLayout\">\n    <motion.span\n      key={open ? \"open\" : \"closed\"}\n      className={styles.folderGlyph}\n      initial={reduced ? false : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) }}\n      animate={{ opacity: 1, scale: 1, filter: blur(0) }}\n      exit={reduced ? { opacity: 0, transition: still } : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle), transition: fadeOut }}\n      transition={reduced ? still : { ...motionTokens.spring.snappy, opacity: fadeIn, filter: fadeIn }}\n    >{open ? <FolderOpen size={16} strokeWidth={1.7} /> : <Folder size={16} strokeWidth={1.7} />}</motion.span>\n  </AnimatePresence>;\n}\n\n/**\n * The selection fill glides between rows through a shared layoutId. A row that is leaving drops its fill at once: left\n * in place, it held the row's exit open, so the row stayed in the tree with pointer events off and the next click on it\n * after reopening its folder did nothing.\n */\nfunction Selection({ layoutId, reduced }: { layoutId: string; reduced: boolean }) {\n  const present = useIsPresent();\n  if (!present) return null;\n  return <motion.span aria-hidden=\"true\" layoutId={layoutId} className={styles.selection} transition={reduced ? still : motionTokens.spring.morph} />;\n}\n\nexport function TreeView({ nodes, defaultExpandedIds = [], expandedIds, onExpandedChange, onSelect, \"aria-label\": ariaLabel = \"File tree\" }: TreeViewProps) {\n  const reduced = useReducedMotion() ?? false;\n  const selectionLayoutId = `tree-selection-${useId()}`;\n  const [internalExpanded, setInternalExpanded] = useState(() => new Set(defaultExpandedIds));\n  const [focusedId, setFocusedId] = useState<string | null>(nodes[0]?.id ?? null);\n  const [selectedId, setSelectedId] = useState<string | null>(null);\n  /** The folder that opened last; its new children fade in one after another, then everything else stays still. */\n  const [openedId, setOpenedId] = useState<string | null>(null);\n  const refs = useRef(new Map<string, HTMLButtonElement>());\n  const tree = useRef<HTMLDivElement>(null);\n  // Fluid hover: the nearest row is lit, gaps and padding included, and keyboard focus moves the fill without travel.\n  const hover = useFluidHover(tree, { items: '[role=\"treeitem\"]' });\n  const expanded = expandedIds ? new Set(expandedIds) : internalExpanded;\n  const visible = flatten(nodes, expanded);\n  const focusableId = visible.some(({ node }) => node.id === focusedId) ? focusedId : (visible[0]?.node.id ?? null);\n  const openedIndex = visible.findIndex(({ node }) => node.id === openedId);\n\n  function setExpanded(next: Set<string>) {\n    if (!expandedIds) setInternalExpanded(next);\n    onExpandedChange?.([...next]);\n  }\n\n  function toggle(node: TreeNode) {\n    if (!node.children?.length) return;\n    const next = new Set(expanded);\n    if (next.has(node.id)) next.delete(node.id); else { next.add(node.id); setOpenedId(node.id); }\n    setExpanded(next);\n  }\n\n  function focus(id: string) {\n    setFocusedId(id);\n    const row = refs.current.get(id);\n    if (row) row.focus();\n    else requestAnimationFrame(() => refs.current.get(id)?.focus());\n  }\n\n  function select(node: TreeNode) {\n    setSelectedId(node.id);\n    onSelect?.(node);\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, item: VisibleNode, index: number) {\n    const { node } = item;\n    if (event.key === \"ArrowDown\") { event.preventDefault(); focus(visible[Math.min(index + 1, visible.length - 1)]!.node.id); return; }\n    if (event.key === \"ArrowUp\") { event.preventDefault(); focus(visible[Math.max(index - 1, 0)]!.node.id); return; }\n    if (event.key === \"Home\") { event.preventDefault(); focus(visible[0]!.node.id); return; }\n    if (event.key === \"End\") { event.preventDefault(); focus(visible[visible.length - 1]!.node.id); return; }\n    if (event.key === \"ArrowRight\" && node.children?.length) {\n      event.preventDefault();\n      if (!expanded.has(node.id)) toggle(node);\n      else if (visible[index + 1]?.parentId === node.id) focus(visible[index + 1]!.node.id);\n      return;\n    }\n    if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      if (node.children?.length && expanded.has(node.id)) toggle(node);\n      else if (item.parentId) focus(item.parentId);\n      return;\n    }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      select(node);\n      if (node.children?.length) toggle(node);\n    }\n  }\n\n  return <div ref={tree} className={styles.tree} role=\"tree\" aria-label={ariaLabel} aria-multiselectable=\"false\" {...hover.handlers}>\n    <FluidHoverHighlight hover={hover} className={styles.hover} />\n    <AnimatePresence initial={false} mode=\"sync\">\n      {visible.map((item, index) => {\n        const hasChildren = Boolean(item.node.children?.length);\n        const isExpanded = hasChildren && expanded.has(item.node.id);\n        const isSelected = selectedId === item.node.id;\n        // Only rows that mount use this delay: the space opens in one motion and the new children follow in order.\n        const delay = openedIndex >= 0 && index > openedIndex ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item : 0;\n        return <motion.div\n          key={item.node.id}\n          className={styles.row}\n          layout={reduced ? false : \"position\"}\n          initial={reduced ? false : { height: 0, opacity: 0, x: -6, overflow: \"hidden\" }}\n          animate={{ height: rowHeight, opacity: 1, x: 0, pointerEvents: \"auto\", transitionEnd: { overflow: \"visible\" }, transition: reduced ? still : { height: motionTokens.spring.smooth, opacity: { ...fadeIn, delay }, x: { ...motionTokens.spring.smooth, delay } } }}\n          exit={reduced ? { opacity: 0, transition: still } : { height: 0, opacity: 0, x: -4, overflow: \"hidden\", pointerEvents: \"none\", transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut } }}\n          transition={reduced ? still : { layout: motionTokens.spring.smooth }}\n        >\n          <button\n            ref={element => { if (element) refs.current.set(item.node.id, element); else refs.current.delete(item.node.id); }}\n            type=\"button\"\n            className={styles.item}\n            role=\"treeitem\"\n            aria-level={item.depth}\n            aria-posinset={item.position}\n            aria-setsize={item.setSize}\n            aria-expanded={hasChildren ? isExpanded : undefined}\n            aria-selected={isSelected}\n            tabIndex={focusableId === item.node.id ? 0 : -1}\n            style={{ \"--tree-depth\": item.depth } as CSSProperties}\n            onFocus={() => setFocusedId(item.node.id)}\n            onKeyDown={event => onKeyDown(event, item, index)}\n            onClick={() => { setFocusedId(item.node.id); select(item.node); if (hasChildren) toggle(item.node); }}\n          >\n            {isSelected && <Selection layoutId={selectionLayoutId} reduced={reduced} />}\n            {item.depth > 1 && <motion.span\n              aria-hidden=\"true\"\n              className={styles.branch}\n              initial={reduced ? false : { opacity: 0, scaleY: 0 }}\n              animate={{ opacity: 1, scaleY: 1 }}\n              transition={reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay }}\n            />}\n            <motion.span\n              className={styles.disclosure}\n              aria-hidden=\"true\"\n              initial={false}\n              animate={{ rotate: isExpanded ? 90 : 0 }}\n              transition={reduced ? still : motionTokens.spring.snappy}\n            >{hasChildren ? <ChevronRight size={14} strokeWidth={1.9} /> : null}</motion.span>\n            <span className={[styles.icon, hasChildren ? styles.folder : fileTone(item.node.label, styles)].join(\" \")} aria-hidden=\"true\">\n              {item.node.icon ?? (hasChildren ? <FolderIcon open={isExpanded} reduced={reduced} /> : <File size={16} strokeWidth={1.7} />)}\n            </span>\n            <span className={styles.label}>{item.node.label}</span>\n          </button>\n        </motion.div>;\n      })}\n    </AnimatePresence>\n  </div>;\n}\n\nexport default TreeView;\n"},{"path":"registry/components/tree-view/tree-view.module.css","type":"registry:component","target":"@components/silicon-ui/tree-view/tree-view.module.css","content":".tree { position: relative; display: block; width: 100%; min-width: 0; box-sizing: border-box; padding: var(--space-related) var(--space-related) var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); overflow: hidden; }\n/* Rows clip only while their height animates (Motion sets overflow), so the selection can glide between them unclipped. */\n.row { position: relative; display: flow-root; min-width: 0; height: 38px; }\n.item { position: relative; display: flex; width: 100%; min-width: 0; height: 36px; margin-top: var(--space-hair); align-items: center; gap: var(--space-related); padding: 0 var(--space-group); padding-left: calc(var(--space-group) + (var(--tree-depth) - 1) * var(--space-comfort)); border: 0; border-radius: var(--radius-detail); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); line-height: 1; text-align: left; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n/* Selection changes color only; a heavier weight would reflow the label. */\n.item[aria-selected=\"true\"] { color: var(--foreground); }\n/* One hover fill glides between rows, under the selection and the row content. */\n.hover { border-radius: var(--radius-detail); background: var(--surface-muted); }\n.selection { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-detail); background: var(--surface-muted); }\n.branch { position: absolute; top: var(--space-tight); bottom: var(--space-tight); left: calc(var(--space-inset) + (var(--tree-depth) - 2) * var(--space-comfort)); z-index: 1; width: 1px; background: var(--border); transform-origin: top; pointer-events: none; }\n.disclosure, .icon, .label { position: relative; z-index: 1; }\n.disclosure { display: grid; width: 14px; height: 16px; flex: 0 0 14px; place-items: center; color: var(--text-muted); }\n.icon { display: grid; width: 16px; height: 16px; flex: 0 0 16px; place-items: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }\n.folder { color: var(--text-secondary); }\n.item[aria-expanded=\"true\"] .folder, .item[aria-selected=\"true\"] .folder { color: var(--foreground); }\n.folderGlyph { position: absolute; inset: 0; display: grid; place-items: center; }\n.typescript { color: color-mix(in oklab, var(--file-typescript) 78%, var(--foreground)); }\n.stylesheet { color: color-mix(in oklab, var(--file-stylesheet) 73%, var(--foreground)); }\n.markdown { color: color-mix(in oklab, var(--file-markdown) 74%, var(--foreground)); }\n.json { color: color-mix(in oklab, var(--file-json) 78%, var(--foreground)); }\n/* Its own line height: the row sets 1, and the clipped box (needed for the ellipsis) would cut descenders like g. */\n.label { min-width: 0; overflow: hidden; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }\n@media (hover: hover) and (pointer: fine) { .item:hover { color: var(--foreground); } }\n@media (prefers-reduced-motion: reduce) { .item, .icon { transition: none; } }\n"},{"path":"lib/fluid-hover.tsx","type":"registry:lib","target":"@components/silicon-ui/lib/fluid-hover.tsx","content":"\"use client\";\n\n/**\n * Fluid hover: in a set of items (a menu, a list, tabs, a grid), the item nearest the pointer is lit, even while the\n * pointer is in a gap, the padding or past the last item, and one highlight springs between items. A click in a gap goes\n * to the lit item, so what is lit is what a click hits. A new pointer entry fades the highlight in where it lands instead\n * of sliding it over from the last item. The keyboard moves it without travel, and reduced motion keeps the fade but\n * drops the travel.\n *\n *   const list = useRef<HTMLUListElement>(null);\n *   const hover = useFluidHover(list, { items: \"[data-item]\" });\n *   <ul ref={list} className={styles.list} {...hover.handlers}>      // .list { position: relative }\n *     <FluidHoverHighlight hover={hover} className={styles.highlight} />\n *     …items…\n *   </ul>\n *\n * Adapted from Fluid Functionalism's use-fluid-hover and fluid-hover-highlight by Micka Touillaud (MIT),\n * https://github.com/mickadesign/fluid-functionalism. Items are found by a selector instead of registering themselves,\n * so a component adopts it without changing its items.\n */\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport type { FocusEvent, MouseEvent, PointerEvent, RefObject } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"./motion-tokens\";\n\n/** An item's box in the container's own layout space (unaffected by transforms or scrolling). */\nexport interface FluidRect { top: number; left: number; width: number; height: number }\n\nexport interface FluidHoverState {\n  /** Where the highlight sits, or null when nothing is lit. */\n  rect: FluidRect | null;\n  /** The lit item. */\n  item: HTMLElement | null;\n  /** False when the highlight should jump (keyboard, a resize) rather than travel. */\n  glide: boolean;\n  /** Steps on each new pointer entry, so the highlight fades in where it lands. */\n  session: number;\n}\n\nexport interface FluidHoverOptions {\n  /** Selector for the items inside the container. */\n  items: string;\n  /** \"y\" for lists (default), \"x\" for a row of tabs, \"xy\" for grids. */\n  axis?: \"x\" | \"y\" | \"xy\";\n  /** Items the highlight skips (and a gap click never activates). Defaults to disabled or aria-disabled items. */\n  isItemDisabled?: (item: HTMLElement) => boolean;\n  /** \"skip\" (default) lights the nearest enabled item even over a disabled one; \"clear\" lights nothing over a disabled item (a calendar's unavailable days). */\n  disabled?: \"skip\" | \"clear\";\n  /** Route a click in a gap to the lit item. On by default; turn it off where empty space should stay inert. */\n  gapClick?: boolean;\n  /** Light the item that receives keyboard focus. On by default. */\n  followFocus?: boolean;\n}\n\nconst defaultDisabled = (item: HTMLElement) =>\n  item.matches(\":disabled, [aria-disabled='true'], [data-disabled]\");\n\n/** Controls that sit between items keep their own clicks. */\nconst CONTROL = \"input, textarea, select, button, a, summary, [contenteditable], [role='textbox'], [role='searchbox'], [role='button']\";\n\n/** The item's box relative to the container, summing offsets through positioned ancestors in between. */\nexport function rectOf(item: HTMLElement, container: HTMLElement): FluidRect {\n  let top = item.offsetTop, left = item.offsetLeft;\n  let parent = item.offsetParent as HTMLElement | null;\n  while (parent && parent !== container && container.contains(parent)) {\n    top += parent.offsetTop + parent.clientTop;\n    left += parent.offsetLeft + parent.clientLeft;\n    parent = parent.offsetParent as HTMLElement | null;\n  }\n  return { top, left, width: item.offsetWidth, height: item.offsetHeight };\n}\n\n/**\n * The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not\n * centres, so a gap next to a wide item still lights the item the pointer is closer to.\n */\nexport function pickNearest(items: HTMLElement[], x: number, y: number, axis: \"x\" | \"y\" | \"xy\"): HTMLElement | null {\n  let nearest: HTMLElement | null = null, best = Infinity;\n  for (const item of items) {\n    const r = item.getBoundingClientRect();\n    if (!r.width && !r.height) continue;\n    if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return item;\n    const dx = Math.max(r.left - x, 0, x - r.right), dy = Math.max(r.top - y, 0, y - r.bottom);\n    const distance = axis === \"x\" ? dx : axis === \"y\" ? dy : Math.hypot(dx, dy);\n    if (distance < best) { best = distance; nearest = item; }\n  }\n  return nearest;\n}\n\n/** Hands a gap click to the item as a real mouse click (detail, modifiers and coordinates kept), so the item reads it as a pointer press, not a keyboard one. */\nfunction forwardClick(item: HTMLElement, event: MouseEvent<HTMLElement>) {\n  const { detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons } = event;\n  item.dispatchEvent(new globalThis.MouseEvent(\"click\", { bubbles: true, cancelable: true, composed: true, view: window, detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons }));\n}\n\n/** Whether an event happened inside the element whose handler hears it, not in a portalled child (a submenu, a tooltip). */\nconst isOwn = (event: { currentTarget: EventTarget; target: EventTarget }) =>\n  !(event.currentTarget instanceof Node) || !(event.target instanceof Node) || event.currentTarget.contains(event.target);\n\nexport function useFluidHover<T extends HTMLElement>(container: RefObject<T | null>, options: FluidHoverOptions) {\n  const { items: selector, axis = \"y\", isItemDisabled = defaultDisabled, disabled = \"skip\", gapClick = true, followFocus = true } = options;\n  const [state, setState] = useState<FluidHoverState>({ rect: null, item: null, glide: false, session: 0 });\n  const current = useRef(state);\n  const pointer = useRef(false);\n  const entering = useRef(false);\n  const frame = useRef(0);\n\n  function commit(next: FluidHoverState) {\n    const prev = current.current;\n    if (prev.item === next.item && prev.session === next.session && sameRect(prev.rect, next.rect)) return;\n    current.current = next;\n    setState(next);\n  }\n\n  function light(item: HTMLElement | null, glide: boolean) {\n    const root = container.current;\n    if (!root || !item) return commit({ ...current.current, rect: null, item: null, glide });\n    let session = current.current.session;\n    if (entering.current) { entering.current = false; session += 1; }\n    commit({ rect: rectOf(item, root), item, glide: glide && session === current.current.session, session });\n  }\n\n  const enabledItems = () => {\n    const root = container.current;\n    return root ? [...root.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item)) : [];\n  };\n\n  // The lit item can move under a still highlight (a resize, content above it changing): follow it without travel.\n  const follow = useEffectEvent(() => { const { item } = current.current; if (item?.isConnected) light(item, false); });\n  useEffect(() => {\n    const root = container.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => follow());\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [container]);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const handlers = {\n    onMouseEnter: () => { entering.current = true; },\n    onMouseMove: (event: MouseEvent<T>) => {\n      if (!isOwn(event)) return;\n      pointer.current = true;\n      const { clientX, clientY } = event;\n      cancelAnimationFrame(frame.current);\n      frame.current = requestAnimationFrame(() => {\n        const over = disabled === \"clear\" ? document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>(selector) : null;\n        light(over && isItemDisabled(over) ? null : pickNearest(enabledItems(), clientX, clientY, axis), true);\n      });\n    },\n    onMouseLeave: () => { cancelAnimationFrame(frame.current); entering.current = false; light(null, false); },\n    onKeyDownCapture: () => { pointer.current = false; },\n    onFocus: (event: FocusEvent<T>) => {\n      if (!followFocus || pointer.current || !isOwn(event)) return;\n      const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(selector) : null;\n      if (item && container.current?.contains(item) && !isItemDisabled(item)) light(item, false);\n    },\n    onBlur: (event: FocusEvent<T>) => {\n      if (!pointer.current && !event.currentTarget.contains(event.relatedTarget as Node | null)) light(null, false);\n    },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick || !isOwn(event)) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const { item } = current.current;\n      if (item?.isConnected && !isItemDisabled(item)) forwardClick(item, event);\n    },\n  };\n\n  /** Clears the highlight at once, such as when a menu opens again. */\n  const reset = () => { cancelAnimationFrame(frame.current); entering.current = true; light(null, false); };\n\n  return { ...state, handlers, reset, light };\n}\n\nexport interface NearestItemOptions extends Omit<FluidHoverOptions, \"followFocus\"> {\n  /** Called with the nearest enabled item as the mouse moves over the container. */\n  onPick: (item: HTMLElement) => void;\n}\n\n/**\n * Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox\n * with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a\n * click in a gap goes to that item. Spread the result on the container.\n */\n// A hook only so callbacks that touch refs can be passed during render; it keeps no state.\nexport function useNearestItem<T extends HTMLElement>({ items: selector, axis = \"y\", isItemDisabled = defaultDisabled, gapClick = true, onPick }: NearestItemOptions) {\n  const nearest = (event: MouseEvent<T>) => {\n    if (!isOwn(event)) return null;\n    const items = [...event.currentTarget.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item));\n    return pickNearest(items, event.clientX, event.clientY, axis);\n  };\n  return {\n    onPointerMove: (event: PointerEvent<T>) => { if (event.pointerType !== \"mouse\") return; const item = nearest(event); if (item) onPick(item); },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const item = nearest(event);\n      if (item) forwardClick(item, event);\n    },\n  };\n}\n\nconst sameRect = (a: FluidRect | null, b: FluidRect | null) =>\n  a === b || (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height);\n\nexport interface FluidHoverHighlightProps {\n  /** The state from useFluidHover (or any { rect, glide, session }). */\n  hover: Pick<FluidHoverState, \"rect\" | \"glide\" | \"session\">;\n  /** Radius, fill, z-index. The container must be position: relative; the highlight is placed at its top-left corner. */\n  className?: string;\n  /** Extra attributes for styling, such as data-tone. */\n  tone?: string;\n}\n\n/** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */\nexport function FluidHoverHighlight({ hover, className, tone }: FluidHoverHighlightProps) {\n  const reduced = useReducedMotion() ?? false;\n  const { rect, glide, session } = hover;\n  const place = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : undefined;\n  return (\n    <AnimatePresence initial={false}>\n      {place ? (\n        <motion.span key={session} className={className} data-tone={tone} aria-hidden=\"true\"\n          style={{ position: \"absolute\", top: 0, left: 0, pointerEvents: \"none\" }}\n          initial={{ opacity: 0, ...place }}\n          animate={{ opacity: 1, ...place }}\n          exit={{ opacity: 0, transition: motionTokens.spring.fast.exit }}\n          transition={{ default: glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: motionTokens.duration.fast } }} />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/tree-view","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/tree-view","markdown":"https://ui.teamofsilicons.com/tree-view.md","tags":["data","navigation"],"status":"stable"}}