@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.
- package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
- package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
- package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
- package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
- package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
- package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
- package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -0
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +35 -0
- package/dist-lib/components/composed/tree/index.d.ts +6 -0
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
- package/dist-lib/components/composed/tree/tree.d.ts +19 -0
- package/dist-lib/components/composed/tree/type.d.ts +290 -0
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
- package/dist-lib/components/composed/tree/utils.d.ts +28 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
- package/dist-lib/components/user-avatar/component.d.ts +3 -0
- package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
- package/dist-lib/components/user-avatar/index.d.ts +18 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +71 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
- package/dist-lib/components/user-avatar/utils.d.ts +2 -0
- package/dist-lib/index.d.ts +6 -2
- package/dist-lib/index.js +3576 -1366
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +16 -12
- package/src/components/beste/piece/browser-frame.tsx +9 -6
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +228 -29
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
- package/src/components/blocks/nav/sidebar/utils.ts +29 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -0
- package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
- package/src/components/blocks/scaffold/constants.ts +7 -0
- package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
- package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
- package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
- package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
- package/src/components/blocks/scaffold/type.ts +28 -0
- package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
- package/src/components/blocks/scaffold/utils.ts +19 -0
- package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -0
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
- package/src/components/composed/tree/constants.ts +60 -0
- package/src/components/composed/tree/index.ts +51 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-context.ts +13 -0
- package/src/components/composed/tree/tree-node.tsx +490 -0
- package/src/components/composed/tree/tree.tsx +286 -0
- package/src/components/composed/tree/type.ts +322 -0
- package/src/components/composed/tree/use-tree-dnd.ts +179 -0
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +307 -0
- package/src/components/composed/tree/utils.ts +172 -0
- package/src/components/composed/user-avatar.tsx +11 -96
- package/src/components/docs/component-preview.tsx +11 -11
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
- package/src/components/user-avatar/audio-wave.tsx +22 -0
- package/src/components/user-avatar/component.tsx +152 -0
- package/src/components/user-avatar/fallback.tsx +26 -0
- package/src/components/user-avatar/index.ts +34 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +85 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +144 -0
- package/src/components/user-avatar/user-avatar-atom.ts +198 -0
- package/src/components/user-avatar/utils.ts +8 -0
- package/src/index.css +211 -153
- package/src/index.ts +35 -2
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- 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("
|
|
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
|
-
|
|
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
|
-
|
|
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}
|