@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
@@ -0,0 +1,360 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { act, render, screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { createStore, getDefaultStore } from "jotai"
5
+
6
+ import { ExegiaProvider } from "@/state"
7
+ import { ShellLayout } from "../shell-layout"
8
+ import {
9
+ measureShellFitAtom,
10
+ mountShellFitAtom,
11
+ removeShellFitInstance,
12
+ resetShellPanelWidthAtom,
13
+ resizeShellPanelAtom,
14
+ shellFitFitsAtom,
15
+ shellFitMeasuredAtom,
16
+ shellFitPanelBoundsAtom,
17
+ shellFitPanelWidthAtom,
18
+ shellFitRequestedWidthAtom,
19
+ shellFitStateAtom,
20
+ } from "../shell-fit-atom"
21
+ import type { ShellMetrics } from "../shell-metrics"
22
+ import { useShellFitActions, useShellFitState } from "../use-shell-fit-state"
23
+ import { useShellPanels } from "../use-shell-panels"
24
+ import type { TPanelMap } from "../type"
25
+ import { SHELL_WIDTHS } from "../utils"
26
+
27
+ type Store = ReturnType<typeof createStore>
28
+
29
+ /** The shell's shipping numbers on a 1024px viewport: rail 256 + body 360 +
30
+ * panel 320 = 936, with 32px of frame. */
31
+ const WIDE: ShellMetrics = {
32
+ rail: 256,
33
+ insetMin: 360,
34
+ panelMin: 320,
35
+ viewport: 1024,
36
+ chrome: 32,
37
+ }
38
+
39
+ /** The same shell squeezed to 800px — 936 needed, no room. */
40
+ const NARROW: ShellMetrics = { ...WIDE, viewport: 800 }
41
+
42
+ function measure(store: Store, id: string, metrics: ShellMetrics) {
43
+ store.set(measureShellFitAtom(id), metrics)
44
+ }
45
+
46
+ describe("shell-fit atoms · unmeasured", () => {
47
+ test("fails open: fits, no width, no measurement", () => {
48
+ const store = createStore()
49
+
50
+ expect(store.get(shellFitFitsAtom("a"))).toBe(true)
51
+ expect(store.get(shellFitMeasuredAtom("a"))).toBeNull()
52
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBeNull()
53
+ expect(store.get(shellFitPanelBoundsAtom("a"))).toEqual({ min: 0, max: 0 })
54
+ })
55
+
56
+ test("a resize before any measurement is remembered unclamped", () => {
57
+ const store = createStore()
58
+ store.set(resizeShellPanelAtom("a"), 900)
59
+
60
+ // Nothing to clamp against yet, so the ask is kept as-is …
61
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(900)
62
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBeNull()
63
+
64
+ // … and clamped on read the moment there is a room to clamp to.
65
+ measure(store, "a", WIDE)
66
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(1024 - 32 - 256 - 360)
67
+ })
68
+ })
69
+
70
+ describe("shell-fit atoms · the rule", () => {
71
+ test("fits while every column has its floor, not once they do not", () => {
72
+ const store = createStore()
73
+
74
+ measure(store, "a", WIDE)
75
+ expect(store.get(shellFitFitsAtom("a"))).toBe(true)
76
+
77
+ measure(store, "a", NARROW)
78
+ expect(store.get(shellFitFitsAtom("a"))).toBe(false)
79
+ })
80
+
81
+ test("the panel opens at its floor and may grow into the body's slack", () => {
82
+ const store = createStore()
83
+ measure(store, "a", WIDE)
84
+
85
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(320)
86
+ expect(store.get(shellFitPanelBoundsAtom("a"))).toEqual({
87
+ min: 320,
88
+ max: 1024 - 32 - 256 - 360,
89
+ })
90
+ })
91
+
92
+ test("a resize is clamped on the way in", () => {
93
+ const store = createStore()
94
+ measure(store, "a", WIDE)
95
+
96
+ store.set(resizeShellPanelAtom("a"), 900)
97
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(376)
98
+
99
+ store.set(resizeShellPanelAtom("a"), 10)
100
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(320)
101
+ })
102
+
103
+ test("a narrowing viewport pulls the panel down without forgetting the ask", () => {
104
+ const store = createStore()
105
+ measure(store, "a", WIDE)
106
+ store.set(resizeShellPanelAtom("a"), 360)
107
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(360)
108
+
109
+ // Less slack: 1000 - 32 - 256 - 360 = 352.
110
+ measure(store, "a", { ...WIDE, viewport: 1000 })
111
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(352)
112
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBe(360)
113
+
114
+ // Room comes back, and so does the width they chose.
115
+ measure(store, "a", WIDE)
116
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(360)
117
+ })
118
+
119
+ test("folding the rail hands its column to the panel's ceiling", () => {
120
+ const store = createStore()
121
+ measure(store, "a", WIDE)
122
+ expect(store.get(shellFitPanelBoundsAtom("a")).max).toBe(376)
123
+
124
+ measure(store, "a", { ...WIDE, rail: 56 })
125
+ expect(store.get(shellFitPanelBoundsAtom("a")).max).toBe(576)
126
+ })
127
+
128
+ test("a measurement that changes nothing is not a change", () => {
129
+ const store = createStore()
130
+ measure(store, "a", WIDE)
131
+ const before = store.get(shellFitStateAtom("a"))
132
+
133
+ measure(store, "a", { ...WIDE })
134
+ expect(store.get(shellFitStateAtom("a"))).toBe(before)
135
+ })
136
+ })
137
+
138
+ describe("shell-fit atoms · seed and reset", () => {
139
+ test("mount seeds the requested width once", () => {
140
+ const store = createStore()
141
+ store.set(mountShellFitAtom("a"), { panelWidth: 400 })
142
+ measure(store, "a", { ...WIDE, viewport: 1400 })
143
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(400)
144
+
145
+ // A later mount (StrictMode, a re-render with a new default) never
146
+ // overwrites a width the user dragged to.
147
+ store.set(resizeShellPanelAtom("a"), 500)
148
+ store.set(mountShellFitAtom("a"), { panelWidth: 340 })
149
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(500)
150
+ })
151
+
152
+ test("reset goes back to the seed, or the floor without one", () => {
153
+ const store = createStore()
154
+ store.set(mountShellFitAtom("a"), { panelWidth: 400 })
155
+ measure(store, "a", { ...WIDE, viewport: 1400 })
156
+ store.set(resizeShellPanelAtom("a"), 500)
157
+
158
+ store.set(resetShellPanelWidthAtom("a"))
159
+ expect(store.get(shellFitPanelWidthAtom("a"))).toBe(400)
160
+
161
+ measure(store, "b", WIDE)
162
+ store.set(resizeShellPanelAtom("b"), 360)
163
+ store.set(resetShellPanelWidthAtom("b"))
164
+ expect(store.get(shellFitPanelWidthAtom("b"))).toBe(320)
165
+ })
166
+ })
167
+
168
+ describe("shell-fit atoms · instances", () => {
169
+ test("two ids never share state", () => {
170
+ const store = createStore()
171
+ measure(store, "a", WIDE)
172
+ measure(store, "b", NARROW)
173
+
174
+ expect(store.get(shellFitFitsAtom("a"))).toBe(true)
175
+ expect(store.get(shellFitFitsAtom("b"))).toBe(false)
176
+ })
177
+
178
+ test("removing an instance drops its state", () => {
179
+ const store = createStore()
180
+ measure(store, "a", WIDE)
181
+ store.set(resizeShellPanelAtom("a"), 360)
182
+
183
+ removeShellFitInstance("a")
184
+
185
+ expect(store.get(shellFitMeasuredAtom("a"))).toBeNull()
186
+ expect(store.get(shellFitRequestedWidthAtom("a"))).toBeNull()
187
+ })
188
+
189
+ test("two stores never share state", () => {
190
+ const one = createStore()
191
+ const two = createStore()
192
+ measure(one, "a", NARROW)
193
+
194
+ expect(one.get(shellFitFitsAtom("a"))).toBe(false)
195
+ expect(two.get(shellFitFitsAtom("a"))).toBe(true)
196
+ })
197
+ })
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // By id, through React
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const PANELS: TPanelMap = {
204
+ left: {
205
+ id: "nav",
206
+ name: "Navigation",
207
+ component: <div>Nav body</div>,
208
+ open: true,
209
+ side: "left",
210
+ },
211
+ right: {
212
+ id: "inspector",
213
+ name: "Inspector",
214
+ component: <div>Inspector body</div>,
215
+ open: true,
216
+ side: "right",
217
+ },
218
+ }
219
+
220
+ /** Answer the shell's CSS-variable probes with real px (happy-dom has no
221
+ * layout engine); see `shell-layout.test.tsx` for the full story. */
222
+ function stubShellWidths() {
223
+ const px = Object.fromEntries(
224
+ Object.entries(SHELL_WIDTHS).map(([name, value]) => [
225
+ name,
226
+ Number.parseFloat(value),
227
+ ])
228
+ )
229
+ const original = HTMLElement.prototype.getBoundingClientRect
230
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
231
+ const isProbe =
232
+ this.style.visibility === "hidden" && this.style.position === "absolute"
233
+ if (!isProbe) return original.call(this)
234
+ const variable = /var\((--[a-z-]+)\)/.exec(this.style.width)?.[1]
235
+ return { width: (variable && px[variable]) || 0 } as DOMRect
236
+ }
237
+ return () => {
238
+ HTMLElement.prototype.getBoundingClientRect = original
239
+ }
240
+ }
241
+
242
+ /** Reads and drives the shell by id — mounted as a SIBLING of the shell, so
243
+ * it holds no controller and sits under no shell context. */
244
+ function RemoteInspectorControls({ shellId }: { shellId: string }) {
245
+ const { fits, panelWidth, bounds } = useShellFitState(shellId)
246
+ const { resizePanel, resetPanelWidth } = useShellFitActions(shellId)
247
+ return (
248
+ <div>
249
+ <p>{`fits: ${String(fits)}`}</p>
250
+ <p>{`width: ${String(panelWidth)}`}</p>
251
+ <p>{`max: ${bounds.max}`}</p>
252
+ <button onClick={() => resizePanel(360)} type="button">
253
+ Widen
254
+ </button>
255
+ <button onClick={resetPanelWidth} type="button">
256
+ Reset width
257
+ </button>
258
+ </div>
259
+ )
260
+ }
261
+
262
+ function NamedShellUnprovided({ shellId }: { shellId?: string }) {
263
+ const panels = useShellPanels({ shellId })
264
+ return <ShellLayout {...panels.providerProps} panels={PANELS} variant="web" />
265
+ }
266
+
267
+ /** `useShellPanels` reads the same atoms the shell writes, so it has to sit
268
+ * under the same `ExegiaProvider` as the shell — mounted above it, it would
269
+ * read Jotai's default store while the shell writes to the provider's. */
270
+ function NamedShell({ shellId }: { shellId?: string }) {
271
+ const panels = useShellPanels({ shellId, defaultPanelWidth: 340 })
272
+ return (
273
+ <>
274
+ <RemoteInspectorControls shellId={panels.shellId} />
275
+ <p>{`hook width: ${String(panels.panelWidth)}`}</p>
276
+ <ShellLayout {...panels.providerProps} panels={PANELS} variant="web" />
277
+ </>
278
+ )
279
+ }
280
+
281
+ describe("shell-fit · by id", () => {
282
+ test("a named shell is readable and resizable from outside it", async () => {
283
+ const user = userEvent.setup()
284
+ const restore = stubShellWidths()
285
+ try {
286
+ // happy-dom's default 1024px viewport: 936 needed. No stylesheet, so the
287
+ // frame measures 0 and the ceiling is 1024 - 256 - 360 = 408.
288
+ render(
289
+ <ExegiaProvider>
290
+ <NamedShell shellId="app-shell" />
291
+ </ExegiaProvider>
292
+ )
293
+
294
+ expect(screen.getByText("fits: true")).toBeDefined()
295
+ expect(screen.getByText("max: 408")).toBeDefined()
296
+ // `defaultPanelWidth` seeded the width, and the hook above the provider
297
+ // reads it back out of the same atoms.
298
+ expect(screen.getByText("width: 340")).toBeDefined()
299
+ expect(screen.getByText("hook width: 340")).toBeDefined()
300
+
301
+ await user.click(screen.getByRole("button", { name: "Widen" }))
302
+ expect(screen.getByText("width: 360")).toBeDefined()
303
+ expect(screen.getByText("hook width: 360")).toBeDefined()
304
+
305
+ await user.click(screen.getByRole("button", { name: "Reset width" }))
306
+ expect(screen.getByText("width: 340")).toBeDefined()
307
+ } finally {
308
+ restore()
309
+ removeShellFitInstance("app-shell")
310
+ }
311
+ })
312
+
313
+ test("a generated id is dropped with the hook that made it", () => {
314
+ const restore = stubShellWidths()
315
+ function Probe() {
316
+ const panels = useShellPanels()
317
+ return (
318
+ <ShellLayout {...panels.providerProps} panels={PANELS} variant="web">
319
+ <span data-testid="shell-id">{panels.shellId}</span>
320
+ </ShellLayout>
321
+ )
322
+ }
323
+ try {
324
+ const { unmount } = render(<Probe />)
325
+ const shellId = screen.getByTestId("shell-id").textContent ?? ""
326
+ // No ExegiaProvider here, so the hooks fall back to Jotai's default
327
+ // store — the provider wrote its measurement there under the hook's id.
328
+ const store = getDefaultStore()
329
+ expect(shellId).not.toBe("")
330
+ expect(store.get(shellFitMeasuredAtom(shellId))).not.toBeNull()
331
+
332
+ unmount()
333
+ // Nothing is left under it once the hook is gone: the family hands out
334
+ // a fresh atom, and a fresh atom reads as unmeasured.
335
+ expect(store.get(shellFitMeasuredAtom(shellId))).toBeNull()
336
+ } finally {
337
+ restore()
338
+ }
339
+ })
340
+
341
+ test("an explicit id outlives the shell", () => {
342
+ const restore = stubShellWidths()
343
+ try {
344
+ const store = getDefaultStore()
345
+ const { unmount } = render(<NamedShellUnprovided shellId="kept" />)
346
+ act(() => {
347
+ store.set(resizeShellPanelAtom("kept"), 360)
348
+ })
349
+ expect(store.get(shellFitRequestedWidthAtom("kept"))).toBe(360)
350
+
351
+ unmount()
352
+ // The dragged width is still there for the next mount — a route change
353
+ // does not reset the inspector.
354
+ expect(store.get(shellFitRequestedWidthAtom("kept"))).toBe(360)
355
+ } finally {
356
+ restore()
357
+ removeShellFitInstance("kept")
358
+ }
359
+ })
360
+ })
@@ -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 />)