import { ChevronDown, ChevronRight, File, Folder, type LucideIcon, } from 'lucide-react'; import { Fragment, useEffect, useMemo, useState } from 'react'; import { useMediaQuery } from '@/hooks'; import { cn } from '@/lib/utils'; import { Button } from '../ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '../ui/command'; import { Modal } from '../ui/modal'; import { ancestorIds, filterNavigatorLeaves, findNode, type NavigatorNode, } from './navigatorUtils'; const NO_HIGHLIGHT = '__none__'; /** * Tree indent, before the row's icons: a 24px step per level through depth 4 * (96px), then 12px per further level, so deep rows still step in but keep * room for a label in the w-64 column. */ function Indent({ depth }: { depth: number }) { if (depth <= 0) return null; const width = Math.min(depth, 4) * 24 + Math.max(depth - 4, 0) * 12; return ( ); } /** The folders to expand to show a node: its ancestors, and itself if a folder. */ function revealIds(nodes: NavigatorNode[], id: string): string[] { const trail = ancestorIds(nodes, id); return findNode(nodes, id)?.kind === 'folder' ? [...trail, id] : trail; } export interface SourceNavigatorProps { nodes: NavigatorNode[]; /** The open leaf or folder. */ selectedId: string | null; onSelect: (node: NavigatorNode) => void; /** Placeholder and accessible name of the filter ("Filter files"). */ filterLabel: string; /** Shown when the filter matches nothing. */ emptyLabel: string; /** Names the phone picker button and its sheet ("Files", "Pages"). */ title: string; /** * `tree`: folders expand and collapse, and a folder can be opened (the file * tree). `groups`: each folder is a heading over its pages (the wiki). */ folderMode?: 'tree' | 'groups'; leafIcon?: LucideIcon; /** Layout only. */ className?: string; } /** * The navigator column of a source view: a filter field over the source's * files or pages, both in one Command, so the arrow keys walk from the field * into the list. The open item is `checked`. Below `lg` it collapses into a * combobox that opens the same list in a bottom sheet. */ export default function SourceNavigator({ nodes, selectedId, onSelect, filterLabel, emptyLabel, title, folderMode = 'tree', leafIcon: LeafIcon = File, className, }: SourceNavigatorProps) { const { isDesktop } = useMediaQuery(); const [query, setQuery] = useState(''); const [sheetOpen, setSheetOpen] = useState(false); // cmdk highlights its first row by default, which reads as hover; start the // highlight on the open item instead and let the keys and pointer move it. // With nothing open, a value that matches no row: an empty one lets cmdk // pick the first row again. const [highlight, setHighlight] = useState(selectedId ?? NO_HIGHLIGHT); useEffect(() => setHighlight(selectedId ?? NO_HIGHLIGHT), [selectedId]); const [expanded, setExpanded] = useState>( () => new Set(selectedId ? revealIds(nodes, selectedId) : []), ); // Opening an item elsewhere (a crumb, a table row, a search) reveals it in // the tree; an opened folder expands too. Other open folders stay open. useEffect(() => { if (!selectedId) return; const trail = revealIds(nodes, selectedId); if (trail.length === 0) return; setExpanded((prev) => trail.every((id) => prev.has(id)) ? prev : new Set([...prev, ...trail]), ); }, [nodes, selectedId]); const matches = useMemo( () => filterNavigatorLeaves(nodes, query), [nodes, query], ); const choose = (node: NavigatorNode) => { if (node.kind === 'folder' && folderMode === 'tree') { setExpanded((prev) => { const next = new Set(prev); if (next.has(node.id) && node.id === selectedId) next.delete(node.id); else next.add(node.id); return next; }); } onSelect(node); if (node.kind === 'leaf') { setQuery(''); setSheetOpen(false); } }; const leafItem = (node: NavigatorNode, depth: number, meta?: string) => ( choose(node)} title={node.path} > {node.label} {meta ? ( {meta} ) : null} ); const treeRows = (list: NavigatorNode[], depth: number): React.ReactNode => list.map((node) => { if (node.kind === 'leaf') return leafItem(node, depth); const open = expanded.has(node.id); const Chevron = open ? ChevronDown : ChevronRight; return ( choose(node)} aria-expanded={open} title={node.path} > {node.label} {node.count !== undefined ? ( {node.count} ) : null} {open ? treeRows(node.children ?? [], depth + 1) : null} ); }); const list = ( {/* The column caps its own list; in the phone sheet the Modal body scrolls, so the list grows with it (no second scroller). */} {query.trim() ? ( matches.length === 0 ? ( {emptyLabel} ) : ( {matches.map(({ node, parentPath }) => leafItem(node, 0, parentPath || undefined), )} ) ) : folderMode === 'groups' ? ( <> {nodes .filter((node) => node.kind === 'leaf') .map((node) => leafItem(node, 0))} {nodes .filter((node) => node.kind === 'folder') .map((folder) => ( {(folder.children ?? []).map((node) => leafItem(node, 0))} ))} ) : ( {treeRows(nodes, 0)} )} ); if (isDesktop) { return ( ); } const current = selectedId ? findNode(nodes, selectedId) : undefined; return ( <>
{list}
); }