Branch tree

A file-branch tree that expands in place on continuous 1px rails, with spring panels that retarget mid-flight and children that close only after their parent.

Show code
"use client";

import {
  animate,
  AnimatePresence,
  motion,
  PresenceContext,
  usePresence,
  useReducedMotion,
  type Transition,
} from "framer-motion";
import {
  useCallback,
  useContext,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

export type BranchNode = {
  /** Stable id. Open state is keyed on it, so derive it from a slug, never a label. */
  id: string;
  label: string;
  /** Plain lines, rendered as a rail-and-tick list one level deeper. */
  lines?: string[];
  children?: BranchNode[];
};

export type BranchContentContext = {
  depth: number;
  /** The node's siblings, in order, and the tree's open ids, so content can
   *  respond to what else is open (e.g. sibling-scale). */
  siblings: readonly BranchNode[];
  openIds: ReadonlySet<string>;
};

interface BranchTreeProps {
  nodes: BranchNode[];
  /** Open on first render. Children of an open node are not opened for you. */
  defaultOpenIds?: string[];
  /** Custom content for a node, rendered above its children inside the panel. */
  renderContent?: (node: BranchNode, context: BranchContentContext) => ReactNode;
  /** Shows the Expand / Collapse control in the top-right corner. */
  expandAll?: boolean;
  className?: string;
}

/**
 * A spring rather than a tween because opening a second branch mid-animation is
 * the common gesture here: a spring retargets from the current height with its
 * velocity intact, where a tween restarts a fresh curve and reads as a stutter.
 * bounce 0 because overshooting a height would clip the content.
 */
const panelSpring = { type: "spring", duration: 0.4, bounce: 0 } as const;

function collectIds(nodes: BranchNode[]): string[] {
  return nodes.flatMap((node) => [node.id, ...collectIds(node.children ?? [])]);
}

/** Every descendant id under each node, so collapsing a parent can clear them. */
function buildDescendantsMap(nodes: BranchNode[]): Map<string, string[]> {
  const map = new Map<string, string[]>();
  function walk(node: BranchNode): string[] {
    const descendants = (node.children ?? []).flatMap((child) => [child.id, ...walk(child)]);
    map.set(node.id, descendants);
    return descendants;
  }
  for (const node of nodes) walk(node);
  return map;
}

/** One <li>: a continuous 1px rail (a single box, so it cannot seam) and a 16px tick. */
function Item({ children }: { children: ReactNode }) {
  return (
    <li
      className={cn(
        "relative",
        "before:absolute before:top-0 before:bottom-0 before:z-[1] before:w-px before:bg-foreground",
        "last:before:bottom-auto last:before:h-[9px]",
        // Rail and tick share one expression, so they cannot drift apart.
        "before:left-[calc(var(--rail-inset)-var(--rail))]",
      )}
    >
      {children}
    </li>
  );
}

function Row({ children }: { children: ReactNode }) {
  return (
    <div
      className={cn(
        "relative z-[1] flex min-h-[18px] items-center",
        "before:absolute before:top-[9px] before:h-px before:w-4 before:bg-foreground",
        "before:left-[calc(var(--rail-inset)-var(--rail))]",
      )}
    >
      {children}
    </div>
  );
}

function List({ root, children }: { root?: boolean; children: ReactNode }) {
  return (
    <ul
      data-root={root ? "" : undefined}
      // minmax(0, 1fr): a grid track otherwise grows to its widest child, so a
      // wide media band would stretch the tree instead of scrolling inside it.
      className="relative m-0 grid list-none grid-cols-[minmax(0,1fr)] gap-0 p-0 pl-[var(--rail)]"
    >
      {children}
    </ul>
  );
}

/**
 * Text is never a block floating free of the rails: each line gets its own
 * tick. Lines are usually strings; any inline node (a loading bar) works too.
 */
export function BranchLines({ lines }: { lines: ReactNode[] }) {
  return (
    <List>
      {lines.map((line, index) => (
        <Item key={typeof line === "string" ? `${index}-${line}` : index}>
          <Row>
            <span className="min-w-0 leading-[18px] [overflow-wrap:anywhere]">{line}</span>
          </Row>
        </Item>
      ))}
    </List>
  );
}

type BranchProps = {
  nodes: BranchNode[];
  depth: number;
  openIds: Set<string>;
  onToggle: (id: string) => void;
  onExited: (id: string) => void;
  instant: boolean;
  renderContent?: BranchTreeProps["renderContent"];
};

function Branch(props: BranchProps) {
  const { nodes, depth } = props;
  return (
    <List root={depth === 0}>
      {nodes.map((node) => (
        <Node
          key={node.id}
          {...props}
          node={node}
          siblings={nodes}
        />
      ))}
    </List>
  );
}

/**
 * The animated panel. Height is measured with offsetHeight, which is layout
 * size, and driven through framer's animate(). Animating to `height: "auto"`
 * makes framer measure with getBoundingClientRect, which includes ancestor
 * transforms: under any scale() the spring ran to the scaled height and then
 * snapped to the real one when it switched back to auto, on every open and
 * close. Calling animate() on the element again mid-flight retargets from the
 * current height with its velocity, so a reversed panel never restarts.
 */
function Panel({
  id,
  transition,
  instant,
  children,
}: {
  id: string;
  transition: Transition;
  instant: boolean;
  children: ReactNode;
}) {
  const [isPresent, safeToRemove] = usePresence();
  // AnimatePresence initial={false}: panels open on first render don't animate in.
  const presence = useContext(PresenceContext);
  const [appear] = useState(() => presence?.initial !== false && !instant);
  const ref = useRef<HTMLDivElement>(null);
  const bodyRef = useRef<HTMLDivElement>(null);
  const run = useRef(0);

  useLayoutEffect(() => {
    const el = ref.current;
    const body = bodyRef.current;
    if (!el || !body) return;
    const token = ++run.current;
    const settled = el.style.height === "" || el.style.height === "auto";

    if (isPresent) {
      if (settled) return; // first paint at auto, nothing to animate
      animate(el, { height: body.offsetHeight }, transition).then(() => {
        // Back to auto so nested panels opening later can grow it.
        if (run.current === token) el.style.height = "auto";
      });
      return;
    }

    // Closing: from a settled auto height start at the real layout height;
    // mid-flight, retarget the running spring.
    const target = settled ? [el.offsetHeight, 0] : 0;
    animate(el, { height: target }, transition).then(() => {
      if (run.current === token) safeToRemove?.();
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per presence change only
  }, [isPresent]);

  useEffect(() => () => void run.current++, []);

  return (
    <div
      ref={ref}
      id={id}
      // Clip the block axis only, so the height animates while wide content
      // (a media band) can still escape sideways. Scroll anchoring off: it
      // keeps correcting the offset while one panel closes and another opens.
      className="-mx-1 overflow-x-visible overflow-y-clip px-1 [overflow-anchor:none]"
      style={appear ? { height: 0 } : undefined}
    >
      <div ref={bodyRef}>{children}</div>
    </div>
  );
}

function Node({
  node,
  depth,
  openIds,
  onToggle,
  onExited,
  instant,
  renderContent,
  siblings,
}: BranchProps & { node: BranchNode; siblings: BranchNode[] }) {
  const reduceMotion = useReducedMotion();
  const content = renderContent?.(node, { depth, siblings, openIds }) ?? null;
  const hasLines = Boolean(node.lines?.length);
  const isOpen = openIds.has(node.id);
  const panelId = `${node.id}-panel`;
  const transition = reduceMotion || instant ? { duration: 0 } : panelSpring;
  const skipMotion = Boolean(reduceMotion || instant);

  if (!content && !hasLines && !node.children?.length) {
    return (
      <Item>
        <Row>
          <span className="min-w-0 leading-[18px] [overflow-wrap:anywhere]">{node.label}</span>
        </Row>
      </Item>
    );
  }

  return (
    <Item>
      <Row>
        <button
          type="button"
          aria-expanded={isOpen}
          aria-controls={panelId}
          onClick={() => onToggle(node.id)}
          className={cn(
            "inline-flex cursor-pointer items-center gap-1 rounded-[2px] border-0 bg-transparent p-0",
            "font-medium leading-[18px] text-foreground [-webkit-tap-highlight-color:transparent]",
            "transition-colors hover:text-muted-foreground",
            "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
          )}
        >
          <span>{node.label}</span>
          <motion.span
            aria-hidden="true"
            className="inline-flex size-1.5 flex-none items-center justify-center text-foreground"
            initial={false}
            animate={{ rotate: isOpen ? 90 : 0 }}
            transition={transition}
          >
            <svg width="6" height="6" viewBox="0 0 8 8" fill="none" className="block">
              <path
                d="M2.5 1 L6 4 L2.5 7"
                stroke="currentColor"
                strokeWidth="1"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </motion.span>
        </button>
      </Row>

      <AnimatePresence initial={false} onExitComplete={() => onExited(node.id)}>
        {isOpen ? (
          <Panel id={panelId} transition={transition} instant={skipMotion}>
            {/* Flex column so child margins cannot collapse, and every open-state
                gap is padding on this inner box: the measured height is the
                border box, and the panel itself can still reach zero. */}
            <div
              className={cn(
                "flex flex-col",
                depth === 0 && "pb-[var(--gap)]",
                depth === 0 && !content && "pt-[var(--gap)]",
              )}
            >
              {content ? <div className="min-w-0 py-[var(--gap)]">{content}</div> : null}
              {hasLines ? <BranchLines lines={node.lines!} /> : null}
              {node.children?.length ? (
                <Branch
                  nodes={node.children}
                  depth={depth + 1}
                  openIds={openIds}
                  onToggle={onToggle}
                  onExited={onExited}
                  instant={instant}
                  renderContent={renderContent}
                />
              ) : null}
            </div>
          </Panel>
        ) : null}
      </AnimatePresence>
    </Item>
  );
}

/**
 * A file-branch tree that expands in place. One component owns open state (a
 * single Set of ids), so Expand / Collapse, descendant pruning and sibling
 * scaling all agree. It is not an accordion: any number of branches stay open.
 */
export function BranchTree({
  nodes,
  defaultOpenIds = [],
  renderContent,
  expandAll = true,
  className,
}: BranchTreeProps) {
  const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(defaultOpenIds));
  // Bulk Expand / Collapse changes every nested level at once; nested
  // height:auto animations would fight and snap, so that one change is instant.
  const [instant, setInstant] = useState(false);
  const allIds = useMemo(() => collectIds(nodes), [nodes]);
  const descendantsMap = useMemo(() => buildDescendantsMap(nodes), [nodes]);
  const allExpanded = allIds.length > 0 && allIds.every((id) => openIds.has(id));

  const onToggle = useCallback((id: string) => {
    setOpenIds((prev) => {
      const next = new Set(prev);
      // Closing removes only the node itself. Its descendants stay open until
      // the panel has finished exiting (onExited), so nested panels never snap.
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const onExited = useCallback(
    (id: string) => {
      setOpenIds((prev) => {
        if (prev.has(id)) return prev;
        const descendants = descendantsMap.get(id) ?? [];
        if (!descendants.some((d) => prev.has(d))) return prev;
        const next = new Set(prev);
        for (const d of descendants) next.delete(d);
        return next;
      });
    },
    [descendantsMap],
  );

  const onExpandCollapse = useCallback(() => {
    setInstant(true);
    setOpenIds(allExpanded ? new Set() : new Set(allIds));
  }, [allExpanded, allIds]);

  useEffect(() => {
    if (!instant) return;
    const frame = requestAnimationFrame(() => setInstant(false));
    return () => cancelAnimationFrame(frame);
  }, [instant, openIds]);

  return (
    <div
      className={cn("relative text-xs leading-4 text-foreground", className)}
      style={{ "--rail": "24px", "--rail-inset": "4px", "--gap": "4px" } as CSSProperties}
    >
      <Branch
        nodes={nodes}
        depth={0}
        openIds={openIds}
        onToggle={onToggle}
        onExited={onExited}
        instant={instant}
        renderContent={renderContent}
      />
      {expandAll ? (
        <button
          type="button"
          onClick={onExpandCollapse}
          className={cn(
            "absolute top-0 right-0 z-20 cursor-pointer rounded-[2px] border-0 bg-transparent p-0",
            "font-medium leading-[18px] text-foreground transition-colors hover:text-muted-foreground",
            "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
          )}
        >
          {allExpanded ? "Collapse" : "Expand"}
        </button>
      ) : null}
    </div>
  );
}