@exegia/corpora-ui 0.20.0 → 0.22.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 (165) hide show
  1. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  2. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  3. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  4. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  5. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  6. package/dist-lib/components/blocks/layout.d.ts +4 -1
  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 -12
  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/__tests__/logo.test.d.ts +1 -0
  35. package/dist-lib/components/composed/logo.d.ts +34 -0
  36. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  37. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  38. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  39. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  40. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  41. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  42. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  43. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  44. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  45. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  46. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  47. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  48. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  49. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  50. package/dist-lib/components/user-avatar/type.d.ts +53 -0
  51. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  52. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
  53. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
  54. package/dist-lib/components/user-avatar/utils.d.ts +22 -0
  55. package/dist-lib/index.d.ts +6 -3
  56. package/dist-lib/index.js +3732 -2670
  57. package/dist-lib/index.js.map +1 -1
  58. package/dist-lib/state/exegia-provider.d.ts +51 -0
  59. package/dist-lib/state/index.d.ts +4 -0
  60. package/dist-lib/state/store.d.ts +17 -0
  61. package/package.json +15 -12
  62. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  63. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  64. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  65. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  66. package/src/components/blocks/auth/auth-state.ts +52 -0
  67. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  68. package/src/components/blocks/layout.ts +30 -2
  69. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  70. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  71. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  72. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  73. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  74. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  75. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  76. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  77. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  78. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  79. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  80. package/src/components/blocks/profile/index.ts +28 -0
  81. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  82. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  83. package/src/components/blocks/profile/type.ts +95 -0
  84. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  85. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  86. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  87. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  88. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  89. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  90. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  91. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  92. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  93. package/src/components/blocks/shell/index.ts +18 -13
  94. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  95. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  96. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  97. package/src/components/blocks/shell/type.ts +130 -3
  98. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  99. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  100. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  101. package/src/components/blocks/shell/utils.ts +44 -4
  102. package/src/components/composed/__tests__/logo.test.tsx +60 -0
  103. package/src/components/composed/logo.tsx +159 -0
  104. package/src/components/composed/tree/CLAUDE.md +132 -0
  105. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  106. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  107. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  108. package/src/components/composed/tree/constants.ts +4 -4
  109. package/src/components/composed/tree/index.ts +38 -0
  110. package/src/components/composed/tree/tree-atom.ts +590 -0
  111. package/src/components/composed/tree/tree-node.tsx +117 -78
  112. package/src/components/composed/tree/tree.tsx +24 -16
  113. package/src/components/composed/tree/type.ts +125 -15
  114. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  115. package/src/components/composed/tree/use-tree-state.ts +105 -0
  116. package/src/components/composed/tree/use-tree.ts +185 -184
  117. package/src/components/composed/user-avatar.tsx +11 -99
  118. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
  119. package/src/components/user-avatar/component.tsx +151 -22
  120. package/src/components/user-avatar/fallback.tsx +3 -1
  121. package/src/components/user-avatar/index.ts +19 -0
  122. package/src/components/user-avatar/presence-badge.tsx +56 -0
  123. package/src/components/user-avatar/type.ts +58 -0
  124. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  125. package/src/components/user-avatar/use-user-avatar.ts +180 -0
  126. package/src/components/user-avatar/user-avatar-atom.ts +218 -0
  127. package/src/components/user-avatar/utils.ts +94 -0
  128. package/src/index.ts +35 -3
  129. package/src/state/exegia-provider.tsx +79 -0
  130. package/src/state/index.ts +4 -0
  131. package/src/state/store.ts +19 -0
  132. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
  133. package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
  134. package/dist-lib/components/blocks/nav/types.d.ts +0 -60
  135. package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
  136. package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
  137. package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
  138. package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
  139. package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
  140. package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
  141. package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
  142. package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
  143. package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
  144. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
  145. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
  146. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
  147. package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
  148. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
  149. package/src/components/blocks/nav/sidebar-block.tsx +0 -142
  150. package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
  151. package/src/components/blocks/nav/types.ts +0 -65
  152. package/src/components/blocks/nav/utils.ts +0 -4
  153. package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
  154. package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
  155. package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
  156. package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
  157. package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
  158. package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
  159. package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
  160. package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
  161. package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
  162. package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
  163. package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
  164. package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
  165. /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -0
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, mock, test } from "bun:test"
2
- import { fireEvent, render, screen, waitFor } from "@testing-library/react"
2
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
3
3
  import userEvent from "@testing-library/user-event"
4
4
  import { FileTextIcon, FolderIcon, SearchIcon } from "lucide-react"
5
5
 
6
+ import { RAIL_COLLAPSED_WIDTH } from "../constants"
6
7
  import { Tree } from "../index"
7
8
  import type { TreeNode } from "../type"
8
9
  import { moveNode } from "../utils"
@@ -78,36 +79,45 @@ const FILES: TreeNode[] = [
78
79
 
79
80
  /** Branch exits run through AnimatePresence — let them settle on real
80
81
  * timers, then query once (happy-dom serves stale waitFor queries while
81
- * an exit is in flight). */
82
- const settleExit = () => new Promise((resolve) => setTimeout(resolve, 400))
82
+ * an exit is in flight). Wrapped in act so the presence teardown that lands
83
+ * during the wait is a tracked React update, not an "outside act" warning. */
84
+ const settleExit = () =>
85
+ act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)))
83
86
 
84
87
  describe("Tree · navigation", () => {
85
- test("3-level data renders top level as collapsible sections, not links", async () => {
88
+ test("3-level data renders top level as collapsible sections above button rows", async () => {
86
89
  const user = userEvent.setup()
87
90
  render(<Tree items={SECTIONED} variant="navigation" />)
88
91
 
89
92
  const section = screen.getByRole("button", { name: "Research" })
90
93
  expect(section.getAttribute("aria-expanded")).toBe("true")
91
- // Sections are buttons — no anchor wraps them.
92
- expect(section.closest("a")).toBeNull()
93
- expect(screen.getByRole("link", { name: "Search" })).toBeDefined()
94
+ // Sections are plain heading buttons; item rows are the shared Button.
95
+ // Neither is an anchor — `href` is metadata for onNavigate now.
96
+ expect(document.querySelector("a")).toBeNull()
97
+ const search = screen.getByRole("button", { name: "Search" })
98
+ expect(search.getAttribute("data-slot")).toBe("tree-row")
99
+ expect(search.getAttribute("type")).toBe("button")
94
100
 
95
101
  // Collapsing the section folds its items away.
96
102
  await user.click(section)
97
103
  await settleExit()
98
- expect(screen.queryByRole("link", { name: "Search" })).toBeNull()
104
+ expect(screen.queryByRole("button", { name: "Search" })).toBeNull()
99
105
  })
100
106
 
101
- test("2-level data keeps top level as links and parents as toggles", async () => {
107
+ test("2-level data keeps top level as leaf rows and parents as toggles", async () => {
102
108
  const user = userEvent.setup()
103
109
  render(<Tree items={FLAT_NAV} variant="navigation" />)
104
110
 
105
- expect(screen.getByRole("link", { name: "Search" })).toBeDefined()
111
+ // A leaf row carries no aria-expanded; a parent row does.
112
+ const search = screen.getByRole("button", { name: "Search" })
113
+ expect(search.getAttribute("aria-expanded")).toBeNull()
106
114
  const parent = screen.getByRole("button", { name: "Library" })
107
115
  expect(parent.getAttribute("aria-expanded")).toBe("false")
108
116
 
109
117
  await user.click(parent)
110
- expect(await screen.findByRole("link", { name: "Manuscripts" })).toBeDefined()
118
+ expect(
119
+ await screen.findByRole("button", { name: "Manuscripts" })
120
+ ).toBeDefined()
111
121
  })
112
122
 
113
123
  test("read-only variants render no drag or rename affordances", () => {
@@ -122,14 +132,15 @@ describe("Tree · navigation", () => {
122
132
  <Tree items={SECTIONED} variant="navigation" />
123
133
  )
124
134
  // Library starts collapsed — its children are absent.
125
- expect(screen.queryByRole("link", { name: "Codices" })).toBeNull()
135
+ expect(screen.queryByRole("button", { name: "Codices" })).toBeNull()
126
136
 
127
137
  rerender(<Tree activeId="codices" items={SECTIONED} variant="navigation" />)
128
- const active = await screen.findByRole("link", { name: "Codices" })
138
+ const active = await screen.findByRole("button", { name: "Codices" })
129
139
  expect(active.getAttribute("aria-current")).toBe("page")
140
+ expect(active.hasAttribute("data-active")).toBe(true)
130
141
  })
131
142
 
132
- test("onNavigate fires for link rows after the node's own onSelect", async () => {
143
+ test("onNavigate fires for leaf rows after the node's own onSelect", async () => {
133
144
  const user = userEvent.setup()
134
145
  const order: string[] = []
135
146
  const items: TreeNode[] = [
@@ -147,29 +158,115 @@ describe("Tree · navigation", () => {
147
158
  variant="navigation"
148
159
  />
149
160
  )
150
- await user.click(screen.getByRole("link", { name: "Alpha" }))
161
+ await user.click(screen.getByRole("button", { name: "Alpha" }))
151
162
  expect(order).toEqual(["onSelect", "onNavigate"])
152
163
  })
164
+
165
+ test("href is passed through to onNavigate, never rendered as an anchor", async () => {
166
+ const user = userEvent.setup()
167
+ const onNavigate = mock((_node: TreeNode) => {})
168
+ render(<Tree items={FLAT_NAV} onNavigate={onNavigate} variant="navigation" />)
169
+
170
+ const search = screen.getByRole("button", { name: "Search" })
171
+ expect(search.tagName).toBe("BUTTON")
172
+ expect(search.getAttribute("href")).toBeNull()
173
+
174
+ await user.click(search)
175
+ expect(onNavigate).toHaveBeenCalledTimes(1)
176
+ expect(onNavigate.mock.calls[0]?.[0]?.href).toBe("/search")
177
+ })
178
+
179
+ test("sound=false strips the cuelume attributes from every row", () => {
180
+ render(<Tree items={FLAT_NAV} sound={false} variant="navigation" />)
181
+ for (const row of document.querySelectorAll('[data-slot="tree-row"]')) {
182
+ expect(row.hasAttribute("data-cuelume-press")).toBe(false)
183
+ expect(row.hasAttribute("data-cuelume-release")).toBe(false)
184
+ }
185
+ })
153
186
  })
154
187
 
155
188
  describe("Tree · toc", () => {
156
- test("leaves anchor to #{id}; explicit hrefs win; parents keep their route", async () => {
189
+ test("parents select on the row and expand from the overlay toggle", async () => {
157
190
  const user = userEvent.setup()
158
- render(<Tree items={TOC} variant="toc" />)
191
+ const onNavigate = mock((_node: TreeNode) => {})
192
+ render(<Tree items={TOC} onNavigate={onNavigate} variant="toc" />)
193
+
194
+ // Guide is a parent, but toc parents are selectable rows — the row
195
+ // itself carries no aria-expanded; the overlay chevron does.
196
+ const guide = screen.getByRole("button", { name: "Guide" })
197
+ expect(guide.getAttribute("aria-expanded")).toBeNull()
198
+ expect(guide.tagName).toBe("BUTTON")
199
+ const toggle = screen.getByRole("button", { name: "Expand Guide" })
200
+ expect(toggle.getAttribute("aria-expanded")).toBe("false")
201
+
202
+ // Pressing the row selects (and reports the node with its href) without
203
+ // expanding it.
204
+ await user.click(guide)
205
+ expect(onNavigate).toHaveBeenCalledTimes(1)
206
+ expect(onNavigate.mock.calls[0]?.[0]?.href).toBe("/guide")
207
+ expect(screen.queryByRole("button", { name: "Setup" })).toBeNull()
159
208
 
160
- const guide = screen.getByRole("link", { name: "Guide" })
161
- expect(guide.getAttribute("href")).toBe("/guide")
209
+ // The overlay toggle expands without selecting.
210
+ await user.click(toggle)
211
+ expect(await screen.findByRole("button", { name: "Setup" })).toBeDefined()
212
+ expect(onNavigate).toHaveBeenCalledTimes(1)
213
+ expect(toggle.getAttribute("aria-expanded")).toBe("true")
214
+
215
+ // Nested parents get their own overlay toggle; leaves get none.
216
+ await user.click(screen.getByRole("button", { name: "Expand Setup" }))
217
+ expect(await screen.findByRole("button", { name: "Install" })).toBeDefined()
218
+ expect(screen.queryByRole("button", { name: "Expand Install" })).toBeNull()
219
+
220
+ // Nothing in a toc renders as an anchor any more.
221
+ expect(document.querySelector("a")).toBeNull()
222
+ })
223
+
224
+ test("rows below the route level keep the native #{id} jump after select", async () => {
225
+ const user = userEvent.setup()
226
+ const order: string[] = []
227
+ window.location.hash = ""
228
+ render(
229
+ <Tree
230
+ items={TOC}
231
+ onNavigate={(node) => order.push(`navigate:${node.id}:${window.location.hash}`)}
232
+ variant="toc"
233
+ />
234
+ )
235
+
236
+ // Depth 0 is a route (href "/guide"): no hash is written — routing is
237
+ // the consumer's job in onNavigate.
238
+ await user.click(screen.getByRole("button", { name: "Guide" }))
239
+ expect(window.location.hash).toBe("")
162
240
 
163
- // Guide navigates on the row; its expansion is the overlay toggle.
164
241
  await user.click(screen.getByRole("button", { name: "Expand Guide" }))
165
- // Below the route level every node is an in-page anchor, parents
166
- // included — only depth 0 carries routes.
167
- const setup = await screen.findByRole("link", { name: "Setup" })
168
- expect(setup.getAttribute("href")).toBe("#setup")
242
+ await user.click(await screen.findByRole("button", { name: "Setup" }))
243
+ // onNavigate ran first (hash still empty inside it), then the jump —
244
+ // the order the old anchor default gave.
245
+ expect(order).toEqual(["navigate:guide:", "navigate:setup:"])
246
+ expect(window.location.hash).toBe("#setup")
169
247
 
170
248
  await user.click(screen.getByRole("button", { name: "Expand Setup" }))
171
- const install = await screen.findByRole("link", { name: "Install" })
172
- expect(install.getAttribute("href")).toBe("#install")
249
+ await user.click(await screen.findByRole("button", { name: "Install" }))
250
+ expect(window.location.hash).toBe("#install")
251
+
252
+ // An explicit hash href wins over the id.
253
+ window.location.hash = ""
254
+ render(
255
+ <Tree
256
+ items={[
257
+ {
258
+ id: "top",
259
+ label: "Top",
260
+ children: [{ id: "leaf", label: "Leaf", href: "#custom" }],
261
+ },
262
+ ]}
263
+ variant="toc"
264
+ />
265
+ )
266
+ await user.click(screen.getByRole("button", { name: "Expand Top" }))
267
+ await user.click(await screen.findByRole("button", { name: "Leaf" }))
268
+ expect(window.location.hash).toBe("#custom")
269
+ window.location.hash = ""
173
270
  })
174
271
  })
175
272
 
@@ -188,26 +285,41 @@ describe("Tree · sidebar", () => {
188
285
 
189
286
  rerender(<Tree collapsed items={RAIL} variant="sidebar" />)
190
287
  await settleExit()
191
- expect(rail()?.className).toContain("w-11")
288
+ expect(rail()?.className).toContain("w-10")
192
289
  expect(rail()?.getAttribute("data-collapsed")).toBe("")
290
+ // Collapsed rows are square-ish tiles that fill the 40px rail.
291
+ const row = screen.getByRole("button", { name: "Search" })
292
+ expect(row.className).toContain("h-10!")
293
+ expect(row.className).toContain("rounded-xl!")
294
+ expect(row.className).toContain("justify-center")
193
295
 
194
296
  // Back open — the direction that was broken.
195
297
  rerender(<Tree items={RAIL} variant="sidebar" />)
196
298
  await settleExit()
197
299
  expect(rail()?.className).toContain("w-full")
198
- expect(rail()?.className).not.toContain("w-11")
300
+ expect(rail()?.className).not.toContain("w-10")
199
301
  expect(rail()?.getAttribute("data-collapsed")).toBeNull()
302
+ expect(screen.getByRole("button", { name: "Search" }).className).not.toContain(
303
+ "h-10!"
304
+ )
305
+ })
306
+
307
+ test("the collapsed px target matches the resting width class", () => {
308
+ // tree.tsx animates the rail to RAIL_COLLAPSED_WIDTH once measured, and
309
+ // falls back to the class before that. Tailwind's w-10 is 2.5rem = 40px;
310
+ // the two must agree or the rail jumps on its first collapse.
311
+ expect(RAIL_COLLAPSED_WIDTH).toBe(40)
200
312
  })
201
313
 
202
314
  test("collapsed shows icon-only rows that keep an accessible name", async () => {
203
315
  const { rerender } = render(<Tree items={RAIL} variant="sidebar" />)
204
- expect(screen.getByRole("link", { name: "Search" }).textContent).toContain(
316
+ expect(screen.getByRole("button", { name: "Search" }).textContent).toContain(
205
317
  "Search"
206
318
  )
207
319
 
208
320
  rerender(<Tree collapsed items={RAIL} variant="sidebar" />)
209
321
  await settleExit()
210
- const row = screen.getByRole("link", { name: "Search" })
322
+ const row = screen.getByRole("button", { name: "Search" })
211
323
  // The label folds to nothing rather than unmounting — keeping it in the
212
324
  // DOM is what lets the fold animate — so assert the fold, not absence.
213
325
  const label = row.querySelector('[data-slot="tree-row-label"]')
@@ -221,7 +333,7 @@ describe("Tree · sidebar", () => {
221
333
  // happy-dom can't drive Base UI's hover/focus-visible open logic, so
222
334
  // assert the wiring: Base UI stamps its trigger attribute on the row.
223
335
  const { rerender } = render(<Tree collapsed items={RAIL} variant="sidebar" />)
224
- const collapsedRow = screen.getByRole("link", { name: "Search" })
336
+ const collapsedRow = screen.getByRole("button", { name: "Search" })
225
337
  expect(collapsedRow.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true)
226
338
 
227
339
  // The tooltip wrapper stays mounted when the rail expands (it is only
@@ -235,7 +347,18 @@ describe("Tree · sidebar", () => {
235
347
  // not read this test as covering it.
236
348
  rerender(<Tree items={RAIL} variant="sidebar" />)
237
349
  await settleExit()
238
- expect(screen.getByRole("link", { name: "Search" })).toBe(collapsedRow)
350
+ expect(screen.getByRole("button", { name: "Search" })).toBe(collapsedRow)
351
+ })
352
+
353
+ test("the active rail row swaps to the secondary Button variant", () => {
354
+ render(<Tree activeId="search" items={RAIL} variant="sidebar" />)
355
+ const active = screen.getByRole("button", { name: "Search" })
356
+ const idle = screen.getByRole("button", { name: "Notes" })
357
+ expect(active.getAttribute("aria-current")).toBe("page")
358
+ // Button's cva emits bg-secondary for "secondary" and nothing of the
359
+ // sort for "ghost" — the cheapest observable proxy for the variant.
360
+ expect(active.className).toContain("bg-secondary")
361
+ expect(idle.className).not.toContain("bg-secondary")
239
362
  })
240
363
 
241
364
  test("nested children are ignored — the rail is single-level", () => {
@@ -287,8 +410,8 @@ describe("Tree · files", () => {
287
410
  />
288
411
  )
289
412
 
290
- // A folder row is a <button> and a leaf row an <a> — either one nesting
291
- // the trailing button would be invalid markup React warns about.
413
+ // Every row is a <button> — one nesting the trailing button would be
414
+ // invalid markup React warns about.
292
415
  for (const row of document.querySelectorAll('[data-slot="tree-row"]')) {
293
416
  expect(row.querySelector("button, a")).toBeNull()
294
417
  }
@@ -370,21 +493,23 @@ describe("Tree · keyboard", () => {
370
493
  const user = userEvent.setup()
371
494
  render(<Tree items={FLAT_NAV} variant="navigation" />)
372
495
 
373
- const search = screen.getByRole("link", { name: "Search" })
496
+ const search = screen.getByRole("button", { name: "Search" })
374
497
  search.focus()
375
498
  await user.keyboard("{ArrowDown}")
376
499
  const library = screen.getByRole("button", { name: "Library" })
377
500
  expect(document.activeElement).toBe(library)
378
501
 
379
502
  await user.keyboard("{ArrowRight}")
380
- expect(await screen.findByRole("link", { name: "Manuscripts" })).toBeDefined()
503
+ expect(
504
+ await screen.findByRole("button", { name: "Manuscripts" })
505
+ ).toBeDefined()
381
506
  await waitFor(() =>
382
507
  expect(library.getAttribute("aria-expanded")).toBe("true")
383
508
  )
384
509
 
385
510
  await user.keyboard("{ArrowLeft}")
386
511
  await settleExit()
387
- expect(screen.queryByRole("link", { name: "Manuscripts" })).toBeNull()
512
+ expect(screen.queryByRole("button", { name: "Manuscripts" })).toBeNull()
388
513
  expect(document.activeElement).toBe(library)
389
514
  })
390
515
 
@@ -35,7 +35,8 @@ const FILES: TreeNode[] = [
35
35
 
36
36
  /** Branch exits run through AnimatePresence — let them settle before
37
37
  * querying (happy-dom serves stale results while an exit is in flight). */
38
- const settleExit = () => new Promise((resolve) => setTimeout(resolve, 400))
38
+ const settleExit = () =>
39
+ act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)))
39
40
 
40
41
  describe("useTree · expansion", () => {
41
42
  test("expand, collapse and toggle drive a single node", () => {
@@ -15,10 +15,10 @@ export const TREE_MICRO_DURATION = 0.15
15
15
  /** Sidebar rail label reveal/hide when `collapsed` flips. */
16
16
  export const TREE_COLLAPSE_DURATION = 0.2
17
17
 
18
- /** The collapsed rail's width in px — an icon row plus its padding. Kept in
19
- * sync with the `w-11` resting class the rail falls back to before its
20
- * expanded width has been measured. */
21
- export const RAIL_COLLAPSED_WIDTH = 44
18
+ /** The collapsed rail's width in px — one `h-10 rounded-xl` icon row, edge
19
+ * to edge. Kept in sync with the `w-10` resting class the rail falls back to
20
+ * before its expanded width has been measured. */
21
+ export const RAIL_COLLAPSED_WIDTH = 40
22
22
 
23
23
  /** Rail label text fade — starts as the width growth is finishing, so the
24
24
  * label never reads as clipped mid-grow. */
@@ -1,13 +1,51 @@
1
1
  export { Tree } from "./tree"
2
2
  export { useTree } from "./use-tree"
3
+ export { useTreeActions, useTreeState } from "./use-tree-state"
3
4
  export { moveNode, renameNode } from "./utils"
5
+ // The public atom surface. `@internal` atoms (config, handlers, mount, the
6
+ // projections) stay unexported on purpose — `export *` would make every
7
+ // internal a breaking-change surface for consumer apps.
8
+ export {
9
+ cancelTreeRenameAtom,
10
+ collapseAllTreeNodesAtom,
11
+ collapseTreeNodeAtom,
12
+ expandAllTreeNodesAtom,
13
+ expandTreeNodeAtom,
14
+ moveTreeNodeAtom,
15
+ removeTreeInstance,
16
+ renameTreeNodeAtom,
17
+ resetTreeAtom,
18
+ revealTreeNodeAtom,
19
+ selectTreeNodeAtom,
20
+ setTreeCollapsedAtom,
21
+ setTreeItemsAtom,
22
+ startTreeRenameAtom,
23
+ toggleTreeCollapsedAtom,
24
+ toggleTreeNodeAtom,
25
+ treeActiveIdAtom,
26
+ treeCanMoveAtom,
27
+ treeCanRenameAtom,
28
+ treeCollapsedAtom,
29
+ treeDraggedIdAtom,
30
+ treeDropTargetAtom,
31
+ treeExpandedIdsAtom,
32
+ treeItemsAtom,
33
+ treeRenamingIdAtom,
34
+ treeSectionedAtom,
35
+ treeSectionIdsAtom,
36
+ treeStateAtom,
37
+ } from "./tree-atom"
4
38
  export type {
39
+ TreeActions,
5
40
  TreeController,
6
41
  TreeControllerProps,
7
42
  TreeDataProps,
8
43
  TreeDropPosition,
44
+ TreeDropTarget,
45
+ TreeInstanceId,
9
46
  TreeNode,
10
47
  TreeProps,
48
+ TreeState,
11
49
  TreeVariant,
12
50
  UseTreeOptions,
13
51
  } from "./type"