@exegia/corpora-ui 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  9. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  10. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  11. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
  13. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  14. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
  15. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
  16. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  17. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  18. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  19. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  20. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  21. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  22. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  23. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  24. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  25. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  26. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  27. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  28. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  29. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  30. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  31. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  32. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  33. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  34. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  35. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  36. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  37. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  38. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  39. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  40. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  41. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  42. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  43. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  44. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  45. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  46. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  47. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  48. package/dist-lib/components/user-avatar/type.d.ts +45 -0
  49. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  50. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  51. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  52. package/dist-lib/index.d.ts +5 -2
  53. package/dist-lib/index.js +3004 -1796
  54. package/dist-lib/index.js.map +1 -1
  55. package/dist-lib/state/exegia-provider.d.ts +51 -0
  56. package/dist-lib/state/index.d.ts +4 -0
  57. package/dist-lib/state/store.d.ts +17 -0
  58. package/package.json +15 -12
  59. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  60. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  61. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  62. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  63. package/src/components/blocks/auth/auth-state.ts +52 -0
  64. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  65. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  66. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  67. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  68. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  69. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  70. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  71. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  72. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  73. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  74. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  75. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  76. package/src/components/blocks/profile/index.ts +28 -0
  77. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  78. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  79. package/src/components/blocks/profile/type.ts +95 -0
  80. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  81. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  82. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  83. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  84. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  85. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  86. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  87. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  88. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  89. package/src/components/blocks/shell/index.ts +18 -0
  90. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  91. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  92. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  93. package/src/components/blocks/shell/type.ts +130 -3
  94. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  95. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  96. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  97. package/src/components/blocks/shell/utils.ts +44 -4
  98. package/src/components/composed/tree/CLAUDE.md +132 -0
  99. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  100. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  101. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  102. package/src/components/composed/tree/constants.ts +4 -4
  103. package/src/components/composed/tree/index.ts +38 -0
  104. package/src/components/composed/tree/tree-atom.ts +590 -0
  105. package/src/components/composed/tree/tree-node.tsx +117 -78
  106. package/src/components/composed/tree/tree.tsx +24 -16
  107. package/src/components/composed/tree/type.ts +125 -15
  108. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  109. package/src/components/composed/tree/use-tree-state.ts +105 -0
  110. package/src/components/composed/tree/use-tree.ts +185 -184
  111. package/src/components/composed/user-avatar.tsx +11 -99
  112. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  113. package/src/components/user-avatar/component.tsx +121 -22
  114. package/src/components/user-avatar/index.ts +19 -0
  115. package/src/components/user-avatar/presence-badge.tsx +56 -0
  116. package/src/components/user-avatar/type.ts +50 -0
  117. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  118. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  119. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  120. package/src/index.ts +34 -2
  121. package/src/state/exegia-provider.tsx +79 -0
  122. package/src/state/index.ts +4 -0
  123. package/src/state/store.ts +19 -0
@@ -3,6 +3,7 @@
3
3
  import { LucideChevronLeft, LucideChevronRight } from "lucide-react"
4
4
  import { AnimatePresence, motion, useReducedMotion } from "motion/react"
5
5
  import * as React from "react"
6
+ import { useAtomValue, useSetAtom } from "jotai"
6
7
 
7
8
  import { cn } from "@/lib/utils"
8
9
  import {
@@ -18,9 +19,27 @@ import {
18
19
  TREE_MICRO_TRANSITION,
19
20
  TREE_ROW_VARIANTS,
20
21
  } from "./constants"
22
+ import {
23
+ cancelTreeRenameAtom,
24
+ renameTreeNodeAtom,
25
+ selectTreeNodeAtom,
26
+ startTreeRenameAtom,
27
+ toggleTreeNodeAtom,
28
+ treeCanRenameAtom,
29
+ treeCollapsedAtom,
30
+ treeNodeActiveAtom,
31
+ treeNodeDraggingAtom,
32
+ treeNodeDropAtom,
33
+ treeNodeExpandedAtom,
34
+ treeNodeRenamingAtom,
35
+ treeSectionedAtom,
36
+ treeSoundAtom,
37
+ treeVariantAtom,
38
+ } from "./tree-atom"
21
39
  import { useTreeContext } from "./tree-context"
22
40
  import type { TreeNode } from "./type"
23
41
  import { EASE_OUT } from "@/lib/ease.ts"
42
+ import { Button } from "@/components/ui/button"
24
43
 
25
44
  /** How a row behaves, resolved from variant + depth + shape. */
26
45
  type RowKind =
@@ -48,71 +67,94 @@ function rowKindOf(
48
67
  return variant === "toc" ? "link" : "toggle"
49
68
  }
50
69
 
51
- export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
52
- const { tree: ctx, renderTrailing } = useTreeContext()
70
+ function TreeRowImpl({ node, depth }: TreeRowProps): React.ReactElement {
71
+ const { treeId, renderTrailing, dnd } = useTreeContext()
53
72
  const reduce = useReducedMotion()
54
- const kind = rowKindOf(node, depth, ctx.variant, ctx.sectioned)
73
+
74
+ // One subscription per field, per node. A row re-renders when its own
75
+ // branch, selection, rename or drag state changes and sits still while a
76
+ // sibling's does — reading the whole controller off context instead
77
+ // re-rendered every row in the tree on every toggle.
78
+ const variant = useAtomValue(treeVariantAtom(treeId))
79
+ const sectioned = useAtomValue(treeSectionedAtom(treeId))
80
+ const collapsed = useAtomValue(treeCollapsedAtom(treeId))
81
+ const canRename = useAtomValue(treeCanRenameAtom(treeId))
82
+ const sound = useAtomValue(treeSoundAtom(treeId))
83
+ const open = useAtomValue(treeNodeExpandedAtom(treeId, node.id))
84
+ const active = useAtomValue(treeNodeActiveAtom(treeId, node.id))
85
+ const renaming = useAtomValue(treeNodeRenamingAtom(treeId, node.id))
86
+ const dragging = useAtomValue(treeNodeDraggingAtom(treeId, node.id))
87
+ const drop = useAtomValue(treeNodeDropAtom(treeId, node.id))
88
+
89
+ const select = useSetAtom(selectTreeNodeAtom(treeId))
90
+ const toggleExpanded = useSetAtom(toggleTreeNodeAtom(treeId))
91
+ const startRename = useSetAtom(startTreeRenameAtom(treeId))
92
+ const cancelRename = useSetAtom(cancelTreeRenameAtom(treeId))
93
+ const rename = useSetAtom(renameTreeNodeAtom(treeId))
94
+
95
+ const kind = rowKindOf(node, depth, variant, sectioned)
55
96
 
56
97
  // The sidebar rail is single-level by contract — nested children are
57
98
  // ignored rather than rendered somewhere misleading.
58
- const children = ctx.variant === "sidebar" ? [] : (node.children ?? [])
99
+ const children = variant === "sidebar" ? [] : (node.children ?? [])
59
100
  const canExpand =
60
- kind !== "link" || (ctx.variant === "toc" && children.length > 0)
61
- const open = ctx.isExpanded(node.id)
62
- const active = ctx.activeId === node.id
63
- const renaming = ctx.renamingId === node.id
101
+ kind !== "link" || (variant === "toc" && children.length > 0)
64
102
 
65
- const { dnd } = ctx
66
- const dragging = dnd.draggedId === node.id
67
- const drop = dnd.dropTarget?.id === node.id ? dnd.dropTarget.position : null
103
+ const files = variant === "files"
68
104
 
69
- // toc rows below the route level scroll — they anchor to the heading
70
- // that shares their id (an explicit href always wins).
71
- const href =
72
- kind === "link" && !node.disabled
73
- ? (node.href ??
74
- (ctx.variant === "toc" && depth > 0 ? `#${node.id}` : undefined))
105
+ // toc rows below the route level are in-page anchors: they scroll to the
106
+ // heading sharing their id (an explicit `#hash` href wins). Rows are
107
+ // buttons, so the anchor default is reproduced by setting location.hash
108
+ // after `select` — the same order the old <a> gave (handlers first, then
109
+ // the browser's jump), with the same :target/history semantics.
110
+ const anchorHash =
111
+ variant === "toc" && kind === "link" && !node.disabled
112
+ ? node.href?.startsWith("#")
113
+ ? node.href
114
+ : depth > 0 && !node.href
115
+ ? `#${node.id}`
116
+ : undefined
75
117
  : undefined
76
118
 
77
- const files = ctx.variant === "files"
78
- const collapsed = ctx.collapsed
79
-
80
119
  function handlePress(event: React.MouseEvent) {
81
120
  if (node.disabled) {
82
121
  event.preventDefault()
83
122
  return
84
123
  }
85
124
  if (kind === "link") {
86
- ctx.select(node.id)
125
+ select(node.id)
126
+ if (anchorHash !== undefined && typeof window !== "undefined") {
127
+ window.location.hash = anchorHash
128
+ }
87
129
  return
88
130
  }
89
- ctx.toggleExpanded(node.id)
131
+ toggleExpanded(node.id)
90
132
  }
91
133
 
92
- function startRename(event: React.MouseEvent) {
93
- if (!ctx.canRename || node.disabled) return
134
+ function handleStartRename(event: React.MouseEvent) {
135
+ if (!canRename || node.disabled) return
94
136
  event.preventDefault()
95
- ctx.startRename(node.id)
137
+ startRename(node.id)
96
138
  }
97
139
 
98
140
  const label = renaming ? (
99
141
  <input
100
142
  autoFocus
101
143
  className={cn(
102
- "h-5 min-w-0 flex-1 rounded-sm border border-input bg-background px-1",
103
- "text-[length:inherit] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
144
+ "h-5 min-w-0 flex-1 rounded-sm border-none bg-transparent px-1",
145
+ "text-[length:inherit] text-foreground outline-none focus-visible:ring-0"
104
146
  )}
105
147
  data-slot="tree-rename-input"
106
148
  defaultValue={node.label}
107
- onBlur={(event) => ctx.rename(node.id, event.currentTarget.value)}
149
+ onBlur={(event) => rename(node.id, event.currentTarget.value)}
108
150
  onClick={(event) => event.stopPropagation()}
109
151
  onKeyDown={(event) => {
110
152
  event.stopPropagation()
111
- if (event.key === "Enter") ctx.rename(node.id, event.currentTarget.value)
112
- if (event.key === "Escape") ctx.cancelRename()
153
+ if (event.key === "Enter") rename(node.id, event.currentTarget.value)
154
+ if (event.key === "Escape") cancelRename()
113
155
  }}
114
156
  />
115
- ) : ctx.variant === "sidebar" ? (
157
+ ) : variant === "sidebar" ? (
116
158
  // The rail label folds away with the rail instead of unmounting rows.
117
159
  <AnimatePresence initial={false} mode="wait">
118
160
  <motion.span
@@ -176,7 +218,7 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
176
218
  ) : (
177
219
  <span
178
220
  className="min-w-0 flex-1 truncate"
179
- onDoubleClick={ctx.canRename ? startRename : undefined}
221
+ onDoubleClick={canRename ? handleStartRename : undefined}
180
222
  >
181
223
  {node.label}
182
224
  </span>
@@ -184,31 +226,31 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
184
226
 
185
227
  const chevron = canExpand ? (
186
228
  <motion.span
187
- animate={{ rotate: open ? (ctx.variant === "navigation" ? -90 : 90) : 0 }}
229
+ animate={{ rotate: open ? (variant === "navigation" ? -90 : 90) : 0 }}
188
230
  aria-hidden
189
231
  className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/70"
190
232
  transition={reduce ? { duration: 0 } : TREE_MICRO_TRANSITION}
191
233
  >
192
- {ctx.variant === "sidebar" && (
234
+ {variant === "sidebar" && (
193
235
  <LucideChevronLeft className="size-3.5 stroke-3" />
194
236
  )}
195
- {ctx.variant === "toc" && (
237
+ {variant === "toc" && (
196
238
  <LucideChevronRight className="size-3.5 stroke-3" />
197
239
  )}
198
- {ctx.variant === "navigation" && (
240
+ {variant === "navigation" && (
199
241
  <LucideChevronLeft className="size-3.5 stroke-3" />
200
242
  )}
201
243
  </motion.span>
202
244
  ) : null
203
245
 
204
246
  // The trailing slot is consumer-rendered and usually holds buttons, so it
205
- // cannot live inside the row — a files folder row is itself a <button>, and
206
- // a leaf row an <a>; either one nesting a button is invalid HTML. It sits
207
- // beside the row instead, overlaid like the toc toggle.
247
+ // cannot live inside the row — every row is itself a <button>, and a
248
+ // button nesting a button is invalid HTML. It sits beside the row
249
+ // instead, overlaid like the toc toggle.
208
250
  const showTrailing = files && !!renderTrailing && !renaming
209
251
 
210
252
  const rowClassName = cn(
211
- "group/row relative flex w-full min-w-0 items-center gap-2 rounded-md px-2",
253
+ "group/row relative flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2",
212
254
  "text-left text-muted-foreground transition-colors duration-150 outline-none",
213
255
  "hover:bg-accent hover:text-accent-foreground",
214
256
  "focus-visible:ring-2 focus-visible:ring-ring",
@@ -217,9 +259,9 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
217
259
  active && "bg-accent font-medium text-accent-foreground",
218
260
  dragging && "opacity-50",
219
261
  drop === "inside" && "bg-accent/60 ring-1 ring-primary/50 ring-inset",
220
- ctx.variant === "sidebar" && collapsed && "justify-center px-0",
262
+ variant === "sidebar" && collapsed && "justify-center px-0",
221
263
  // Room for the overlay expand toggle on toc parent rows.
222
- ctx.variant === "toc" && kind === "link" && children.length > 0 && "pr-7",
264
+ variant === "toc" && kind === "link" && children.length > 0 && "pr-7",
223
265
  // …and for the overlay trailing actions on files rows. Sized for one
224
266
  // icon button plus its inset; a wider slot clips the truncated label,
225
267
  // which is why the prop is documented as row actions, not free content.
@@ -232,7 +274,7 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
232
274
  "data-branch": canExpand ? "" : undefined,
233
275
  "data-expanded": canExpand && open ? "" : undefined,
234
276
  "data-active": active ? "" : undefined,
235
- "data-cuelume-press": ctx.sound ? "" : undefined,
277
+ "data-cuelume-press": sound ? "" : undefined,
236
278
  draggable: dnd.enabled && !renaming ? true : undefined,
237
279
  onDragStart: dnd.enabled
238
280
  ? (event: React.DragEvent) => dnd.onRowDragStart(event, node.id)
@@ -261,12 +303,11 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
261
303
  </span>
262
304
  )}
263
305
  {label}
264
- {node.badge !== undefined &&
265
- !(ctx.variant === "sidebar" && collapsed) && (
266
- <span className="ml-auto shrink-0 text-xs text-muted-foreground/80">
267
- {node.badge}
268
- </span>
269
- )}
306
+ {node.badge !== undefined && !(variant === "sidebar" && collapsed) && (
307
+ <span className="ml-auto shrink-0 text-xs text-muted-foreground/80">
308
+ {node.badge}
309
+ </span>
310
+ )}
270
311
  {/* nav toggle chevrons trail like the sidebar block; files lead;
271
312
  toc link rows get a separate overlay toggle instead. */}
272
313
  {kind === "toggle" && !files && (
@@ -289,10 +330,10 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
289
330
  {...rowInteractionProps}
290
331
  aria-expanded={open}
291
332
  className={cn(
292
- "group/row flex w-full items-center gap-1 rounded-md px-2 pt-1 pb-1",
333
+ "group/row flex w-full cursor-pointer items-center gap-1 rounded-md px-2 py-1",
293
334
  "text-xs font-medium tracking-wide text-muted-foreground/80 uppercase",
294
335
  "transition-colors duration-150 outline-none hover:text-foreground",
295
- "focus-visible:ring-2 focus-visible:ring-ring"
336
+ "focus-visible:ring-0 focus-visible:ring-ring"
296
337
  )}
297
338
  onClick={handlePress}
298
339
  type="button"
@@ -301,38 +342,30 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
301
342
  {chevron}
302
343
  </button>
303
344
  )
304
- } else if (href !== undefined) {
305
- row = (
306
- <a
307
- {...rowInteractionProps}
308
- aria-current={active ? "page" : undefined}
309
- aria-label={
310
- ctx.variant === "sidebar" && collapsed ? node.label : undefined
311
- }
312
- className={rowClassName}
313
- href={href}
314
- onClick={handlePress}
315
- target={node.target}
316
- >
317
- {content}
318
- </a>
319
- )
320
345
  } else {
346
+ // Every non-section row is the shared Button — link rows included. An
347
+ // `href` no longer renders an anchor: navigation runs through
348
+ // `selectTreeNodeAtom` (onSelect → onNavigate), and the consumer routes
349
+ // from the node it receives. `sound` is forwarded so Button's own
350
+ // press/release cues follow the tree's setting rather than its default.
321
351
  row = (
322
- <button
352
+ <Button
323
353
  {...rowInteractionProps}
354
+ variant={variant === "sidebar" && active ? "secondary" : "ghost"}
324
355
  aria-current={active ? "page" : undefined}
325
356
  aria-expanded={kind === "toggle" ? open : undefined}
326
- aria-label={
327
- ctx.variant === "sidebar" && collapsed ? node.label : undefined
328
- }
329
- className={rowClassName}
357
+ aria-label={variant === "sidebar" && collapsed ? node.label : undefined}
358
+ className={cn(
359
+ "transform cursor-pointer! justify-start transition-transform ease-smooth-out",
360
+ variant === "sidebar" && collapsed ? "h-10! rounded-xl!" : undefined,
361
+ rowClassName
362
+ )}
330
363
  disabled={node.disabled}
331
364
  onClick={handlePress}
332
- type="button"
365
+ sound={sound}
333
366
  >
334
367
  {content}
335
- </button>
368
+ </Button>
336
369
  )
337
370
  }
338
371
 
@@ -340,7 +373,7 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
340
373
  // Expanded, the visible label already does, so the tooltip is disabled
341
374
  // rather than unmounted: swapping the wrapper in and out with `collapsed`
342
375
  // would remount the row and cut the label's fold animation short.
343
- if (ctx.variant === "sidebar") {
376
+ if (variant === "sidebar") {
344
377
  row = (
345
378
  <Tooltip disabled={!collapsed || node.disabled}>
346
379
  <TooltipTrigger render={row} />
@@ -352,18 +385,18 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
352
385
  // toc parents navigate on the row itself, so their chevron lives beside
353
386
  // the row as its own toggle.
354
387
  const tocToggle =
355
- ctx.variant === "toc" && kind === "link" && children.length > 0 ? (
388
+ variant === "toc" && kind === "link" && children.length > 0 ? (
356
389
  <button
357
390
  aria-expanded={open}
358
391
  aria-label={`${open ? "Collapse" : "Expand"} ${node.label}`}
359
392
  className={cn(
360
- "absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center",
393
+ "absolute top-1/2 right-1 flex size-5 -translate-y-1/2 cursor-pointer items-center",
361
394
  "justify-center rounded-sm text-muted-foreground/70 outline-none",
362
395
  "hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
363
396
  )}
364
397
  onClick={(event) => {
365
398
  event.stopPropagation()
366
- ctx.toggleExpanded(node.id)
399
+ toggleExpanded(node.id)
367
400
  }}
368
401
  type="button"
369
402
  >
@@ -436,6 +469,12 @@ export function TreeRow({ node, depth }: TreeRowProps): React.ReactElement {
436
469
  )
437
470
  }
438
471
 
472
+ /** Rows are memoized so a re-render of the root — a rail measurement, a new
473
+ * `renderTrailing` — does not walk the whole tree. Each row's own state
474
+ * reaches it through its atoms instead. */
475
+ export const TreeRow = React.memo(TreeRowImpl)
476
+ TreeRow.displayName = "TreeRow"
477
+
439
478
  /** Insertion indicator above or below a row while a drag hovers it. */
440
479
  function TreeDropLine({ side }: { side: "top" | "bottom" }) {
441
480
  return (
@@ -19,6 +19,7 @@ import {
19
19
  TREE_EASE,
20
20
  } from "./constants"
21
21
  import { useTree } from "./use-tree"
22
+ import { useTreeDndHandlers } from "./use-tree-dnd"
22
23
  import { motion, useReducedMotion } from "motion/react"
23
24
 
24
25
  const DEFAULT_LABELS: Record<TreeController["variant"], string> = {
@@ -48,14 +49,16 @@ export function Tree(props: TreeProps): React.ReactElement {
48
49
  // Two components rather than one: hooks may not be called conditionally,
49
50
  // and the controller form has no props to build a fallback controller
50
51
  // from. Nobody switches a tree between the two forms at runtime.
51
- return props.tree
52
- ? <TreeView
53
- ariaLabel={props.ariaLabel}
54
- className={props.className}
55
- renderTrailing={props.renderTrailing}
56
- tree={props.tree}
57
- />
58
- : <UncontrolledTree {...props} />
52
+ return props.tree ? (
53
+ <TreeView
54
+ ariaLabel={props.ariaLabel}
55
+ className={props.className}
56
+ renderTrailing={props.renderTrailing}
57
+ tree={props.tree}
58
+ />
59
+ ) : (
60
+ <UncontrolledTree {...props} />
61
+ )
59
62
  }
60
63
 
61
64
  /** The props form: builds its own controller and renders through it. */
@@ -70,6 +73,7 @@ function UncontrolledTree(props: TreeDataProps): React.ReactElement {
70
73
  className,
71
74
  } = props
72
75
  const tree = useTree({
76
+ treeId: props.treeId,
73
77
  variant,
74
78
  items,
75
79
  activeId,
@@ -112,7 +116,7 @@ function TreeView({
112
116
  // as wide as whatever holds it, which can change on resize.
113
117
  //
114
118
  // A container with no width of its own sizes to the list — so it shrinks
115
- // around the 44px rail once collapsed and follows the list mid-tween. Both
119
+ // around the 40px rail once collapsed and follows the list mid-tween. Both
116
120
  // would feed the list's own width back in as its target and pin it. So:
117
121
  // no observing while collapsed (the last expanded width stands), and each
118
122
  // sample is taken with the list's inline width cleared, so the classes'
@@ -210,9 +214,13 @@ function TreeView({
210
214
  }
211
215
  }
212
216
 
217
+ // Rows take the tree's id and the stable drag handlers, never the
218
+ // controller — so this value keeps its identity and a toggle re-renders only
219
+ // the rows whose own atoms changed.
220
+ const dnd = useTreeDndHandlers(tree.treeId)
213
221
  const context = React.useMemo<TreeContextValue>(
214
- () => ({ tree, renderTrailing }),
215
- [tree, renderTrailing]
222
+ () => ({ treeId: tree.treeId, renderTrailing, dnd }),
223
+ [tree.treeId, renderTrailing, dnd]
216
224
  )
217
225
 
218
226
  const label = ariaLabel ?? DEFAULT_LABELS[variant]
@@ -220,15 +228,15 @@ function TreeView({
220
228
  <motion.ul
221
229
  aria-label={label}
222
230
  className={cn(
223
- "flex min-w-0 flex-col h-full",
231
+ "flex h-full min-w-0 flex-col",
224
232
  // Resting widths, and the fallback until the rail is measured. The
225
233
  // motion target below overrides these with an inline width.
226
- collapsed ? "w-11 gap-y-2" : "w-full",
234
+ collapsed ? "w-10 gap-y-2!" : "w-full",
227
235
  variant === "files" ? "gap-px" : sectioned ? "gap-3" : "gap-0.5"
228
236
  )}
229
237
  // Both endpoints must be px: motion cannot interpolate a number
230
- // against "100%", and animating 44 -> "100%" pinned the inline width
231
- // at 44px, so the rail collapsed once and never reopened. The
238
+ // against "100%", and animating 40 -> "100%" pinned the inline width
239
+ // at 40px, so the rail collapsed once and never reopened. The
232
240
  // expanded target is the rail's own measured width.
233
241
  initial={false}
234
242
  animate={railTarget}
@@ -266,7 +274,7 @@ function TreeView({
266
274
  ) : (
267
275
  <nav
268
276
  aria-label={label}
269
- className={cn("min-w-0 flex h-full justify-center", className)}
277
+ className={cn("flex h-full min-w-0 justify-center", className)}
270
278
  data-slot="tree-root"
271
279
  ref={railRef}
272
280
  >
@@ -8,8 +8,11 @@ export interface TreeNode {
8
8
  /** Leading icon. Required in practice for `sidebar` — it is all that
9
9
  * remains of a row while the rail is collapsed. */
10
10
  icon?: React.ReactNode
11
- /** Renders an anchor. Without one the row is a button; `toc` leaves
12
- * default to `#{id}` so they scroll to the matching heading. */
11
+ /** Route metadata for the consumer's `onNavigate` — every row renders as
12
+ * a button, so this is never turned into an anchor element. Read it (with
13
+ * `target`) off the node your handler receives. One native behaviour is
14
+ * kept: `toc` rows below the route level jump to `#{id}` after select
15
+ * (a `#hash` href overrides the id; a non-hash href disables the jump). */
13
16
  href?: string
14
17
  target?: "_blank" | "_self" | "_parent" | "_top"
15
18
  disabled?: boolean
@@ -27,14 +30,31 @@ export type TreeVariant = "navigation" | "toc" | "sidebar" | "files"
27
30
  /** Where a dragged row lands relative to its drop target. */
28
31
  export type TreeDropPosition = "before" | "after" | "inside"
29
32
 
33
+ /** Key for one tree’s state in the store. Any stable string; `useTree`
34
+ * generates one when you don't pass it. */
35
+ export type TreeInstanceId = string
36
+
37
+ /** Row a drag hovers, and where the drop would land. */
38
+ export interface TreeDropTarget {
39
+ id: string
40
+ position: TreeDropPosition
41
+ }
42
+
30
43
  interface TreeBaseProps {
31
44
  items: TreeNode[]
45
+ /** Key this tree's state under a name your app can address —
46
+ * `useTreeState("app-nav")` / `useTreeActions("app-nav")` reach it from
47
+ * anywhere under `ExegiaProvider`. Without one the tree generates a key
48
+ * and its state is dropped when it unmounts. */
49
+ treeId?: TreeInstanceId
32
50
  /** Never set on the data form — `tree` selects the controller form. */
33
51
  tree?: never
34
52
  /** `id` of the current entry — matches any depth. Its ancestors expand. */
35
53
  activeId?: string
36
- /** Fires for every selection (rows and leaves alike), after the row's
37
- * anchor default — wire your router's navigate here. */
54
+ /** Fires for every selection (rows and leaves alike), after the node's
55
+ * own `onSelect`. Rows are buttons, so this is the routing path — wire
56
+ * your router's navigate here. `toc` rows below the route level still jump
57
+ * to `#{id}` natively, after this fires. */
38
58
  onNavigate?: (node: TreeNode) => void
39
59
  /** Expand/collapse cues. Silent until `bindSounds()`. */
40
60
  sound?: boolean
@@ -72,12 +92,14 @@ export type TreeDataProps = TreeBaseProps &
72
92
  | ({
73
93
  /** Nested app navigation. With 3 levels of nodes the top level
74
94
  * becomes collapsible section names (styled as headings, not
75
- * links); with 2 it renders plain link rows. */
95
+ * rows); with 2 it renders plain selectable rows. */
76
96
  variant: "navigation"
77
97
  } & TreeReadonlyProps)
78
98
  | ({
79
- /** Table of contents: top-level nodes are routes, leaf nodes are
80
- * in-page anchors (`#{id}` unless the node carries an `href`). */
99
+ /** Table of contents: top-level nodes are routes (handled in
100
+ * `onNavigate`); rows below jump to `#{id}` after select, like an
101
+ * anchor would. Parents select on the row and expand from a
102
+ * separate overlay chevron. */
81
103
  variant: "toc"
82
104
  } & TreeReadonlyProps)
83
105
  | ({
@@ -106,6 +128,11 @@ export type TreeDataProps = TreeBaseProps &
106
128
  * controlled when passed and hook-owned when its `default*` twin is used
107
129
  * instead. */
108
130
  export interface UseTreeOptions {
131
+ /** Key this tree's state under a name your app can address —
132
+ * `useTreeState("app-nav")` / `useTreeActions("app-nav")` reach it from
133
+ * anywhere under `ExegiaProvider`. Without one the hook generates a key
134
+ * and the state is dropped when the component unmounts. */
135
+ treeId?: TreeInstanceId
109
136
  /** Which shape the tree takes — gates rename/reorder (`files`) and the
110
137
  * collapsible rail (`sidebar`). */
111
138
  variant: TreeVariant
@@ -143,6 +170,8 @@ export interface UseTreeOptions {
143
170
  /** Everything the tree can do, callable from outside the component.
144
171
  * Returned by `useTree` and accepted by `<Tree tree={…} />`. */
145
172
  export interface TreeController {
173
+ /** The key this tree's state is stored under. */
174
+ treeId: TreeInstanceId
146
175
  variant: TreeVariant
147
176
  items: TreeNode[]
148
177
  /** `navigation` with 3 levels of nodes — depth 0 renders as sections. */
@@ -187,26 +216,107 @@ export interface TreeController {
187
216
  canMove: boolean
188
217
  move: (id: string, parentId: string | null, index: number) => void
189
218
 
219
+ /** Back to the values the tree mounted with. */
220
+ reset: () => void
221
+
190
222
  dnd: TreeDndContextValue
191
223
  }
192
224
 
193
225
  /** @internal What a row needs from the root, threaded through context so
194
- * the recursive rows stay prop-light. */
226
+ * the recursive rows stay prop-light.
227
+ *
228
+ * Deliberately free of tree state. Rows read state from per-node atoms, so
229
+ * this value keeps its identity for the life of the tree and expanding one
230
+ * branch no longer re-renders every row through context. */
195
231
  export interface TreeContextValue {
196
- tree: TreeController
232
+ treeId: TreeInstanceId
197
233
  renderTrailing?: (node: TreeNode) => React.ReactNode
234
+ dnd: TreeDndHandlers
198
235
  }
199
236
 
200
- /** @internal Drag state shared by every row (all `null`/no-op outside
201
- * `files`). */
202
- export interface TreeDndContextValue {
237
+ /** @internal The drag event handlers, stable for the life of the tree —
238
+ * they read drag state out of the store instead of closing over it, so
239
+ * handing them to every row costs no re-renders. */
240
+ export interface TreeDndHandlers {
203
241
  enabled: boolean
204
- draggedId: string | null
205
- /** Row the pointer is over and where the drop would land. */
206
- dropTarget: { id: string; position: TreeDropPosition } | null
207
242
  onRowDragStart: (event: React.DragEvent, id: string) => void
208
243
  onRowDragOver: (event: React.DragEvent, node: TreeNode) => void
209
244
  onRowDragLeave: (event: React.DragEvent) => void
210
245
  onRowDrop: (event: React.DragEvent, node: TreeNode) => void
211
246
  onRowDragEnd: () => void
212
247
  }
248
+
249
+ /** @internal Handlers plus live drag state — what `TreeController.dnd`
250
+ * exposes (all `null`/no-op outside `files`). */
251
+ export interface TreeDndContextValue extends TreeDndHandlers {
252
+ draggedId: string | null
253
+ /** Row the pointer is over and where the drop would land. */
254
+ dropTarget: TreeDropTarget | null
255
+ }
256
+
257
+ /** Everything observable about one tree, for consumers reading it by id. */
258
+ export interface TreeState {
259
+ variant: TreeVariant
260
+ items: TreeNode[]
261
+ activeId?: string
262
+ expandedIds: ReadonlySet<string>
263
+ collapsed: boolean
264
+ renamingId: string | null
265
+ sectioned: boolean
266
+ sectionIds: string[]
267
+ canRename: boolean
268
+ canMove: boolean
269
+ draggedId: string | null
270
+ dropTarget: TreeDropTarget | null
271
+ }
272
+
273
+ /** Everything doable to one tree from outside its component. */
274
+ export interface TreeActions {
275
+ expand: (id: string) => void
276
+ collapse: (id: string) => void
277
+ toggleExpanded: (id: string) => void
278
+ expandAll: () => void
279
+ collapseAll: () => void
280
+ reveal: (id: string) => void
281
+ setCollapsed: (collapsed: boolean) => void
282
+ toggleCollapsed: () => void
283
+ select: (id: string) => void
284
+ startRename: (id: string) => void
285
+ cancelRename: () => void
286
+ rename: (id: string, label: string) => void
287
+ move: (id: string, parentId: string | null, index: number) => void
288
+ setItems: (items: TreeNode[]) => void
289
+ reset: () => void
290
+ }
291
+
292
+ /** @internal Projection of `useTree`'s options — primitives only, so the
293
+ * store write runs once per real change instead of once per render. */
294
+ export interface TreeConfig {
295
+ variant: TreeVariant
296
+ sound: boolean
297
+ controlsItems: boolean
298
+ controlsActiveId: boolean
299
+ controlsCollapsed: boolean
300
+ managesItems: boolean
301
+ hasRenameHandler: boolean
302
+ hasMoveHandler: boolean
303
+ }
304
+
305
+ /** @internal Latest option callbacks. Only write atoms read this, so it
306
+ * can be refreshed every commit without re-rendering anything. */
307
+ export interface TreeHandlers {
308
+ onNavigate?: (node: TreeNode) => void
309
+ onItemsChange?: (items: TreeNode[]) => void
310
+ onExpandedChange?: (ids: string[]) => void
311
+ onCollapsedChange?: (collapsed: boolean) => void
312
+ onRename?: (id: string, label: string) => void
313
+ onMove?: (id: string, parentId: string | null, index: number) => void
314
+ }
315
+
316
+ /** @internal What an instance starts from, replayed by `resetTreeAtom`. */
317
+ export interface TreeSeed {
318
+ items: TreeNode[]
319
+ activeId?: string
320
+ collapsed: boolean
321
+ expandedIds?: string[]
322
+ }