@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,18 +1,30 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
// beui.dev/components/motion/animated-sidebar
|
|
3
3
|
|
|
4
|
-
import { motion } from "motion/react"
|
|
4
|
+
import { motion, type PanInfo } from "motion/react"
|
|
5
|
+
import {
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
} from "react"
|
|
5
12
|
import { EASE_OUT } from "@/lib/ease.ts"
|
|
6
13
|
import { cn } from "@/lib/utils"
|
|
7
14
|
import type { AnimatedSidebarProps } from "./type"
|
|
8
15
|
import {
|
|
9
16
|
AnimatedSidebarPanelContext,
|
|
17
|
+
expandedWidthVar,
|
|
10
18
|
SIDEBAR_MORPH_TRANSITION,
|
|
11
19
|
PANEL_TRANSITION,
|
|
12
20
|
REDUCED_TRANSITION,
|
|
13
|
-
useAnimatedSidebar
|
|
21
|
+
useAnimatedSidebar,
|
|
14
22
|
} from "./utils"
|
|
15
23
|
|
|
24
|
+
/** How far one arrow key moves the panel edge (shift = coarse step). */
|
|
25
|
+
const RESIZE_STEP = 16
|
|
26
|
+
const RESIZE_STEP_COARSE = 64
|
|
27
|
+
|
|
16
28
|
export function AnimatedPanel({
|
|
17
29
|
side = "left",
|
|
18
30
|
variant = "sidebar",
|
|
@@ -26,21 +38,97 @@ export function AnimatedPanel({
|
|
|
26
38
|
...props
|
|
27
39
|
}: AnimatedSidebarProps) {
|
|
28
40
|
const context = useAnimatedSidebar()
|
|
41
|
+
const { bounds, panelWidth, resetPanelWidth, resizePanel } = context.fit
|
|
29
42
|
const sideOpen = context.open[side]
|
|
30
43
|
const collapsed = collapsible !== "none" && !sideOpen
|
|
31
44
|
const offcanvas = collapsed && collapsible === "offcanvas"
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
|
|
46
|
+
// Only the expanded secondary panel is resizable: the rail is pinned to its
|
|
47
|
+
// own columns, and a collapsed panel has no edge left to grab.
|
|
48
|
+
const resizable =
|
|
49
|
+
side === "right" && collapsible === "offcanvas" && !collapsed
|
|
50
|
+
|
|
51
|
+
const resizeStartRef = useRef(0)
|
|
52
|
+
const [resizing, setResizing] = useState(false)
|
|
53
|
+
|
|
54
|
+
const baseWidth = offcanvas
|
|
55
|
+
? 0
|
|
34
56
|
: collapsed
|
|
35
57
|
? "var(--sidebar-width-icon)"
|
|
36
|
-
:
|
|
58
|
+
: expandedWidthVar(side)
|
|
59
|
+
// The secondary panel's width is shell state, not panel state: the shell
|
|
60
|
+
// measured it, re-clamps it when the room changes, and hands back the same
|
|
61
|
+
// width after a collapse. Everything else rides the CSS variables, which
|
|
62
|
+
// also covers the server render, where nothing has been measured yet.
|
|
63
|
+
const width = resizable && panelWidth !== null ? panelWidth : baseWidth
|
|
64
|
+
|
|
65
|
+
const setPanelRef = useCallback(
|
|
66
|
+
(node: HTMLElement | null) => {
|
|
67
|
+
if (typeof ref === "function") ref(node)
|
|
68
|
+
else if (ref) ref.current = node
|
|
69
|
+
},
|
|
70
|
+
[ref]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const handleResizeStart = useCallback(() => {
|
|
74
|
+
resizeStartRef.current = panelWidth ?? bounds.min
|
|
75
|
+
setResizing(true)
|
|
76
|
+
}, [bounds.min, panelWidth])
|
|
77
|
+
|
|
78
|
+
const handleResize = useCallback(
|
|
79
|
+
(_event: PointerEvent, info: PanInfo) => {
|
|
80
|
+
// The handle rides the panel's inner edge, so the panel grows as the
|
|
81
|
+
// pointer travels left. `offset` is measured from the pointer-down
|
|
82
|
+
// point, so the edge tracks it 1:1 instead of accumulating rounding.
|
|
83
|
+
resizePanel(resizeStartRef.current - info.offset.x)
|
|
84
|
+
},
|
|
85
|
+
[resizePanel]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const handleResizeEnd = useCallback(() => setResizing(false), [])
|
|
89
|
+
|
|
90
|
+
const handleResizeKeyDown = useCallback(
|
|
91
|
+
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
92
|
+
const direction =
|
|
93
|
+
event.key === "ArrowLeft" ? 1 : event.key === "ArrowRight" ? -1 : 0
|
|
94
|
+
if (!direction) return
|
|
95
|
+
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
const step = event.shiftKey ? RESIZE_STEP_COARSE : RESIZE_STEP
|
|
98
|
+
resizePanel((panelWidth ?? bounds.min) + direction * step)
|
|
99
|
+
},
|
|
100
|
+
[bounds.min, panelWidth, resizePanel]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// The pointer leaves the 2px handle the moment the drag starts, so the
|
|
104
|
+
// cursor and the text selection have to be held on the document instead.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!resizing) return
|
|
107
|
+
|
|
108
|
+
const { body } = document
|
|
109
|
+
const previousCursor = body.style.cursor
|
|
110
|
+
const previousUserSelect = body.style.userSelect
|
|
111
|
+
body.style.cursor = "col-resize"
|
|
112
|
+
body.style.userSelect = "none"
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
body.style.cursor = previousCursor
|
|
116
|
+
body.style.userSelect = previousUserSelect
|
|
117
|
+
}
|
|
118
|
+
}, [resizing])
|
|
119
|
+
|
|
120
|
+
// A secondary panel exists only while the viewport can carry it beside the
|
|
121
|
+
// rail and the body at their own floors. Below that there is nothing to dock
|
|
122
|
+
// into, so the panel stands down rather than squeezing the content column —
|
|
123
|
+
// and its trigger stands down with it.
|
|
124
|
+
if (side === "right" && !context.fit.fits) return null
|
|
37
125
|
|
|
38
126
|
if (context.isMobile) return <div>TODO: Create Mobile nav experience</div>
|
|
39
127
|
|
|
40
128
|
return (
|
|
41
129
|
<motion.aside
|
|
42
130
|
{...props}
|
|
43
|
-
ref={
|
|
131
|
+
ref={setPanelRef}
|
|
44
132
|
initial={false}
|
|
45
133
|
aria-label={ariaLabel}
|
|
46
134
|
data-slot="sidebar"
|
|
@@ -48,6 +136,7 @@ export function AnimatedPanel({
|
|
|
48
136
|
data-collapsible={collapsible}
|
|
49
137
|
data-variant={variant}
|
|
50
138
|
data-side={side}
|
|
139
|
+
data-resizing={resizing ? "" : undefined}
|
|
51
140
|
animate={{
|
|
52
141
|
width,
|
|
53
142
|
// Swallow the shell's gap-x-2 column gap while off canvas so the
|
|
@@ -57,7 +146,11 @@ export function AnimatedPanel({
|
|
|
57
146
|
? { marginLeft: offcanvas ? "-0.5rem" : "0rem" }
|
|
58
147
|
: { marginRight: offcanvas ? "-0.5rem" : "0rem" }),
|
|
59
148
|
}}
|
|
60
|
-
|
|
149
|
+
// A drag has to land on the frame it happened — the morph spring would
|
|
150
|
+
// trail the pointer and keep settling after the handle is released.
|
|
151
|
+
transition={
|
|
152
|
+
resizing || context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION
|
|
153
|
+
}
|
|
61
154
|
style={style}
|
|
62
155
|
className={cn(
|
|
63
156
|
"group/sidebar relative hidden h-full shrink-0 will-change-[width] md:block",
|
|
@@ -66,14 +159,28 @@ export function AnimatedPanel({
|
|
|
66
159
|
className
|
|
67
160
|
)}
|
|
68
161
|
>
|
|
69
|
-
{
|
|
162
|
+
{resizable && (
|
|
70
163
|
// The handle occupies the shell's gap-x-2 column gap. It must stay
|
|
71
164
|
// outside the clipping mask below or the gap would never receive
|
|
72
165
|
// hover — clipping cuts hit-testing, not just painting.
|
|
73
166
|
<motion.div
|
|
74
|
-
|
|
167
|
+
role="separator"
|
|
168
|
+
aria-label={`Resize ${ariaLabel}`}
|
|
169
|
+
aria-orientation="vertical"
|
|
170
|
+
tabIndex={0}
|
|
171
|
+
className="absolute inset-y-0 -left-2 z-10 w-2 cursor-col-resize touch-none"
|
|
75
172
|
initial="idle"
|
|
173
|
+
// Hover alone would drop the glow the instant the drag leaves the
|
|
174
|
+
// handle, so the gesture pins it open for the whole drag.
|
|
175
|
+
animate={resizing ? "hover" : "idle"}
|
|
76
176
|
whileHover="hover"
|
|
177
|
+
whileFocus="hover"
|
|
178
|
+
onPanStart={handleResizeStart}
|
|
179
|
+
onPan={handleResize}
|
|
180
|
+
onPanEnd={handleResizeEnd}
|
|
181
|
+
onKeyDown={handleResizeKeyDown}
|
|
182
|
+
// The usual escape hatch out of a width you dragged to.
|
|
183
|
+
onDoubleClick={resetPanelWidth}
|
|
77
184
|
>
|
|
78
185
|
<motion.div
|
|
79
186
|
variants={{ idle: { scaleY: 0 }, hover: { scaleY: 1 } }}
|
|
@@ -86,30 +193,20 @@ export function AnimatedPanel({
|
|
|
86
193
|
/>
|
|
87
194
|
</motion.div>
|
|
88
195
|
)}
|
|
89
|
-
{/* The
|
|
90
|
-
|
|
91
|
-
clipping the aside itself — the aside must stay
|
|
92
|
-
gap handle above to be hoverable. */}
|
|
93
|
-
<div
|
|
94
|
-
className={
|
|
95
|
-
collapsible === "offcanvas"
|
|
96
|
-
? "absolute inset-0 overflow-hidden rounded-[inherit]"
|
|
97
|
-
: "contents"
|
|
98
|
-
}
|
|
99
|
-
>
|
|
196
|
+
{/* The aside's own width animation is the whole layout story: the
|
|
197
|
+
inner panel just fills it and fades, so it is masked to the aside's
|
|
198
|
+
box here instead of clipping the aside itself — the aside must stay
|
|
199
|
+
unclipped for the gap handle above to be hoverable. */}
|
|
200
|
+
<div className={"contents"}>
|
|
100
201
|
<motion.div
|
|
101
202
|
initial={false}
|
|
102
|
-
animate={{
|
|
103
|
-
opacity: offcanvas ? 0 : 1,
|
|
104
|
-
x: offcanvas ? (side === "left" ? "-100%" : "100%") : "0%",
|
|
105
|
-
}}
|
|
203
|
+
animate={{ opacity: offcanvas ? 0 : 1 }}
|
|
106
204
|
transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}
|
|
107
205
|
className={cn(
|
|
108
|
-
"sticky top-0 flex flex-col overflow-hidden",
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// detached strip.
|
|
206
|
+
"sticky top-0 flex w-full flex-col overflow-hidden",
|
|
207
|
+
// Pin the right panel to the aside's trailing edge so the aside's
|
|
208
|
+
// width animation expands the panel out of it instead of
|
|
209
|
+
// revealing a detached strip.
|
|
113
210
|
side === "right" && "ml-auto",
|
|
114
211
|
|
|
115
212
|
// Panels fill the shell's container, not the viewport — the shell
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import { ShellLayout } from "./shell-layout"
|
|
2
2
|
|
|
3
3
|
export type * from "./type"
|
|
4
|
+
export * from "./shell-metrics"
|
|
4
5
|
export * from "./use-shell-panels"
|
|
6
|
+
export { useShellFit } from "./use-shell-fit"
|
|
7
|
+
export { useShellFitActions, useShellFitState } from "./use-shell-fit-state"
|
|
8
|
+
// The public atom surface. `@internal` atoms (measure, mount) stay unexported
|
|
9
|
+
// on purpose — `export *` would make every internal a breaking-change surface
|
|
10
|
+
// for consumer apps.
|
|
11
|
+
export {
|
|
12
|
+
removeShellFitInstance,
|
|
13
|
+
resetShellPanelWidthAtom,
|
|
14
|
+
resizeShellPanelAtom,
|
|
15
|
+
shellFitFitsAtom,
|
|
16
|
+
shellFitMeasuredAtom,
|
|
17
|
+
shellFitMetricsAtom,
|
|
18
|
+
shellFitPanelBoundsAtom,
|
|
19
|
+
shellFitPanelWidthAtom,
|
|
20
|
+
shellFitRequestedWidthAtom,
|
|
21
|
+
shellFitStateAtom,
|
|
22
|
+
} from "./shell-fit-atom"
|
|
5
23
|
export * from "./utils"
|
|
6
24
|
|
|
7
25
|
export * from "./animated-panel"
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance Jotai state for the shell's layout fit.
|
|
3
|
+
*
|
|
4
|
+
* Every atom is a module-level family keyed by a shell id, so one
|
|
5
|
+
* `ExegiaProvider` at the app root is enough: several shells coexist in one
|
|
6
|
+
* store without a provider each, and a title bar, a command palette or a
|
|
7
|
+
* layout test can drive one by id without holding its controller.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the tree, this feature has no controlled props — the shell measures
|
|
10
|
+
* itself, nobody hands it a viewport. So, like `blocks/auth`'s coordination
|
|
11
|
+
* layer, there is no config atom, no projection and no owned-* loop guard.
|
|
12
|
+
* `useShellFit` is the only writer of `metrics`, and that write is silent by
|
|
13
|
+
* design: a resize is not an event an app subscribed to.
|
|
14
|
+
*
|
|
15
|
+
* The rule itself lives in `shell-metrics.ts` and is called from here, so the
|
|
16
|
+
* arithmetic has one home whether it is read through a hook or a store.
|
|
17
|
+
*/
|
|
18
|
+
import { atom } from "jotai"
|
|
19
|
+
import type { Getter, Setter } from "jotai"
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
clampPanelWidth,
|
|
23
|
+
fitsPanel,
|
|
24
|
+
metricsEqual,
|
|
25
|
+
panelBounds,
|
|
26
|
+
type ShellMetrics,
|
|
27
|
+
} from "./shell-metrics"
|
|
28
|
+
import type {
|
|
29
|
+
ShellFitInstanceId,
|
|
30
|
+
ShellFitPanelBounds,
|
|
31
|
+
ShellFitSeed,
|
|
32
|
+
ShellFitState,
|
|
33
|
+
} from "./type"
|
|
34
|
+
|
|
35
|
+
/** An unmeasured shell. Zeroes read as "no layout yet", which the rule fails
|
|
36
|
+
* open on rather than hiding a panel over a reading it never took. */
|
|
37
|
+
const NO_METRICS: ShellMetrics = {
|
|
38
|
+
rail: 0,
|
|
39
|
+
insetMin: 0,
|
|
40
|
+
panelMin: 0,
|
|
41
|
+
viewport: 0,
|
|
42
|
+
chrome: 0,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A panel that mounts at `--panel-width`. */
|
|
46
|
+
const DEFAULT_SHELL_FIT_SEED: ShellFitSeed = { panelWidth: null }
|
|
47
|
+
|
|
48
|
+
const NO_BOUNDS: ShellFitPanelBounds = { min: 0, max: 0 }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A string-keyed atom family.
|
|
52
|
+
*
|
|
53
|
+
* `jotai/utils`' `atomFamily` is deprecated for Jotai v3, and we need only
|
|
54
|
+
* the string-keyed case with a `remove` — so this stays in-house rather than
|
|
55
|
+
* adding `jotai-family` as a second Jotai package to keep version-aligned.
|
|
56
|
+
* Dropping a key lets the store's WeakMap release that instance's state.
|
|
57
|
+
*/
|
|
58
|
+
type Family<AtomType> = ((id: ShellFitInstanceId) => AtomType) & {
|
|
59
|
+
remove: (id: ShellFitInstanceId) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every family, so `removeShellFitInstance` can drop an id from all of them. */
|
|
63
|
+
const families: { remove: (id: ShellFitInstanceId) => void }[] = []
|
|
64
|
+
|
|
65
|
+
function keyed<AtomType>(create: (id: ShellFitInstanceId) => AtomType) {
|
|
66
|
+
const cache = new Map<ShellFitInstanceId, AtomType>()
|
|
67
|
+
const family = ((id: ShellFitInstanceId) => {
|
|
68
|
+
let instance = cache.get(id)
|
|
69
|
+
if (instance === undefined) {
|
|
70
|
+
instance = create(id)
|
|
71
|
+
cache.set(id, instance)
|
|
72
|
+
}
|
|
73
|
+
return instance
|
|
74
|
+
}) as Family<AtomType>
|
|
75
|
+
family.remove = (id: ShellFitInstanceId) => {
|
|
76
|
+
cache.delete(id)
|
|
77
|
+
}
|
|
78
|
+
families.push(family)
|
|
79
|
+
return family
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stateFamily<Value>(name: string, initialValue: Value) {
|
|
83
|
+
return keyed((id) => {
|
|
84
|
+
const instance = atom(initialValue)
|
|
85
|
+
instance.debugLabel = `shell-fit/${id}/${name}`
|
|
86
|
+
return instance
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readFamily<Value>(
|
|
91
|
+
name: string,
|
|
92
|
+
read: (get: Getter, id: ShellFitInstanceId) => Value
|
|
93
|
+
) {
|
|
94
|
+
return keyed((id) => {
|
|
95
|
+
const instance = atom((get) => read(get, id))
|
|
96
|
+
instance.debugLabel = `shell-fit/${id}/${name}`
|
|
97
|
+
return instance
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function actionFamily<Args extends unknown[]>(
|
|
102
|
+
name: string,
|
|
103
|
+
write: (
|
|
104
|
+
get: Getter,
|
|
105
|
+
set: Setter,
|
|
106
|
+
id: ShellFitInstanceId,
|
|
107
|
+
...args: Args
|
|
108
|
+
) => void
|
|
109
|
+
) {
|
|
110
|
+
return keyed((id) => {
|
|
111
|
+
const instance = atom(null, (get, set, ...args: Args) =>
|
|
112
|
+
write(get, set, id, ...args)
|
|
113
|
+
)
|
|
114
|
+
instance.debugLabel = `shell-fit/${id}/${name}`
|
|
115
|
+
return instance
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The shell's own measurement of its columns. `useShellFit` is the only
|
|
120
|
+
* writer; read `shellFitMeasuredAtom` to get it with the "no layout yet" case
|
|
121
|
+
* already resolved. */
|
|
122
|
+
export const shellFitMetricsAtom = stateFamily<ShellMetrics>(
|
|
123
|
+
"metrics",
|
|
124
|
+
NO_METRICS
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
/** The width the user dragged to, before clamping — null while the panel sits
|
|
128
|
+
* at `--panel-width`. Read `shellFitPanelWidthAtom` for the width the panel
|
|
129
|
+
* actually renders at: clamping on read is what lets a narrowing viewport
|
|
130
|
+
* pull the panel down without forgetting the width they asked for. */
|
|
131
|
+
export const shellFitRequestedWidthAtom = stateFamily<number | null>(
|
|
132
|
+
"requestedWidth",
|
|
133
|
+
null
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const shellFitSeedAtom = stateFamily<ShellFitSeed>(
|
|
137
|
+
"seed",
|
|
138
|
+
DEFAULT_SHELL_FIT_SEED
|
|
139
|
+
)
|
|
140
|
+
const shellFitInitializedAtom = stateFamily<boolean>("initialized", false)
|
|
141
|
+
|
|
142
|
+
/** The metrics, or null when the shell has no layout to report — a server
|
|
143
|
+
* render, a `display: none` host, a test with no layout engine. A zero is not
|
|
144
|
+
* a width, and treating it as one would pin the panel to nothing. */
|
|
145
|
+
export const shellFitMeasuredAtom = readFamily<ShellMetrics | null>(
|
|
146
|
+
"measured",
|
|
147
|
+
(get, id) => {
|
|
148
|
+
const metrics = get(shellFitMetricsAtom(id))
|
|
149
|
+
return metrics.panelMin > 0 ? metrics : null
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
/** Whether the shell can hold a secondary panel at all. */
|
|
154
|
+
export const shellFitFitsAtom = readFamily("fits", (get, id) =>
|
|
155
|
+
fitsPanel(get(shellFitMetricsAtom(id)))
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
/** The range a resize may land in. */
|
|
159
|
+
export const shellFitPanelBoundsAtom = readFamily<ShellFitPanelBounds>(
|
|
160
|
+
"panelBounds",
|
|
161
|
+
(get, id) => {
|
|
162
|
+
const measured = get(shellFitMeasuredAtom(id))
|
|
163
|
+
return measured ? panelBounds(measured) : NO_BOUNDS
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
/** The width the secondary panel renders at, or null before the first
|
|
168
|
+
* measurement, where the caller falls back to `--panel-width`. */
|
|
169
|
+
export const shellFitPanelWidthAtom = readFamily<number | null>(
|
|
170
|
+
"panelWidth",
|
|
171
|
+
(get, id) => {
|
|
172
|
+
const measured = get(shellFitMeasuredAtom(id))
|
|
173
|
+
if (!measured) return null
|
|
174
|
+
const requested = get(shellFitRequestedWidthAtom(id))
|
|
175
|
+
return clampPanelWidth(requested ?? measured.panelMin, measured)
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
/** The whole fit of one shell. This changes on every measurement, so a
|
|
180
|
+
* component that reads one field should subscribe to that field's atom
|
|
181
|
+
* instead: `useAtomValue(shellFitFitsAtom("app-shell"))`. */
|
|
182
|
+
export const shellFitStateAtom = readFamily<ShellFitState>(
|
|
183
|
+
"state",
|
|
184
|
+
(get, id) => ({
|
|
185
|
+
metrics: get(shellFitMeasuredAtom(id)),
|
|
186
|
+
fits: get(shellFitFitsAtom(id)),
|
|
187
|
+
panelWidth: get(shellFitPanelWidthAtom(id)),
|
|
188
|
+
bounds: get(shellFitPanelBoundsAtom(id)),
|
|
189
|
+
})
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
/** @internal The measurement's way in. Silent, and inert when the numbers did
|
|
193
|
+
* not move — a resize event that changes nothing must not re-render a shell. */
|
|
194
|
+
export const measureShellFitAtom = actionFamily<[metrics: ShellMetrics]>(
|
|
195
|
+
"measure",
|
|
196
|
+
(get, set, id, metrics) => {
|
|
197
|
+
if (metricsEqual(get(shellFitMetricsAtom(id)), metrics)) return
|
|
198
|
+
set(shellFitMetricsAtom(id), metrics)
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
/** Resize the secondary panel. Clamped on the way in, so a drag past the
|
|
203
|
+
* gutter parks at the bound instead of banking travel it has to give back —
|
|
204
|
+
* and a caller with no idea how wide the shell is can still ask for 900. */
|
|
205
|
+
export const resizeShellPanelAtom = actionFamily<[width: number]>(
|
|
206
|
+
"resizePanel",
|
|
207
|
+
(get, set, id, width) => {
|
|
208
|
+
const measured = get(shellFitMeasuredAtom(id))
|
|
209
|
+
set(
|
|
210
|
+
shellFitRequestedWidthAtom(id),
|
|
211
|
+
measured ? clampPanelWidth(width, measured) : width
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
/** Back to the width the shell mounted with — `defaultPanelWidth`, or
|
|
217
|
+
* `--panel-width` when there was none. */
|
|
218
|
+
export const resetShellPanelWidthAtom = actionFamily<[]>(
|
|
219
|
+
"resetPanelWidth",
|
|
220
|
+
(get, set, id) => {
|
|
221
|
+
set(shellFitRequestedWidthAtom(id), get(shellFitSeedAtom(id)).panelWidth)
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
/** @internal Seed the instance once. A seed describes the mount, not every
|
|
226
|
+
* render, so a `defaultPanelWidth` that arrives later never overwrites a
|
|
227
|
+
* width the user dragged to. */
|
|
228
|
+
export const mountShellFitAtom = actionFamily<[seed: ShellFitSeed]>(
|
|
229
|
+
"mount",
|
|
230
|
+
(get, set, id, seed) => {
|
|
231
|
+
if (get(shellFitInitializedAtom(id))) return
|
|
232
|
+
set(shellFitInitializedAtom(id), true)
|
|
233
|
+
set(shellFitSeedAtom(id), seed)
|
|
234
|
+
set(shellFitRequestedWidthAtom(id), seed.panelWidth)
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
/** Drop every atom for `id`. `useShellFit` calls this on unmount for shells it
|
|
239
|
+
* keyed itself; an explicit `shellId` is the app's key and outlives its
|
|
240
|
+
* component, so a resized panel survives a route change. */
|
|
241
|
+
export function removeShellFitInstance(id: ShellFitInstanceId): void {
|
|
242
|
+
for (const family of families) family.remove(id)
|
|
243
|
+
}
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
import { MotionIcon } from "motion-icons-react"
|
|
4
4
|
import * as React from "react"
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
AnimatedPanel
|
|
8
|
-
} from "./animated-panel.tsx"
|
|
6
|
+
import { AnimatedPanel } from "./animated-panel.tsx"
|
|
9
7
|
import { cn } from "@/lib/utils"
|
|
10
8
|
import type { ShellLayoutProps, ShellPanelControlProps } from "./type"
|
|
11
9
|
import { TITLE_BAR_HEIGHT } from "./utils"
|
|
@@ -23,82 +21,86 @@ export function ShellLayout({
|
|
|
23
21
|
defaultOpen,
|
|
24
22
|
...panelControlProps
|
|
25
23
|
}: ShellLayoutProps): React.ReactElement {
|
|
26
|
-
|
|
27
24
|
const background: ClassNameValue = `bg-linear-to-tr/increasing from-neutral-200 via-neutral-100 to-stone-200 dark:from-neutral-900 dark:via-neutral-950 dark:to-stone-950`
|
|
28
25
|
|
|
29
26
|
// Each panel seeds its own side's initial state (`defaultOpen ?? open`);
|
|
30
27
|
// an explicit `defaultOpen` record — usually from useShellPanels — wins
|
|
31
28
|
// per side.
|
|
32
29
|
const initialOpen: ShellPanelControlProps["defaultOpen"] = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
...(panels?.left && {
|
|
31
|
+
left: panels.left.defaultOpen ?? true,
|
|
32
|
+
}),
|
|
33
|
+
...(panels?.right && {
|
|
34
|
+
right: panels.right.defaultOpen ?? false,
|
|
35
|
+
}),
|
|
36
|
+
...defaultOpen,
|
|
37
|
+
}
|
|
42
38
|
|
|
43
39
|
return (
|
|
44
40
|
<AnimatedPanelProvider
|
|
45
41
|
{...panelControlProps}
|
|
46
42
|
defaultOpen={initialOpen}
|
|
47
|
-
className={cn("relative h-full min-h-0 pb-2", className, background)}
|
|
43
|
+
className={cn("relative h-full min-h-0 px-2 pb-2", className, background)}
|
|
48
44
|
style={{
|
|
49
45
|
paddingTop: variant === "desktop" ? TITLE_BAR_HEIGHT : 0,
|
|
50
46
|
}}
|
|
51
47
|
>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
{panels?.left?.component && (
|
|
49
|
+
<AnimatedPanel
|
|
50
|
+
ariaLabel="Primary navigation"
|
|
51
|
+
collapsible="icon"
|
|
52
|
+
role="navigation"
|
|
53
|
+
variant="inset"
|
|
54
|
+
>
|
|
55
|
+
{panels?.left?.component}
|
|
56
|
+
</AnimatedPanel>
|
|
57
|
+
)}
|
|
60
58
|
|
|
61
|
-
<AnimatedPanelInset
|
|
62
|
-
<header className="flex h-12 items-center justify-between gap-2 border-b px-2">
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
59
|
+
<AnimatedPanelInset>
|
|
60
|
+
<header className="flex h-12 flex-row! items-center justify-between gap-2 border-b px-2">
|
|
61
|
+
{panels?.left?.component && (
|
|
62
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
63
|
+
<AnimatedPanelTrigger side="left">
|
|
66
64
|
<MotionIcon name="PanelLeft" size={24} animation="press" />
|
|
67
|
-
|
|
68
|
-
</
|
|
69
|
-
|
|
70
|
-
{header &&
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
</AnimatedPanelTrigger>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
{header && (
|
|
69
|
+
<div className="flex w-full flex-1 items-center">{header}</div>
|
|
70
|
+
)}
|
|
71
|
+
{panels?.right?.component && (
|
|
72
|
+
<div className="flex flex-1 items-center justify-end gap-2">
|
|
73
|
+
<AnimatedPanelTrigger aria-label="Toggle panel" side="right">
|
|
74
74
|
<MotionIcon
|
|
75
75
|
className="opacity-70"
|
|
76
76
|
name="PanelRight"
|
|
77
77
|
size={24}
|
|
78
78
|
/>
|
|
79
|
-
|
|
80
|
-
</
|
|
81
|
-
|
|
79
|
+
</AnimatedPanelTrigger>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
82
|
</header>
|
|
83
83
|
<div className="min-h-24 flex-1 overflow-auto">{children}</div>
|
|
84
84
|
</AnimatedPanelInset>
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
86
|
+
{panels?.right?.component && (
|
|
87
|
+
<AnimatedPanel
|
|
88
|
+
ariaLabel={panels.right.name ?? "Secondary panel"}
|
|
89
|
+
// Below md the panel is portal led over the page, so it carries the
|
|
90
|
+
// surface itself; the desktop rail keeps it on the inner panel.
|
|
91
|
+
className={cn(
|
|
92
|
+
"bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900",
|
|
93
|
+
"outline-offset-0.5 border-t-3 border-white outline-neutral-100 dark:inset-ring-black",
|
|
94
|
+
"rounded-lg shadow-md shadow-neutral-200 dark:shadow-neutral-950"
|
|
95
|
+
)}
|
|
96
|
+
collapsible="offcanvas"
|
|
97
|
+
role="complementary"
|
|
98
|
+
side="right"
|
|
99
|
+
variant="inset"
|
|
100
|
+
>
|
|
101
|
+
{panels.right.component}
|
|
102
|
+
</AnimatedPanel>
|
|
103
|
+
)}
|
|
102
104
|
</AnimatedPanelProvider>
|
|
103
105
|
)
|
|
104
106
|
}
|