import { useEffect, useRef, type MouseEvent, type ReactNode } from 'react' import { CollapsibleSection } from './CollapsibleSection' import { EmptyState } from './EmptyState' import { NavItem } from './NavItem' import { SectionHeader, type SectionAction } from './SectionHeader' import { SkeletonList } from './Skeleton' /** One row of the tree, and the rows under it. */ export interface NavTreeNode { id: string label: string href: string /** 16px, stroke 1.5. */ icon?: ReactNode /** `0` or absent renders no counter. */ count?: number /** What the count counts, for the tooltip. */ countLabel?: string /** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */ hint?: ReactNode /** Something new here: NavItem's `unread`. */ unread?: boolean /** With `unread`: the warning badge in the dot's place. */ urgent?: boolean /** The row's "More options" menu: `MenuItem`s. */ menu?: ReactNode /** * Rows under this one. `null` while they are being read: the row has its * arrow, and a skeleton under it once open. Absent with `hasChildren`, the * same — for rows read only when the row opens. */ children?: NavTreeNode[] | null /** The row has rows under it that are not read yet: it gets its arrow, and `onOpenChange` says when to read them. */ hasChildren?: boolean /** Said under the row, level with its rows, in place of them: "Nothing in this yet.", "This could not be read." The caller's words. */ childrenMessage?: string defaultOpen?: boolean /** The caller owns whether its rows are open. */ open?: boolean onOpenChange?: (open: boolean) => void } /** A group heading over its rows: the top of the tree. */ export interface NavTreeGroup { id: string title: string /** The rows. `null` while they are being read: a skeleton in their place. */ nodes: NavTreeNode[] | null /** Said in the rows' place, level with them: "Nothing in this yet.", "This could not be read." The caller's words. */ message?: string /** Beside the title, always shown: an `UnreadDot`, typically. */ trailing?: ReactNode /** Beside the title, shown while the row is hovered or keyboard-focused: "Open", typically. */ actions?: SectionAction[] /** The group's "More options" menu, after its actions: `MenuItem`s — a folder's Rename, Archive. */ menu?: ReactNode /** 16px, before the title — a folder's; the arrow takes its place on hover. */ icon?: ReactNode defaultOpen?: boolean /** The caller owns whether the group is open. */ open?: boolean onOpenChange?: (open: boolean) => void /** * The pointer rests on the group, or the keyboard reaches it: read its rows * now, so opening it draws them. Resting, not passing — a pointer sweeping * down the column calls nothing. */ onIntent?: () => void } export interface NavTreeProps { /** The quiet label over the groups. Absent, none is drawn. */ title?: string /** Beside the label, always shown: "New", typically. */ titleActions?: SectionAction[] /** * `null` while the first read is on its way: a skeleton in the groups' place. * Absent when the app draws its own groups as `children`. */ groups?: NavTreeGroup[] | null /** * The groups, drawn by the app: `NavTreeSection`s with `NavItem` rows — for * an app whose groups and rows each read their own data. Used when `groups` * is absent. */ children?: ReactNode /** The row that is current: `active`, and its group starts open. */ selected?: string /** A row was clicked. A router app prevents the default and navigates. */ onSelect?: (node: NavTreeNode, event: MouseEvent) => void /** What to say when `groups` is empty. The words are the caller's; absent, nothing is said. */ emptyMessage?: string /** Remember each group open or closed in this browser, under this key and the group's id. The app prefixes it. */ storageKey?: string } /** How long the pointer rests on a group before `onIntent` (Peek's FolderTree, 150ms). */ export const NAV_TREE_INTENT_MS = 150 function holds(nodes: NavTreeNode[] | null, id: string | undefined): boolean { return !!nodes && id !== undefined && nodes.some((node) => node.id === id || holds(node.children ?? null, id)) } /** A line in the rows' place: level with the rows, which pad themselves (Katerina, 14 September). */ function Line({ message }: { message: string }) { return (
) } function Row({ node, selected, onSelect }: { node: NavTreeNode } & Pick) { const under = node.childrenMessage !== undefined ? ( ) : node.children?.length ? ( node.children.map((child) => ) ) : node.children === null || (node.hasChildren && node.children === undefined) ? ( ) : undefined return ( onSelect(node, event) : undefined} > {under} ) } function Group({ group, selected, onSelect, storageKey }: { group: NavTreeGroup } & Pick) { return ( {group.nodes?.map((node) => )} ) } export interface NavTreeSectionProps { title: string /** Marks the group's box as `data-nav-group`, for tests and photo runs to find it. */ id?: string /** Said in the rows' place, level with them. The caller's words. */ message?: string /** Beside the title, always shown: an `UnreadDot`, typically. */ trailing?: ReactNode /** Beside the title, shown while the row is hovered or keyboard-focused: "Open", typically. */ actions?: SectionAction[] /** The group's "More options" menu, after its actions: `MenuItem`s. */ menu?: ReactNode /** 16px, before the title; the arrow takes its place on hover. */ icon?: ReactNode /** Open unless told otherwise. */ defaultOpen?: boolean /** The caller owns whether the group is open. */ open?: boolean onOpenChange?: (open: boolean) => void /** The pointer rests on the group, or the keyboard reaches it: read its rows now. */ onIntent?: () => void /** Its rows are being read: a skeleton in their place. */ loading?: boolean /** * Opened, and its rows are being read: the arrow turns and the group stays * shut until they are in, then opens once — `CollapsibleSection`'s. For a * read that is usually quick; a slow one is `loading`'s skeleton. */ pending?: boolean /** Remember it open or closed in this browser, under this key. */ storageKey?: string /** The rows: `NavItem`s. */ children?: ReactNode } /** * One group of a `NavTree`, for an app that draws its own: the standard * heading folding its rows, lightly indented under its title, with the * group's line, skeleton, actions and `onIntent`. `NavTree` draws these from * `groups`; an app whose groups each read their own data draws them itself, * as `NavTree`'s children. */ export function NavTreeSection({ id, title, message, loading = false, pending, trailing, actions, menu, icon, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) { const timer = useRef | undefined>(undefined) useEffect(() => () => clearTimeout(timer.current), []) const intent = onIntent return (
{ clearTimeout(timer.current); timer.current = setTimeout(intent, NAV_TREE_INTENT_MS) } : undefined} onPointerLeave={intent ? () => clearTimeout(timer.current) : undefined} onFocus={intent} > {message !== undefined ? : loading ? : children}
) } /** * A sidebar's tree, from one nested list (Katerina, 2 October: Leaf's folder * tree, then Peek's and Ship's): a quiet label with its actions, then each * group as the standard heading folding its rows, lightly indented under its * title; rows under rows to any depth, each row a link whose icon becomes the * arrow that folds what is under it. Groups and rows can be read when they * open. Only the group that holds the current row starts open, unless the * caller owns it. */ export function NavTree({ title, titleActions, groups, children, selected, onSelect, emptyMessage = '', storageKey }: NavTreeProps) { return ( <> {title && } {groups === undefined ? ( children ) : groups === null ? ( ) : groups.length === 0 ? ( // No words, no line: an app that says nothing when it has no groups. emptyMessage ? : null ) : ( groups.map((group) => ) )} ) }