{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-hinge-fold",
  "title": "Tree Hinge Fold",
  "description": "Tree view whose branches unfold like a carpenter's folding rule: each child group is a hinged segment that swings open around a visible pivot, rows unfolding in sequence, and snaps shut faster than it opens.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/tree-hinge-fold/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\n\nexport type RuleNode = {\n  id: string;\n  label: string;\n  children?: RuleNode[];\n};\n\n// Tree whose branches unfold like a carpenter's folding rule: each child\n// group is a hinged segment that swings open around a visible pivot, rows\n// unfolding in sequence down the segment. Collapse snaps shut fast, no\n// stagger — a rule folds quicker than it opens.\nexport function FoldingRule({\n  nodes,\n  defaultExpanded = [],\n  className = \"\",\n}: {\n  /** root-level nodes of the tree */\n  nodes: RuleNode[];\n  /** ids expanded on first render */\n  defaultExpanded?: string[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const [expanded, setExpanded] = useState<Set<string>>(\n    () => new Set(defaultExpanded)\n  );\n  const [selected, setSelected] = useState<string | null>(null);\n  const [focused, setFocused] = useState<string>(() => nodes[0]?.id ?? \"\");\n  const treeRef = useRef<HTMLDivElement>(null);\n  const reduced = useRef(false);\n  useEffect(() => {\n    reduced.current = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n  }, []);\n\n  const toggle = useCallback((id: string) => {\n    setExpanded((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  }, []);\n\n  // the visible rows in document order, with parent links for Left-arrow\n  const visible: { node: RuleNode; parent: RuleNode | null }[] = [];\n  const walk = (list: RuleNode[], parent: RuleNode | null) => {\n    for (const node of list) {\n      visible.push({ node, parent });\n      if (node.children && expanded.has(node.id)) walk(node.children, node);\n    }\n  };\n  walk(nodes, null);\n\n  const focusItem = (id: string) => {\n    setFocused(id);\n    // roving tabindex — focus the row element directly\n    treeRef.current\n      ?.querySelector<HTMLElement>(`[data-rule-id=\"${CSS.escape(id)}\"]`)\n      ?.focus();\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const i = visible.findIndex((v) => v.node.id === focused);\n    if (i === -1) return;\n    const { node, parent } = visible[i];\n    let handled = true;\n    switch (e.key) {\n      case \"ArrowDown\":\n        if (i < visible.length - 1) focusItem(visible[i + 1].node.id);\n        break;\n      case \"ArrowUp\":\n        if (i > 0) focusItem(visible[i - 1].node.id);\n        break;\n      case \"ArrowRight\":\n        if (node.children?.length) {\n          if (!expanded.has(node.id)) toggle(node.id);\n          else focusItem(node.children[0].id);\n        }\n        break;\n      case \"ArrowLeft\":\n        if (node.children?.length && expanded.has(node.id)) toggle(node.id);\n        else if (parent) focusItem(parent.id);\n        break;\n      case \"Home\":\n        if (visible.length) focusItem(visible[0].node.id);\n        break;\n      case \"End\":\n        if (visible.length) focusItem(visible[visible.length - 1].node.id);\n        break;\n      case \"Enter\":\n      case \" \":\n        if (node.children?.length) toggle(node.id);\n        setSelected(node.id);\n        break;\n      default:\n        handled = false;\n    }\n    if (handled) e.preventDefault();\n  };\n\n  return (\n    <div\n      ref={treeRef}\n      role=\"tree\"\n      aria-label=\"File tree\"\n      className={`font-mono text-[13px] ${className}`}\n      onKeyDown={onKeyDown}\n    >\n      {nodes.map((node) => (\n        <Branch\n          key={node.id}\n          node={node}\n          depth={0}\n          expanded={expanded}\n          selected={selected}\n          focused={focused}\n          reduced={reduced}\n          onToggle={toggle}\n          onSelect={(id) => {\n            setSelected(id);\n            setFocused(id);\n          }}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction Branch({\n  node,\n  depth,\n  expanded,\n  selected,\n  focused,\n  reduced,\n  onToggle,\n  onSelect,\n}: {\n  node: RuleNode;\n  depth: number;\n  expanded: Set<string>;\n  selected: string | null;\n  focused: string;\n  reduced: { current: boolean };\n  onToggle: (id: string) => void;\n  onSelect: (id: string) => void;\n}) {\n  const hasChildren = !!node.children?.length;\n  const isOpen = hasChildren && expanded.has(node.id);\n  const isSelected = selected === node.id;\n\n  return (\n    <div\n      role=\"treeitem\"\n      aria-expanded={hasChildren ? isOpen : undefined}\n      aria-selected={isSelected}\n      data-rule-id={node.id}\n      tabIndex={focused === node.id ? 0 : -1}\n      onClick={(e) => {\n        e.stopPropagation();\n        if (hasChildren) onToggle(node.id);\n        onSelect(node.id);\n      }}\n      className=\"group/item cursor-pointer outline-none\"\n    >\n      <div\n        className={`flex items-center gap-1.5 rounded-sm px-2 py-1.5 transition-colors duration-150 group-focus-visible/item:outline-2 group-focus-visible/item:-outline-offset-2 group-focus-visible/item:outline-ns-accent ${\n          isSelected\n            ? \"bg-surface text-foreground\"\n            : \"text-ns-muted hover:bg-surface hover:text-foreground\"\n        }`}\n        style={{ paddingLeft: `${8 + depth * 20}px` }}\n      >\n        {hasChildren ? (\n          <svg\n            viewBox=\"0 0 12 12\"\n            aria-hidden=\"true\"\n            className=\"h-3 w-3 shrink-0 transition-transform duration-200 ease-out\"\n            style={{ transform: isOpen ? \"rotate(90deg)\" : \"rotate(0deg)\" }}\n          >\n            <path\n              d=\"M4.5 2.5 8 6l-3.5 3.5\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.4\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        ) : (\n          <span\n            aria-hidden=\"true\"\n            className=\"block h-1 w-1 shrink-0 rounded-full bg-border\"\n            style={{ marginLeft: 4, marginRight: 4 }}\n          />\n        )}\n        <span className=\"truncate\">{node.label}</span>\n      </div>\n\n      {hasChildren && (\n        <div\n          className=\"grid\"\n          style={{\n            gridTemplateRows: isOpen ? \"1fr\" : \"0fr\",\n            transition: reduced.current\n              ? \"none\"\n              : `grid-template-rows ${isOpen ? 360 : 200}ms cubic-bezier(0.22, 1, 0.36, 1)`,\n          }}\n        >\n          <div\n            className=\"min-h-0 overflow-hidden\"\n            style={{ perspective: \"700px\" }}\n          >\n            {/* the hinged segment: swings open around the joint at its top */}\n            <div\n              role=\"group\"\n              className=\"relative\"\n              style={{\n                transformOrigin: \"top center\",\n                transform: reduced.current\n                  ? \"none\"\n                  : isOpen\n                    ? \"rotateX(0deg)\"\n                    : \"rotateX(-58deg)\",\n                opacity: isOpen ? 1 : 0,\n                transition: reduced.current\n                  ? \"none\"\n                  : isOpen\n                    ? \"transform 360ms cubic-bezier(0.34, 1.4, 0.64, 1), opacity 200ms ease-out\"\n                    : \"transform 200ms cubic-bezier(0.55, 0, 0.85, 0.4), opacity 160ms ease-in\",\n              }}\n            >\n              {/* hinge pivot + segment edge rule — z-10 + pointer-events-none:\n                  each child row below sits in its own transform stacking\n                  context (for the row-stagger animation), which the transform\n                  spec promotes to an implicit z-index:0. Left at z-index:auto\n                  these decorative spans tied for that same paint layer and\n                  lost the DOM-order tiebreak to the (later) row wrappers, so\n                  a hovered row's opaque bg-surface painted over the line and\n                  visually severed it. Explicit z-index lifts them into the\n                  next paint layer, above every row's hover/selected fill. */}\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute bottom-1 top-0 z-10 w-px bg-border pointer-events-none\"\n                style={{ left: `${13 + depth * 20 + 8}px` }}\n              />\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute top-0.5 z-10 h-[5px] w-[5px] rounded-full border border-ns-muted bg-surface pointer-events-none\"\n                style={{ left: `${13 + depth * 20 + 6}px` }}\n              />\n              {node.children!.map((child, i) => (\n                <div\n                  key={child.id}\n                  style={\n                    reduced.current\n                      ? undefined\n                      : {\n                          transformOrigin: \"top center\",\n                          transform: isOpen\n                            ? \"rotateX(0deg)\"\n                            : \"rotateX(-28deg)\",\n                          opacity: isOpen ? 1 : 0,\n                          transition: isOpen\n                            ? `transform 300ms cubic-bezier(0.34, 1.4, 0.64, 1) ${60 + i * 45}ms, opacity 180ms ease-out ${60 + i * 45}ms`\n                            : \"transform 150ms ease-in, opacity 120ms ease-in\",\n                        }\n                  }\n                >\n                  <Branch\n                    node={child}\n                    depth={depth + 1}\n                    expanded={expanded}\n                    selected={selected}\n                    focused={focused}\n                    reduced={reduced}\n                    onToggle={onToggle}\n                    onSelect={onSelect}\n                  />\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/tree-hinge-fold.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "tree",
      "navigation",
      "hierarchy",
      "fold",
      "hinge",
      "keyboard"
    ],
    "instruction": "Build a tree view where expanding a node reads as a carpenter's folding rule swinging open a hinged segment. Each child group sits inside a grid-template-rows 0fr/1fr wrapper (transitioned 360 ms open / 200 ms close with ease-out-quint) so layout height animates for free, and the group itself lives under a 700px perspective and rotates from rotateX(-58deg) at transform-origin top to flat, with an overshooting back-out cubic-bezier(0.34, 1.4, 0.64, 1) so the segment lands with a slight bounce; individual child rows carry their own smaller rotateX(-28deg) staggered ~45 ms apart down the segment, so the branch unfolds row by row rather than as one slab. Collapse is deliberately asymmetric: faster (200 ms), ease-in, no stagger — a rule folds shut quicker than it opens. Each open segment draws its hinge as a small bordered pivot dot at the joint plus a 1px vertical rule down the segment's spine, positioned per depth so nesting reads as jointed segments, and leaf rows carry a 1px dot bullet instead of a chevron; the chevron rotates 90 degrees on expand. Full tree semantics: role=tree with an aria-label, role=treeitem rows carrying aria-expanded (only when the node has children) and aria-selected, children inside role=group, roving tabindex with ArrowUp/ArrowDown over the visible flattened order, ArrowRight expands then dives to first child, ArrowLeft collapses then climbs to the parent, Home/End jump the ends, Enter/Space toggles and selects. Selection is a token bg-surface fill with foreground text; hover matches; focus is a token accent outline. All colors from theme tokens only. Under prefers-reduced-motion every rotation and height transition is disabled — groups simply appear and disappear."
  },
  "type": "registry:ui"
}