@exegia/corpora-ui 0.19.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 (162) 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/__tests__/use-ai-sidebar.test.d.ts +1 -0
  9. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  10. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  11. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  13. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  14. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
  15. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  16. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
  17. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
  18. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  19. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  20. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  21. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  22. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  23. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  24. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  25. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  26. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  27. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  28. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  29. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  30. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  31. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  32. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  33. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  34. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  35. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  36. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  37. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  38. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  39. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  40. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  41. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  42. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  43. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  44. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  45. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  46. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  47. package/dist-lib/components/composed/tree/index.d.ts +6 -0
  48. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  49. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  50. package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
  51. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  52. package/dist-lib/components/composed/tree/type.d.ts +290 -0
  53. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
  54. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  55. package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
  56. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  57. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  58. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  59. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  60. package/dist-lib/components/user-avatar/component.d.ts +3 -0
  61. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  62. package/dist-lib/components/user-avatar/index.d.ts +18 -0
  63. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  64. package/dist-lib/components/user-avatar/type.d.ts +71 -0
  65. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  66. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  67. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  68. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  69. package/dist-lib/index.d.ts +6 -2
  70. package/dist-lib/index.js +3576 -1366
  71. package/dist-lib/index.js.map +1 -1
  72. package/dist-lib/state/exegia-provider.d.ts +51 -0
  73. package/dist-lib/state/index.d.ts +4 -0
  74. package/dist-lib/state/store.d.ts +17 -0
  75. package/package.json +16 -12
  76. package/src/components/beste/piece/browser-frame.tsx +9 -6
  77. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  78. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  79. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  80. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  81. package/src/components/blocks/auth/auth-state.ts +52 -0
  82. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  83. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  84. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  85. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  86. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
  87. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  88. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  89. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  90. package/src/components/blocks/nav/sidebar/type.ts +228 -29
  91. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  92. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
  93. package/src/components/blocks/nav/sidebar/utils.ts +29 -1
  94. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  95. package/src/components/blocks/profile/index.ts +28 -0
  96. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  97. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  98. package/src/components/blocks/profile/type.ts +95 -0
  99. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  100. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  101. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  102. package/src/components/blocks/scaffold/constants.ts +7 -0
  103. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  104. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  105. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  106. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  107. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  108. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  109. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  110. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  111. package/src/components/blocks/scaffold/type.ts +28 -0
  112. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  113. package/src/components/blocks/scaffold/utils.ts +19 -0
  114. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  115. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  116. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  117. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  118. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  119. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  120. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  121. package/src/components/blocks/shell/index.ts +18 -0
  122. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  123. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  124. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  125. package/src/components/blocks/shell/type.ts +130 -3
  126. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  127. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  128. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  129. package/src/components/blocks/shell/utils.ts +44 -4
  130. package/src/components/composed/tree/CLAUDE.md +132 -0
  131. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  132. package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
  133. package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
  134. package/src/components/composed/tree/constants.ts +60 -0
  135. package/src/components/composed/tree/index.ts +51 -0
  136. package/src/components/composed/tree/tree-atom.ts +590 -0
  137. package/src/components/composed/tree/tree-context.ts +13 -0
  138. package/src/components/composed/tree/tree-node.tsx +490 -0
  139. package/src/components/composed/tree/tree.tsx +286 -0
  140. package/src/components/composed/tree/type.ts +322 -0
  141. package/src/components/composed/tree/use-tree-dnd.ts +179 -0
  142. package/src/components/composed/tree/use-tree-state.ts +105 -0
  143. package/src/components/composed/tree/use-tree.ts +307 -0
  144. package/src/components/composed/tree/utils.ts +172 -0
  145. package/src/components/composed/user-avatar.tsx +11 -96
  146. package/src/components/docs/component-preview.tsx +11 -11
  147. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  148. package/src/components/user-avatar/audio-wave.tsx +22 -0
  149. package/src/components/user-avatar/component.tsx +152 -0
  150. package/src/components/user-avatar/fallback.tsx +26 -0
  151. package/src/components/user-avatar/index.ts +34 -0
  152. package/src/components/user-avatar/presence-badge.tsx +56 -0
  153. package/src/components/user-avatar/type.ts +85 -0
  154. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  155. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  156. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  157. package/src/components/user-avatar/utils.ts +8 -0
  158. package/src/index.css +211 -153
  159. package/src/index.ts +35 -2
  160. package/src/state/exegia-provider.tsx +79 -0
  161. package/src/state/index.ts +4 -0
  162. package/src/state/store.ts +19 -0
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, mock, test } from "bun:test"
2
- import { render, screen } from "@testing-library/react"
2
+ import { act, render, screen } from "@testing-library/react"
3
3
  import userEvent from "@testing-library/user-event"
4
4
 
5
5
  import { ShellLayout } from "../shell-layout"
6
6
  import { useShellPanels } from "../use-shell-panels"
7
+ import { SHELL_WIDTHS } from "../utils"
7
8
  import type { TPanelMap, UseShellPanelsOptions } from "../type"
8
9
 
9
10
  const PANELS: TPanelMap = {
@@ -23,6 +24,68 @@ const PANELS: TPanelMap = {
23
24
  },
24
25
  }
25
26
 
27
+ /** The shell resolves its columns from CSS variables with hidden probe
28
+ * elements. happy-dom has no layout engine, so every probe measures 0 and the
29
+ * fit rule fails open — answer the probes with the real px behind each
30
+ * variable so the rule works on the numbers it ships with:
31
+ *
32
+ * rail 256 (56 folded) + body 360 + panel 320
33
+ *
34
+ * Only probes are intercepted; every other box keeps happy-dom's answer. */
35
+ function stubShellWidths() {
36
+ const px = Object.fromEntries(
37
+ Object.entries(SHELL_WIDTHS).map(([name, value]) => [
38
+ name,
39
+ Number.parseFloat(value),
40
+ ])
41
+ )
42
+ const original = HTMLElement.prototype.getBoundingClientRect
43
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
44
+ const isProbe =
45
+ this.style.visibility === "hidden" && this.style.position === "absolute"
46
+ if (!isProbe) return original.call(this)
47
+
48
+ const variable = /var\((--[a-z-]+)\)/.exec(this.style.width)?.[1]
49
+ return { width: (variable && px[variable]) || 0 } as DOMRect
50
+ }
51
+ return () => {
52
+ HTMLElement.prototype.getBoundingClientRect = original
53
+ }
54
+ }
55
+
56
+ interface HappyDOMWindow {
57
+ happyDOM?: { setViewport: (viewport: { width: number }) => void }
58
+ }
59
+
60
+ /** Resize the viewport the way the shell hears it: happy-dom's own viewport
61
+ * plus the `resize` event, inside `act` so the remeasure lands in this tick. */
62
+ function resizeViewport(width: number) {
63
+ act(() => {
64
+ ;(globalThis as HappyDOMWindow).happyDOM?.setViewport({ width })
65
+ window.dispatchEvent(new Event("resize"))
66
+ })
67
+ }
68
+
69
+ /** happy-dom's default, restored between viewport tests. */
70
+ const WIDE_VIEWPORT = 1024
71
+
72
+ /** Mount the shell at `width` px of viewport with real column widths in play.
73
+ * The returned function puts both back — inside `act`, because happy-dom
74
+ * fires `resize` on `setViewport` and the shell (still mounted until
75
+ * cleanup) remeasures on it. */
76
+ function shellViewport(width: number) {
77
+ const restoreWidths = stubShellWidths()
78
+ ;(globalThis as HappyDOMWindow).happyDOM?.setViewport({ width })
79
+ return () => {
80
+ restoreWidths()
81
+ act(() => {
82
+ ;(globalThis as HappyDOMWindow).happyDOM?.setViewport({
83
+ width: WIDE_VIEWPORT,
84
+ })
85
+ })
86
+ }
87
+ }
88
+
26
89
  /** The desktop rail / drawer elements, queried by their landmark roles. */
27
90
  function leftRail() {
28
91
  return screen.getByRole("navigation", { name: "Primary navigation" })
@@ -73,7 +136,7 @@ describe("ShellLayout", () => {
73
136
  expect(leftRail().getAttribute("data-state")).toBe("collapsed")
74
137
  })
75
138
 
76
- test("a panel's trigger node replaces the default icon, keeping the toggle", async () => {
139
+ test("the shell owns the trigger icon, ignoring a panel's trigger node", async () => {
77
140
  const user = userEvent.setup()
78
141
  render(
79
142
  <ShellLayout
@@ -86,12 +149,19 @@ describe("ShellLayout", () => {
86
149
  )
87
150
 
88
151
  const rightTrigger = screen.getByRole("button", { name: "Toggle panel" })
89
- expect(rightTrigger.textContent).toContain("Custom trigger")
152
+ expect(rightTrigger.textContent).not.toContain("Custom trigger")
90
153
 
91
154
  await user.click(rightTrigger)
92
155
  expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
93
156
  })
94
157
 
158
+ test("a side with no panel gets no header trigger", () => {
159
+ render(<ShellLayout panels={{ left: PANELS.left! }} variant="web" />)
160
+
161
+ expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeDefined()
162
+ expect(screen.queryByRole("button", { name: "Toggle panel" })).toBeNull()
163
+ })
164
+
95
165
  test("a panel's own defaultOpen flag seeds its side's initial state", () => {
96
166
  render(
97
167
  <ShellLayout
@@ -122,6 +192,56 @@ describe("ShellLayout", () => {
122
192
  expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
123
193
  })
124
194
 
195
+ test("keeps the right panel while every column fits", () => {
196
+ // 256 + 360 + 320 = 936, inside happy-dom's 1024px viewport.
197
+ const restore = shellViewport(WIDE_VIEWPORT)
198
+ try {
199
+ render(<ShellLayout panels={PANELS} variant="web" />)
200
+
201
+ expect(rightDrawer()).toBeDefined()
202
+ expect(screen.getByRole("button", { name: "Toggle panel" })).toBeDefined()
203
+ } finally {
204
+ restore()
205
+ }
206
+ })
207
+
208
+ test("drops the right panel when the columns no longer fit", () => {
209
+ // 936 needed, 800 available.
210
+ const restore = shellViewport(800)
211
+ try {
212
+ render(<ShellLayout panels={PANELS} variant="web" />)
213
+
214
+ expect(screen.queryByRole("complementary")).toBeNull()
215
+ // Its trigger goes with it rather than driving nothing.
216
+ expect(screen.queryByRole("button", { name: "Toggle panel" })).toBeNull()
217
+ // The rail and the body are untouched — they are never the ones to go.
218
+ expect(leftRail()).toBeDefined()
219
+ expect(screen.getByRole("main")).toBeDefined()
220
+ } finally {
221
+ restore()
222
+ }
223
+ })
224
+
225
+ test("folding the left rail hands its column to the right panel", async () => {
226
+ const user = userEvent.setup()
227
+ // Expanded: 256 + 360 + 320 = 936 > 800. Folded: 56 + 360 + 320 = 736.
228
+ const restore = shellViewport(800)
229
+ try {
230
+ render(<ShellLayout panels={PANELS} variant="web" />)
231
+
232
+ expect(leftRail().getAttribute("data-state")).toBe("expanded")
233
+ expect(screen.queryByRole("complementary")).toBeNull()
234
+
235
+ await user.click(screen.getByRole("button", { name: "Toggle sidebar" }))
236
+
237
+ expect(leftRail().getAttribute("data-state")).toBe("collapsed")
238
+ expect(rightDrawer()).toBeDefined()
239
+ expect(screen.getByRole("button", { name: "Toggle panel" })).toBeDefined()
240
+ } finally {
241
+ restore()
242
+ }
243
+ })
244
+
125
245
  test("⌘B toggles the left rail", async () => {
126
246
  const user = userEvent.setup()
127
247
  render(<ShellLayout panels={PANELS} variant="web" />)
@@ -148,6 +268,7 @@ function HookedShell({
148
268
  <button onClick={() => panels.toggle("right")} type="button">
149
269
  External toggle
150
270
  </button>
271
+ {panels.isNarrow && <p>No room for the inspector</p>}
151
272
  </ShellLayout>
152
273
  )
153
274
  }
@@ -180,6 +301,74 @@ describe("useShellPanels", () => {
180
301
  expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
181
302
  })
182
303
 
304
+ test("mirrors the shell's too-narrow verdict back into the hook", () => {
305
+ const restore = shellViewport(800)
306
+ try {
307
+ render(<HookedShell />)
308
+
309
+ // The shell measures, the hook hears about it, and outside UI stands
310
+ // down with the panel it would have driven.
311
+ expect(screen.getByText("No room for the inspector")).toBeDefined()
312
+ expect(screen.queryByRole("complementary")).toBeNull()
313
+ } finally {
314
+ restore()
315
+ }
316
+ })
317
+
318
+ test("leaves the hook's verdict alone while the panel fits", () => {
319
+ const restore = shellViewport(WIDE_VIEWPORT)
320
+ try {
321
+ render(<HookedShell />)
322
+
323
+ expect(screen.queryByText("No room for the inspector")).toBeNull()
324
+ expect(rightDrawer()).toBeDefined()
325
+ } finally {
326
+ restore()
327
+ }
328
+ })
329
+
330
+ test("refuses to open the right panel while the shell is too narrow", async () => {
331
+ const user = userEvent.setup()
332
+ const onPanelChange = mock(() => {})
333
+ const restore = shellViewport(800)
334
+ try {
335
+ render(<HookedShell onPanelChange={onPanelChange} />)
336
+
337
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
338
+
339
+ // Nothing opened, and nothing was reported — the write never happened,
340
+ // so widening the viewport later cannot surface a stale panel.
341
+ expect(screen.queryByRole("complementary")).toBeNull()
342
+ expect(onPanelChange).not.toHaveBeenCalled()
343
+ } finally {
344
+ restore()
345
+ }
346
+ })
347
+
348
+ test("retires the right panel's state when the shell goes too narrow", async () => {
349
+ const user = userEvent.setup()
350
+ const onPanelChange = mock(() => {})
351
+ const restore = shellViewport(WIDE_VIEWPORT)
352
+ try {
353
+ render(<HookedShell onPanelChange={onPanelChange} />)
354
+
355
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
356
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
357
+
358
+ resizeViewport(800)
359
+
360
+ expect(screen.queryByRole("complementary")).toBeNull()
361
+ expect(onPanelChange).toHaveBeenLastCalledWith(false, "right")
362
+
363
+ // The state went with the panel: coming back does not spring it open.
364
+ resizeViewport(WIDE_VIEWPORT)
365
+
366
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
367
+ } finally {
368
+ restore()
369
+ }
370
+ })
371
+
183
372
  test("the shell's own triggers round-trip through the hook's state", async () => {
184
373
  const user = userEvent.setup()
185
374
  render(<HookedShell />)
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import {
4
+ clampPanelWidth,
5
+ fitsPanel,
6
+ metricsEqual,
7
+ panelBounds,
8
+ requiredWidth,
9
+ type ShellMetrics,
10
+ } from "../shell-metrics"
11
+
12
+ /** The columns the shell ships with: rail 256 (56 folded), body floor 360,
13
+ * secondary panel floor 320 — 936px before a panel can exist — plus the 32px
14
+ * of shell padding and column gaps that only the ceiling pays for. */
15
+ function shell(over: Partial<ShellMetrics> = {}): ShellMetrics {
16
+ return {
17
+ rail: 256,
18
+ insetMin: 360,
19
+ panelMin: 320,
20
+ viewport: 1024,
21
+ chrome: 32,
22
+ ...over,
23
+ }
24
+ }
25
+
26
+ describe("fitsPanel", () => {
27
+ test("wants the rail plus both floors to clear the viewport", () => {
28
+ expect(requiredWidth(shell())).toBe(936)
29
+ expect(fitsPanel(shell())).toBe(true)
30
+ expect(fitsPanel(shell({ viewport: 800 }))).toBe(false)
31
+ })
32
+
33
+ test("is strict — a viewport that fits exactly has nothing left to give", () => {
34
+ expect(fitsPanel(shell({ viewport: 936 }))).toBe(false)
35
+ expect(fitsPanel(shell({ viewport: 937 }))).toBe(true)
36
+ })
37
+
38
+ test("a folded rail hands its column back", () => {
39
+ expect(fitsPanel(shell({ rail: 56, viewport: 800 }))).toBe(true)
40
+ })
41
+
42
+ test("a shell with no rail spends nothing on one", () => {
43
+ expect(fitsPanel(shell({ rail: 0, viewport: 700 }))).toBe(true)
44
+ })
45
+
46
+ test("an unmeasurable shell fails open rather than hiding the panel", () => {
47
+ expect(
48
+ fitsPanel({
49
+ rail: 0,
50
+ insetMin: 0,
51
+ panelMin: 0,
52
+ viewport: 0,
53
+ chrome: 0,
54
+ })
55
+ ).toBe(true)
56
+ expect(fitsPanel(shell({ panelMin: 0 }))).toBe(true)
57
+ })
58
+
59
+ test("ignores the shell's own frame — the rule is stated on the viewport", () => {
60
+ expect(fitsPanel(shell({ chrome: 0 }))).toBe(
61
+ fitsPanel(shell({ chrome: 300 }))
62
+ )
63
+ })
64
+ })
65
+
66
+ describe("panelBounds", () => {
67
+ test("the panel may take every px the body holds above its floor", () => {
68
+ // 1024 - 32 frame - 256 rail - 360 body floor.
69
+ expect(panelBounds(shell())).toEqual({ min: 320, max: 376 })
70
+ expect(panelBounds(shell({ rail: 56 })).max).toBe(576)
71
+ })
72
+
73
+ test("the shell's frame comes out of the ceiling, not the floor", () => {
74
+ expect(panelBounds(shell({ chrome: 0 })).max).toBe(408)
75
+ expect(panelBounds(shell({ chrome: 0 })).min).toBe(320)
76
+ })
77
+
78
+ test("never inverts — a shell with no room reports its floor twice", () => {
79
+ expect(panelBounds(shell({ viewport: 800 }))).toEqual({
80
+ min: 320,
81
+ max: 320,
82
+ })
83
+ })
84
+ })
85
+
86
+ describe("clampPanelWidth", () => {
87
+ test("holds a drag inside the bounds and leaves the middle alone", () => {
88
+ expect(clampPanelWidth(600, shell())).toBe(376)
89
+ expect(clampPanelWidth(100, shell())).toBe(320)
90
+ expect(clampPanelWidth(360, shell())).toBe(360)
91
+ })
92
+
93
+ test("a narrowing viewport pulls a resized panel back down", () => {
94
+ const resized = clampPanelWidth(500, shell({ rail: 56 }))
95
+ expect(resized).toBe(500)
96
+ expect(clampPanelWidth(resized, shell({ rail: 56, viewport: 900 }))).toBe(
97
+ 452
98
+ )
99
+ })
100
+ })
101
+
102
+ describe("metricsEqual", () => {
103
+ test("compares fields, so a fresh object off the same layout is quiet", () => {
104
+ expect(metricsEqual(shell(), shell())).toBe(true)
105
+ expect(metricsEqual(shell(), shell({ viewport: 1025 }))).toBe(false)
106
+ expect(metricsEqual(shell(), shell({ rail: 56 }))).toBe(false)
107
+ })
108
+ })
@@ -13,7 +13,11 @@ export const AnimatedPanelInset = forwardRef<
13
13
  ref={forwardedRef}
14
14
  data-slot="sidebar-inset"
15
15
  className={cn(
16
- "relative flex min-w-0 flex-1 flex-col bg-neutral-50 dark:bg-neutral-900",
16
+ // The body takes every column the panels leave (flex-1 = width 100%)
17
+ // but never yields past its own floor: a resized panel may only take
18
+ // the slack above --inset-min-width, and the shell drops the secondary
19
+ // panel entirely once even that no longer fits.
20
+ "relative flex min-w-(--inset-min-width) flex-1 flex-col bg-neutral-50 dark:bg-neutral-900",
17
21
  "outline-offset-0.5 overflow-hidden border-t-3 border-white outline-neutral-100 dark:border-neutral-800 dark:inset-ring-black",
18
22
  "rounded-lg shadow-md shadow-neutral-200 dark:shadow-neutral-950",
19
23
  className
@@ -1,24 +1,26 @@
1
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
2
- import { useReducedMotion } from "motion/react"
2
+ import { useIsomorphicLayoutEffect, useReducedMotion } from "motion/react"
3
3
  import { cn } from "@/lib/utils.ts"
4
- import type {
5
- AnimatedSidebarProviderProps,
6
- SidebarSide,
7
- } from "./type"
4
+ import type { AnimatedSidebarProviderProps, SidebarSide } from "./type"
5
+ import { useShellFit } from "./use-shell-fit"
8
6
  import {
9
7
  AnimatedSidebarContext,
8
+ SHELL_WIDTHS,
10
9
  SIDEBAR_KEYBOARD_SHORTCUT,
11
10
  useIsMobile,
12
11
  } from "./utils"
13
12
 
14
13
  export function AnimatedPanelProvider({
15
14
  children,
15
+ shellId,
16
+ defaultPanelWidth,
16
17
  open,
17
18
  defaultOpen,
18
19
  onOpenChange,
19
20
  openMobile,
20
21
  defaultOpenMobile,
21
22
  onOpenMobileChange,
23
+ onNarrowChange,
22
24
  className,
23
25
  style,
24
26
  ...props
@@ -40,6 +42,12 @@ export function AnimatedPanelProvider({
40
42
  const generatedId = useId()
41
43
  const leftTriggerRef = useRef<HTMLButtonElement>(null)
42
44
  const rightTriggerRef = useRef<HTMLButtonElement>(null)
45
+ const wrapperRef = useRef<HTMLDivElement>(null)
46
+ // The narrow gate below is consulted by the setters, but the verdict comes
47
+ // from `useShellFit` further down — which needs those very setters to retire
48
+ // the panel. A ref unwinds the cycle: a setter only ever runs from an event,
49
+ // by which time the layout effect has published the current verdict.
50
+ const fitsRef = useRef(true)
43
51
 
44
52
  // Per-side controlled/uncontrolled merge — a side is controlled exactly
45
53
  // when its key is present on the controlled record.
@@ -63,8 +71,14 @@ export function AnimatedPanelProvider({
63
71
  [controlledMobileLeft, controlledMobileRight, internalOpenMobile]
64
72
  )
65
73
 
74
+ // The right panel is not on screen while the shell is too narrow to hold
75
+ // it, so opening it would only ever surface later as a panel nobody asked
76
+ // for. Closing always goes through — that is how state left over from a
77
+ // wider viewport clears.
66
78
  const setOpen = useCallback(
67
79
  (nextOpen: boolean, side: SidebarSide) => {
80
+ if (nextOpen && side === "right" && !fitsRef.current) return
81
+
68
82
  const controlled = side === "left" ? controlledLeft : controlledRight
69
83
  if (controlled === undefined) {
70
84
  setInternalOpen((prev) =>
@@ -78,6 +92,8 @@ export function AnimatedPanelProvider({
78
92
 
79
93
  const setOpenMobile = useCallback(
80
94
  (nextOpen: boolean, side: SidebarSide) => {
95
+ if (nextOpen && side === "right" && !fitsRef.current) return
96
+
81
97
  const controlled =
82
98
  side === "left" ? controlledMobileLeft : controlledMobileRight
83
99
  if (controlled === undefined) {
@@ -103,6 +119,58 @@ export function AnimatedPanelProvider({
103
119
  []
104
120
  )
105
121
 
122
+ // A measurement that lands on "no room" retires the right panel's open
123
+ // state instead of parking it: a panel that survived the crossing would
124
+ // spring back the moment the viewport widened, long after the user asked
125
+ // for it. Closing is the one write the narrow gate lets through, and the
126
+ // guards keep a repeated verdict from re-reporting a panel already shut.
127
+ //
128
+ // It rides a ref so `useShellFit` can call the latest one from a listener
129
+ // it bound once, and it is a layout effect declared FIRST so the ref is
130
+ // filled before that listener takes its opening measurement.
131
+ const retireRightPanelRef = useRef<() => void>(() => {})
132
+ useIsomorphicLayoutEffect(() => {
133
+ retireRightPanelRef.current = () => {
134
+ if (openState.right) setOpen(false, "right")
135
+ if (openMobileState.right) setOpenMobile(false, "right")
136
+ }
137
+ })
138
+ const retireRightPanel = useCallback(() => retireRightPanelRef.current(), [])
139
+
140
+ // Everything about the shell's columns — whether a secondary panel fits,
141
+ // how wide it is, how far it may be dragged — lives in this one hook, and
142
+ // in the shell-fit atoms it writes to under `shellId`. The rail's fold is an
143
+ // input because its column is part of the room the panel needs.
144
+ const fit = useShellFit({
145
+ shellId,
146
+ hostRef: wrapperRef,
147
+ railOpen: openState.left,
148
+ defaultPanelWidth,
149
+ onUnfit: retireRightPanel,
150
+ })
151
+ const isNarrow = !fit.fits
152
+ useIsomorphicLayoutEffect(() => {
153
+ fitsRef.current = fit.fits
154
+ })
155
+
156
+ // The verdict has to travel up to whoever renders the provider —
157
+ // useShellPanels, a title bar — since context only flows down. The
158
+ // listener above is bound once, so the callback rides a ref rather than
159
+ // rebinding it whenever a consumer passes a fresh arrow.
160
+ const onNarrowChangeRef = useRef(onNarrowChange)
161
+ useEffect(() => {
162
+ onNarrowChangeRef.current = onNarrowChange
163
+ })
164
+
165
+ // Only real crossings are news: a resize that leaves the verdict alone is
166
+ // silent, and so is the mount default the consumer already holds.
167
+ const reportedNarrowRef = useRef(false)
168
+ useEffect(() => {
169
+ if (reportedNarrowRef.current === isNarrow) return
170
+ reportedNarrowRef.current = isNarrow
171
+ onNarrowChangeRef.current?.(isNarrow)
172
+ }, [isNarrow])
173
+
106
174
  useEffect(() => {
107
175
  const handleShortcut = (event: KeyboardEvent) => {
108
176
  if (
@@ -122,6 +190,7 @@ export function AnimatedPanelProvider({
122
190
  <AnimatedSidebarContext.Provider
123
191
  value={{
124
192
  isMobile,
193
+ fit,
125
194
  layoutId: `${generatedId}-active`,
126
195
  open: openState,
127
196
  openMobile: openMobileState,
@@ -134,15 +203,12 @@ export function AnimatedPanelProvider({
134
203
  >
135
204
  <div
136
205
  {...props}
206
+ ref={wrapperRef}
137
207
  data-slot="sidebar-wrapper"
208
+ data-narrow={isNarrow ? "" : undefined}
138
209
  data-state-left={openState.left ? "expanded" : "collapsed"}
139
210
  data-state-right={openState.right ? "expanded" : "collapsed"}
140
- style={{
141
- "--sidebar-width": "19rem",
142
- "--sidebar-width-icon": "4.25rem",
143
- "--sidebar-width-mobile": "18rem",
144
- ...style,
145
- }}
211
+ style={{ ...SHELL_WIDTHS, ...style }}
146
212
  className={cn(
147
213
  "group/sidebar-wrapper flex w-full min-w-0 gap-x-2",
148
214
  className
@@ -16,6 +16,10 @@ export const AnimatedPanelTrigger = forwardRef<
16
16
  : context.open[side]
17
17
  const triggerRef = context.triggerRefs[side]
18
18
 
19
+ // The right panel drops out of a viewport too narrow to hold it, so its
20
+ // toggle would otherwise sit in the header driving nothing.
21
+ if (side === "right" && !context.fit.fits) return null
22
+
19
23
  return (
20
24
  <Button
21
25
  {...props}