@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { SCAFFOLD_PANEL_CAPACITY } from "./constants"
|
|
6
|
+
import { getPanelCapacity } from "./utils"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Internal bookkeeping for responsive panel hiding.
|
|
10
|
+
*
|
|
11
|
+
* - `visibleOrder`: visible panel ids, least-recently-activated first — the
|
|
12
|
+
* front is what capacity pressure evicts next.
|
|
13
|
+
* - `autoHidden`: panels the scaffold hid because the canvas shrank, most
|
|
14
|
+
* recent last. They return on their own when room comes back.
|
|
15
|
+
* - `userHidden`: panels hidden by a tab press. They stay hidden until the
|
|
16
|
+
* tab is pressed again, however wide the canvas grows.
|
|
17
|
+
*/
|
|
18
|
+
interface PanelVisibilityState {
|
|
19
|
+
visibleOrder: string[]
|
|
20
|
+
autoHidden: string[]
|
|
21
|
+
userHidden: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EMPTY_STATE: PanelVisibilityState = {
|
|
25
|
+
visibleOrder: [],
|
|
26
|
+
autoHidden: [],
|
|
27
|
+
userHidden: [],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fold the registered ids and current capacity into a settled state:
|
|
31
|
+
* prune departed panels, admit new ones, evict the least-recently-activated
|
|
32
|
+
* visible panels past capacity, and restore auto-hidden ones when room
|
|
33
|
+
* returns. Returns `prev` untouched when nothing changes. */
|
|
34
|
+
function reconcile(
|
|
35
|
+
prev: PanelVisibilityState,
|
|
36
|
+
ids: readonly string[],
|
|
37
|
+
capacity: number
|
|
38
|
+
): PanelVisibilityState {
|
|
39
|
+
const known = new Set(ids)
|
|
40
|
+
const visibleOrder = prev.visibleOrder.filter((id) => known.has(id))
|
|
41
|
+
const autoHidden = prev.autoHidden.filter((id) => known.has(id))
|
|
42
|
+
const userHidden = prev.userHidden.filter((id) => known.has(id))
|
|
43
|
+
|
|
44
|
+
const tracked = new Set([...visibleOrder, ...autoHidden, ...userHidden])
|
|
45
|
+
for (const id of ids) if (!tracked.has(id)) visibleOrder.push(id)
|
|
46
|
+
|
|
47
|
+
while (visibleOrder.length > capacity && visibleOrder.length > 1) {
|
|
48
|
+
autoHidden.push(visibleOrder.shift() as string)
|
|
49
|
+
}
|
|
50
|
+
while (visibleOrder.length < capacity && autoHidden.length > 0) {
|
|
51
|
+
visibleOrder.push(autoHidden.pop() as string)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const unchanged =
|
|
55
|
+
visibleOrder.join(" ") === prev.visibleOrder.join(" ") &&
|
|
56
|
+
autoHidden.join(" ") === prev.autoHidden.join(" ") &&
|
|
57
|
+
userHidden.join(" ") === prev.userHidden.join(" ")
|
|
58
|
+
return unchanged ? prev : { visibleOrder, autoHidden, userHidden }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PanelVisibility {
|
|
62
|
+
/** How many panels currently fit side by side (1..capacity cap). */
|
|
63
|
+
panelCapacity: number
|
|
64
|
+
/** Ids of panels currently hidden (auto + user), for tabs to reflect. */
|
|
65
|
+
hiddenPanelIds: readonly string[]
|
|
66
|
+
isPanelHidden: (id: string) => boolean
|
|
67
|
+
togglePanelVisibility: (id: string) => void
|
|
68
|
+
/** Id of the panel whose tab is hovered — the canvas spotlights it. */
|
|
69
|
+
hoveredPanelId: string | null
|
|
70
|
+
/** @internal Tabs report pointer enter/leave on their label. */
|
|
71
|
+
setPanelHovered: (id: string, hovered: boolean) => void
|
|
72
|
+
/** @internal Canvas reports its ordered panel ids each render. */
|
|
73
|
+
registerPanelIds: (ids: readonly string[]) => void
|
|
74
|
+
/** @internal Canvas reports its measured width. */
|
|
75
|
+
setCanvasWidth: (width: number) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Owns which panels are hidden as the canvas gains and loses room.
|
|
79
|
+
* Lives in `Scaffold.Root`; Canvas and Tab reach it through context. */
|
|
80
|
+
export function usePanelVisibility(): PanelVisibility {
|
|
81
|
+
const [panelIds, setPanelIds] = React.useState<readonly string[]>([])
|
|
82
|
+
const [measuredCapacity, setMeasuredCapacity] = React.useState<number | null>(
|
|
83
|
+
null
|
|
84
|
+
)
|
|
85
|
+
const [state, setState] = React.useState<PanelVisibilityState>(EMPTY_STATE)
|
|
86
|
+
const [hoveredPanelId, setHoveredPanelId] = React.useState<string | null>(
|
|
87
|
+
null
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const panelCapacity = measuredCapacity ?? SCAFFOLD_PANEL_CAPACITY
|
|
91
|
+
|
|
92
|
+
const registerPanelIds = React.useCallback((ids: readonly string[]) => {
|
|
93
|
+
setPanelIds((prev) =>
|
|
94
|
+
prev.length === ids.length && prev.every((id, i) => id === ids[i])
|
|
95
|
+
? prev
|
|
96
|
+
: [...ids]
|
|
97
|
+
)
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
// Store the derived capacity, not the raw width — resize storms only
|
|
101
|
+
// re-render the tree when a panel actually gains or loses room.
|
|
102
|
+
const setCanvasWidth = React.useCallback((width: number) => {
|
|
103
|
+
setMeasuredCapacity(getPanelCapacity(width))
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
// Settle during render (the derive-state-while-rendering pattern): ids
|
|
107
|
+
// and capacity arrive as state, so an effect here would only add a
|
|
108
|
+
// cascading render after each change.
|
|
109
|
+
const settled = reconcile(state, panelIds, panelCapacity)
|
|
110
|
+
if (settled !== state) setState(settled)
|
|
111
|
+
|
|
112
|
+
const togglePanelVisibility = React.useCallback(
|
|
113
|
+
(id: string) => {
|
|
114
|
+
setState((prev) => {
|
|
115
|
+
if (!panelIds.includes(id)) return prev
|
|
116
|
+
const hidden =
|
|
117
|
+
prev.autoHidden.includes(id) || prev.userHidden.includes(id)
|
|
118
|
+
if (!hidden && prev.visibleOrder.length <= 1) return prev
|
|
119
|
+
|
|
120
|
+
const next: PanelVisibilityState = hidden
|
|
121
|
+
? {
|
|
122
|
+
// Showing: newest activation — reconcile evicts the oldest
|
|
123
|
+
// visible panel if the canvas can't fit one more.
|
|
124
|
+
visibleOrder: [...prev.visibleOrder, id],
|
|
125
|
+
autoHidden: prev.autoHidden.filter((other) => other !== id),
|
|
126
|
+
userHidden: prev.userHidden.filter((other) => other !== id),
|
|
127
|
+
}
|
|
128
|
+
: {
|
|
129
|
+
visibleOrder: prev.visibleOrder.filter((other) => other !== id),
|
|
130
|
+
autoHidden: prev.autoHidden,
|
|
131
|
+
userHidden: [...prev.userHidden, id],
|
|
132
|
+
}
|
|
133
|
+
return reconcile(next, panelIds, panelCapacity)
|
|
134
|
+
})
|
|
135
|
+
},
|
|
136
|
+
[panelIds, panelCapacity]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Leaving clears only its own id — a tab that unmounts mid-hover must
|
|
140
|
+
// not wipe out a hover the pointer has already moved on to.
|
|
141
|
+
const setPanelHovered = React.useCallback((id: string, hovered: boolean) => {
|
|
142
|
+
setHoveredPanelId((prev) => {
|
|
143
|
+
if (hovered) return id
|
|
144
|
+
return prev === id ? null : prev
|
|
145
|
+
})
|
|
146
|
+
}, [])
|
|
147
|
+
|
|
148
|
+
const hiddenPanelIds = React.useMemo(
|
|
149
|
+
() => [...settled.autoHidden, ...settled.userHidden],
|
|
150
|
+
[settled]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const isPanelHidden = React.useCallback(
|
|
154
|
+
(id: string) => hiddenPanelIds.includes(id),
|
|
155
|
+
[hiddenPanelIds]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return React.useMemo(
|
|
159
|
+
() => ({
|
|
160
|
+
panelCapacity,
|
|
161
|
+
hiddenPanelIds,
|
|
162
|
+
isPanelHidden,
|
|
163
|
+
togglePanelVisibility,
|
|
164
|
+
hoveredPanelId,
|
|
165
|
+
setPanelHovered,
|
|
166
|
+
registerPanelIds,
|
|
167
|
+
setCanvasWidth,
|
|
168
|
+
}),
|
|
169
|
+
[
|
|
170
|
+
panelCapacity,
|
|
171
|
+
hiddenPanelIds,
|
|
172
|
+
isPanelHidden,
|
|
173
|
+
togglePanelVisibility,
|
|
174
|
+
hoveredPanelId,
|
|
175
|
+
setPanelHovered,
|
|
176
|
+
registerPanelIds,
|
|
177
|
+
setCanvasWidth,
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -2,6 +2,25 @@ import type { Variants } from "motion/react"
|
|
|
2
2
|
import type { ClassNameValue } from "tailwind-merge"
|
|
3
3
|
import type { TSubPanelVariant } from "@/components/blocks/scaffold/type.ts"
|
|
4
4
|
import { EASE_OUT } from "@/lib/ease.ts"
|
|
5
|
+
import {
|
|
6
|
+
SCAFFOLD_CANVAS_GAP,
|
|
7
|
+
SCAFFOLD_PANEL_CAPACITY,
|
|
8
|
+
SCAFFOLD_PANEL_MIN_WIDTH,
|
|
9
|
+
} from "./constants"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How many panels fit on a canvas of `width` px, granting each
|
|
13
|
+
* `SCAFFOLD_PANEL_MIN_WIDTH` plus the gaps between them. Clamped to
|
|
14
|
+
* [1, SCAFFOLD_PANEL_CAPACITY]; an unmeasured canvas (`null`) fits the cap.
|
|
15
|
+
*/
|
|
16
|
+
export function getPanelCapacity(width: number | null): number {
|
|
17
|
+
if (width === null) return SCAFFOLD_PANEL_CAPACITY
|
|
18
|
+
const fits = Math.floor(
|
|
19
|
+
(width + SCAFFOLD_CANVAS_GAP) /
|
|
20
|
+
(SCAFFOLD_PANEL_MIN_WIDTH + SCAFFOLD_CANVAS_GAP)
|
|
21
|
+
)
|
|
22
|
+
return Math.max(1, Math.min(SCAFFOLD_PANEL_CAPACITY, fits))
|
|
23
|
+
}
|
|
5
24
|
|
|
6
25
|
/** Desktop backdrop the whole scaffold sits on — a soft warm-gray wash. */
|
|
7
26
|
export const scaffoldBackgroundClass: ClassNameValue =
|
|
@@ -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
|
+
})
|