@exegia/corpora-ui 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/ai-sidebar-atom.d.ts +217 -0
- 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 +109 -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 +6 -1
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -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/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/constants.d.ts +4 -4
- package/dist-lib/components/composed/tree/index.d.ts +3 -1
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
- package/dist-lib/components/composed/tree/type.d.ts +116 -18
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +4 -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/component.d.ts +1 -8
- package/dist-lib/components/user-avatar/index.d.ts +5 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +45 -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/index.d.ts +5 -2
- package/dist-lib/index.js +3004 -1796
- 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 +15 -12
- 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/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
- 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 +125 -30
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
- package/src/components/blocks/nav/sidebar/utils.ts +1 -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/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 +162 -37
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
- package/src/components/composed/tree/constants.ts +4 -4
- package/src/components/composed/tree/index.ts +38 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-node.tsx +117 -78
- package/src/components/composed/tree/tree.tsx +24 -16
- package/src/components/composed/tree/type.ts +125 -15
- package/src/components/composed/tree/use-tree-dnd.ts +82 -51
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +185 -184
- package/src/components/composed/user-avatar.tsx +11 -99
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
- package/src/components/user-avatar/component.tsx +121 -22
- package/src/components/user-avatar/index.ts +19 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +50 -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/index.ts +34 -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,49 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react"
|
|
4
|
+
import { useAtomValue, useSetAtom } from "jotai"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
resetShellPanelWidthAtom,
|
|
8
|
+
resizeShellPanelAtom,
|
|
9
|
+
shellFitStateAtom,
|
|
10
|
+
} from "./shell-fit-atom"
|
|
11
|
+
import type { ShellFitActions, ShellFitInstanceId, ShellFitState } from "./type"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read the fit of the shell registered under `shellId` from anywhere below
|
|
15
|
+
* `ExegiaProvider` — no controller, no props, no provider of its own.
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* const { fits, panelWidth } = useShellFitState("app-shell")
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* This returns the whole state object, so the caller re-renders on every
|
|
22
|
+
* measurement. A component that reads one field should subscribe to that
|
|
23
|
+
* field's atom instead: `useAtomValue(shellFitFitsAtom("app-shell"))`.
|
|
24
|
+
*/
|
|
25
|
+
export function useShellFitState(shellId: ShellFitInstanceId): ShellFitState {
|
|
26
|
+
return useAtomValue(shellFitStateAtom(shellId))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drive the secondary panel of the shell registered under `shellId` from
|
|
31
|
+
* anywhere. Writes only — the caller never re-renders when the shell moves,
|
|
32
|
+
* so this is what a command palette or a keyboard shortcut should reach for.
|
|
33
|
+
*
|
|
34
|
+
* ```tsx
|
|
35
|
+
* const shell = useShellFitActions("app-shell")
|
|
36
|
+
* <Button onClick={() => shell.resizePanel(480)}>Wide inspector</Button>
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function useShellFitActions(
|
|
40
|
+
shellId: ShellFitInstanceId
|
|
41
|
+
): ShellFitActions {
|
|
42
|
+
const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
|
|
43
|
+
const resetPanelWidth = useSetAtom(resetShellPanelWidthAtom(shellId))
|
|
44
|
+
|
|
45
|
+
return useMemo(
|
|
46
|
+
() => ({ resizePanel, resetPanelWidth }),
|
|
47
|
+
[resizePanel, resetPanelWidth]
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react"
|
|
4
|
+
import { useAtomValue, useSetAtom } from "jotai"
|
|
5
|
+
import { useIsomorphicLayoutEffect } from "motion/react"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
measureShellFitAtom,
|
|
9
|
+
mountShellFitAtom,
|
|
10
|
+
removeShellFitInstance,
|
|
11
|
+
resetShellPanelWidthAtom,
|
|
12
|
+
resizeShellPanelAtom,
|
|
13
|
+
shellFitStateAtom,
|
|
14
|
+
} from "./shell-fit-atom"
|
|
15
|
+
import { fitsPanel, type ShellMetrics } from "./shell-metrics"
|
|
16
|
+
import type {
|
|
17
|
+
ShellFitController,
|
|
18
|
+
ShellFitSeed,
|
|
19
|
+
UseShellFitOptions,
|
|
20
|
+
} from "./type"
|
|
21
|
+
import { resolveLength } from "./utils"
|
|
22
|
+
|
|
23
|
+
/** Read the shell's columns out of the DOM. The widths come from CSS
|
|
24
|
+
* variables; the rail's share comes from its own state, not its box, so a
|
|
25
|
+
* fold counts the moment React commits it rather than a frame later when the
|
|
26
|
+
* width animation has moved. */
|
|
27
|
+
function readMetrics(host: HTMLElement, railOpen: boolean): ShellMetrics {
|
|
28
|
+
// Whether a shell has a rail at all is the caller's composition, so it is
|
|
29
|
+
// read off the DOM rather than tracked in state.
|
|
30
|
+
const rail = host.querySelector<HTMLElement>(
|
|
31
|
+
'[data-slot="sidebar"][data-side="left"]'
|
|
32
|
+
)
|
|
33
|
+
const collapsible = rail?.dataset.collapsible
|
|
34
|
+
|
|
35
|
+
const frame = getComputedStyle(host)
|
|
36
|
+
const gap = Number.parseFloat(frame.columnGap) || 0
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
rail: !rail
|
|
40
|
+
? 0
|
|
41
|
+
: railOpen || collapsible === "none"
|
|
42
|
+
? resolveLength(host, "var(--sidebar-width)")
|
|
43
|
+
: collapsible === "offcanvas"
|
|
44
|
+
? 0
|
|
45
|
+
: resolveLength(host, "var(--sidebar-width-icon)"),
|
|
46
|
+
insetMin: resolveLength(host, "var(--inset-min-width)"),
|
|
47
|
+
panelMin: resolveLength(host, "var(--panel-width)"),
|
|
48
|
+
viewport: window.innerWidth,
|
|
49
|
+
chrome:
|
|
50
|
+
(Number.parseFloat(frame.paddingLeft) || 0) +
|
|
51
|
+
(Number.parseFloat(frame.paddingRight) || 0) +
|
|
52
|
+
gap * Math.max(0, host.children.length - 1),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Measures the shell and decides what the secondary panel may do: whether it
|
|
58
|
+
* exists at all, and how wide it may be dragged.
|
|
59
|
+
*
|
|
60
|
+
* The three things that move the answer are all observed here — the viewport
|
|
61
|
+
* (`resize`), the rail's fold (`railOpen`, a render input) and the user's own
|
|
62
|
+
* drag (`resizePanel`) — so no caller has to re-derive it. The measurement is
|
|
63
|
+
* the only thing this hook keeps to itself: the numbers land in the shell-fit
|
|
64
|
+
* atoms keyed by `shellId`, so anything under `ExegiaProvider` can read the
|
|
65
|
+
* verdict (`useShellFitState`) or move the panel (`useShellFitActions`)
|
|
66
|
+
* without holding this controller.
|
|
67
|
+
*/
|
|
68
|
+
export function useShellFit({
|
|
69
|
+
shellId: explicitId,
|
|
70
|
+
hostRef,
|
|
71
|
+
railOpen,
|
|
72
|
+
defaultPanelWidth,
|
|
73
|
+
onUnfit,
|
|
74
|
+
}: UseShellFitOptions): ShellFitController {
|
|
75
|
+
// A generated key isolates unnamed shells from each other; an explicit
|
|
76
|
+
// `shellId` is the app's handle on this one.
|
|
77
|
+
const generatedId = useId()
|
|
78
|
+
const shellId = explicitId ?? generatedId
|
|
79
|
+
|
|
80
|
+
// Read once: `defaultPanelWidth` describes the mount, not every render.
|
|
81
|
+
const [seed] = useState<ShellFitSeed>(() => ({
|
|
82
|
+
panelWidth: defaultPanelWidth ?? null,
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
const mount = useSetAtom(mountShellFitAtom(shellId))
|
|
86
|
+
const measure = useSetAtom(measureShellFitAtom(shellId))
|
|
87
|
+
const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
|
|
88
|
+
const resetPanelWidth = useSetAtom(resetShellPanelWidthAtom(shellId))
|
|
89
|
+
|
|
90
|
+
// Before paint, and before the first measurement below, so a seeded width
|
|
91
|
+
// is in the store by the time the panel first reads its width.
|
|
92
|
+
useIsomorphicLayoutEffect(() => {
|
|
93
|
+
mount(seed)
|
|
94
|
+
}, [mount, seed])
|
|
95
|
+
|
|
96
|
+
// The listener below is bound once per fold, so the callback rides a ref
|
|
97
|
+
// rather than rebinding it whenever a consumer passes a fresh arrow.
|
|
98
|
+
const onUnfitRef = useRef(onUnfit)
|
|
99
|
+
useIsomorphicLayoutEffect(() => {
|
|
100
|
+
onUnfitRef.current = onUnfit
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
useIsomorphicLayoutEffect(() => {
|
|
104
|
+
const host = hostRef.current
|
|
105
|
+
if (!host) return
|
|
106
|
+
|
|
107
|
+
const takeMeasurement = () => {
|
|
108
|
+
const next = readMetrics(host, railOpen)
|
|
109
|
+
measure(next)
|
|
110
|
+
if (!fitsPanel(next)) onUnfitRef.current?.()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Measured before paint so a shell that cannot hold the panel never
|
|
114
|
+
// flashes one, and re-measured on every fold because the rail's column is
|
|
115
|
+
// part of the room the panel needs.
|
|
116
|
+
takeMeasurement()
|
|
117
|
+
window.addEventListener("resize", takeMeasurement)
|
|
118
|
+
return () => window.removeEventListener("resize", takeMeasurement)
|
|
119
|
+
}, [hostRef, railOpen, measure])
|
|
120
|
+
|
|
121
|
+
// A shell the hook keyed is scrap once its component goes. An explicit
|
|
122
|
+
// `shellId` is the app's key and outlives the mount — that is what lets a
|
|
123
|
+
// dragged width survive a route change.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (explicitId !== undefined) return
|
|
126
|
+
return () => removeShellFitInstance(shellId)
|
|
127
|
+
}, [shellId, explicitId])
|
|
128
|
+
|
|
129
|
+
const state = useAtomValue(shellFitStateAtom(shellId))
|
|
130
|
+
|
|
131
|
+
return useMemo<ShellFitController>(
|
|
132
|
+
() => ({ shellId, ...state, resizePanel, resetPanelWidth }),
|
|
133
|
+
[shellId, state, resizePanel, resetPanelWidth]
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useCallback, useMemo, useState } from "react"
|
|
3
|
+
import { useCallback, useEffect, useId, useMemo, useState } from "react"
|
|
4
|
+
import { useAtomValue, useSetAtom } from "jotai"
|
|
4
5
|
|
|
6
|
+
import {
|
|
7
|
+
removeShellFitInstance,
|
|
8
|
+
resizeShellPanelAtom,
|
|
9
|
+
shellFitFitsAtom,
|
|
10
|
+
shellFitPanelWidthAtom,
|
|
11
|
+
} from "./shell-fit-atom"
|
|
5
12
|
import type {
|
|
6
13
|
ShellPanelControlProps,
|
|
7
14
|
ShellPanelControls,
|
|
@@ -15,12 +22,36 @@ import type {
|
|
|
15
22
|
* callback. Spread the returned `providerProps` onto ShellLayout; the
|
|
16
23
|
* setters and `toggle` are for UI that lives outside the shell (title-bar
|
|
17
24
|
* buttons, command palette, shortcuts).
|
|
25
|
+
*
|
|
26
|
+
* The way back down is the store: `providerProps` carries a `shellId`, the
|
|
27
|
+
* shell files its measurement under it, and this hook reads `isNarrow` and
|
|
28
|
+
* `panelWidth` straight out of those atoms — so outside UI stands down with
|
|
29
|
+
* the panel instead of measuring `--sidebar-width` a second time. Name the
|
|
30
|
+
* `shellId` and any component below `ExegiaProvider` can do the same with
|
|
31
|
+
* `useShellFitState(shellId)`.
|
|
32
|
+
*
|
|
33
|
+
* Call it under the same `ExegiaProvider` as the shell it drives (or under
|
|
34
|
+
* none at all, on both sides): mounted above the provider it would read
|
|
35
|
+
* Jotai's default store while the shell writes to the provider's.
|
|
18
36
|
*/
|
|
19
37
|
export function useShellPanels({
|
|
38
|
+
shellId: explicitId,
|
|
20
39
|
defaultOpen,
|
|
21
40
|
defaultOpenMobile,
|
|
41
|
+
defaultPanelWidth,
|
|
22
42
|
onPanelChange,
|
|
23
43
|
}: UseShellPanelsOptions = {}): ShellPanelControls {
|
|
44
|
+
// The hook, not the provider, keys the shell: it renders above the provider
|
|
45
|
+
// and has to read the same atoms the provider writes. Whoever generates the
|
|
46
|
+
// key drops it — the provider treats a passed-in id as the app's and leaves
|
|
47
|
+
// it alone on unmount.
|
|
48
|
+
const generatedId = useId()
|
|
49
|
+
const shellId = explicitId ?? generatedId
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (explicitId !== undefined) return
|
|
52
|
+
return () => removeShellFitInstance(shellId)
|
|
53
|
+
}, [shellId, explicitId])
|
|
54
|
+
|
|
24
55
|
const [open, setOpenState] = useState<Record<SidebarSide, boolean>>(() => ({
|
|
25
56
|
left: defaultOpen?.left ?? true,
|
|
26
57
|
right: defaultOpen?.right ?? false,
|
|
@@ -32,24 +63,40 @@ export function useShellPanels({
|
|
|
32
63
|
right: defaultOpenMobile?.right ?? false,
|
|
33
64
|
}))
|
|
34
65
|
|
|
66
|
+
// Reads false until the shell has measured itself — an unmeasured shell
|
|
67
|
+
// fails open, so nothing out here stands down over a reading that has not
|
|
68
|
+
// happened yet.
|
|
69
|
+
const fits = useAtomValue(shellFitFitsAtom(shellId))
|
|
70
|
+
const isNarrow = !fits
|
|
71
|
+
const panelWidth = useAtomValue(shellFitPanelWidthAtom(shellId))
|
|
72
|
+
const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
|
|
73
|
+
|
|
74
|
+
// The shell drops the right panel when the viewport cannot hold it, so
|
|
75
|
+
// opening it from out here would only surface later as a panel nobody
|
|
76
|
+
// asked for. Closing always goes through — that is how state left over
|
|
77
|
+
// from a wider viewport clears.
|
|
35
78
|
const setOpen = useCallback(
|
|
36
79
|
(nextOpen: boolean, side: SidebarSide) => {
|
|
80
|
+
if (nextOpen && side === "right" && isNarrow) return
|
|
81
|
+
|
|
37
82
|
setOpenState((prev) =>
|
|
38
83
|
prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
|
|
39
84
|
)
|
|
40
85
|
onPanelChange?.(nextOpen, side)
|
|
41
86
|
},
|
|
42
|
-
[onPanelChange]
|
|
87
|
+
[isNarrow, onPanelChange]
|
|
43
88
|
)
|
|
44
89
|
|
|
45
90
|
const setOpenMobile = useCallback(
|
|
46
91
|
(nextOpen: boolean, side: SidebarSide) => {
|
|
92
|
+
if (nextOpen && side === "right" && isNarrow) return
|
|
93
|
+
|
|
47
94
|
setOpenMobileState((prev) =>
|
|
48
95
|
prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
|
|
49
96
|
)
|
|
50
97
|
onPanelChange?.(nextOpen, side)
|
|
51
98
|
},
|
|
52
|
-
[onPanelChange]
|
|
99
|
+
[isNarrow, onPanelChange]
|
|
53
100
|
)
|
|
54
101
|
|
|
55
102
|
const toggle = useCallback(
|
|
@@ -59,15 +106,21 @@ export function useShellPanels({
|
|
|
59
106
|
|
|
60
107
|
const providerProps = useMemo<ShellPanelControlProps>(
|
|
61
108
|
() => ({
|
|
109
|
+
shellId,
|
|
110
|
+
defaultPanelWidth,
|
|
62
111
|
open,
|
|
63
112
|
onOpenChange: setOpen,
|
|
64
113
|
openMobile,
|
|
65
114
|
onOpenMobileChange: setOpenMobile,
|
|
66
115
|
}),
|
|
67
|
-
[open, openMobile, setOpen, setOpenMobile]
|
|
116
|
+
[shellId, defaultPanelWidth, open, openMobile, setOpen, setOpenMobile]
|
|
68
117
|
)
|
|
69
118
|
|
|
70
119
|
return {
|
|
120
|
+
shellId,
|
|
121
|
+
isNarrow,
|
|
122
|
+
panelWidth,
|
|
123
|
+
resizePanel,
|
|
71
124
|
open,
|
|
72
125
|
openMobile,
|
|
73
126
|
setOpen,
|
|
@@ -92,6 +92,49 @@ export const FOCUSABLE_SELECTOR = [
|
|
|
92
92
|
export const MOBILE_QUERY = "(max-width: 767px)"
|
|
93
93
|
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The shell's layout contract, in px. Every entry lands on the provider's
|
|
97
|
+
* wrapper as a CSS variable, so a consumer overrides a column by restyling it
|
|
98
|
+
* rather than by passing a prop:
|
|
99
|
+
*
|
|
100
|
+
* | variable | column |
|
|
101
|
+
* | ---------------------- | ----------------------------------------- |
|
|
102
|
+
* | `--sidebar-width` | left rail, expanded |
|
|
103
|
+
* | `--sidebar-width-icon` | left rail, folded to icons |
|
|
104
|
+
* | `--panel-width` | secondary panel: its floor AND its default |
|
|
105
|
+
* | `--inset-min-width` | the body's floor |
|
|
106
|
+
*
|
|
107
|
+
* These are only the defaults. Every measurement resolves the live variable,
|
|
108
|
+
* so an override wins over the value written here.
|
|
109
|
+
*/
|
|
110
|
+
export const SHELL_WIDTHS = {
|
|
111
|
+
"--sidebar-width": "256px",
|
|
112
|
+
"--sidebar-width-icon": "56px",
|
|
113
|
+
"--sidebar-width-mobile": "18rem",
|
|
114
|
+
"--panel-width": "320px",
|
|
115
|
+
"--inset-min-width": "360px",
|
|
116
|
+
} as const
|
|
117
|
+
|
|
118
|
+
/** The expanded width of a panel docked to `side`: the secondary panel opens
|
|
119
|
+
* at `--panel-width`, the primary rail at `--sidebar-width`. */
|
|
120
|
+
export function expandedWidthVar(side: "left" | "right") {
|
|
121
|
+
return side === "right" ? "var(--panel-width)" : "var(--sidebar-width)"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Resolve a CSS length — `var()` included — to px inside `host`'s cascade.
|
|
125
|
+
* Custom properties inherit, so a throwaway probe mounted in `host` reads the
|
|
126
|
+
* very `--sidebar-width` the shell lays out with, including a value a
|
|
127
|
+
* consumer overrode on the provider. Returns 0 when `host` has no layout
|
|
128
|
+
* (server render, `display: none`), so callers must fail open on 0 rather
|
|
129
|
+
* than treat it as a real measurement. */
|
|
130
|
+
export function resolveLength(host: HTMLElement, value: string) {
|
|
131
|
+
const probe = document.createElement("div")
|
|
132
|
+
probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;width:${value}`
|
|
133
|
+
host.appendChild(probe)
|
|
134
|
+
const px = probe.getBoundingClientRect().width
|
|
135
|
+
probe.remove()
|
|
136
|
+
return px
|
|
137
|
+
}
|
|
95
138
|
|
|
96
139
|
export const AnimatedSidebarContext =
|
|
97
140
|
createContext<AnimatedSidebarContextValue | null>(null)
|
|
@@ -131,13 +174,10 @@ export function useIsMobile() {
|
|
|
131
174
|
)
|
|
132
175
|
}
|
|
133
176
|
|
|
134
|
-
|
|
135
177
|
export function useAnimatedSidebarPanel() {
|
|
136
178
|
const context = useContext(AnimatedSidebarPanelContext)
|
|
137
179
|
if (!context) {
|
|
138
|
-
throw new Error(
|
|
139
|
-
"Animated Sidebar parts must be used inside AnimatedPanel."
|
|
140
|
-
)
|
|
180
|
+
throw new Error("Animated Sidebar parts must be used inside AnimatedPanel.")
|
|
141
181
|
}
|
|
142
182
|
return context
|
|
143
183
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# composed/tree
|
|
2
|
+
|
|
3
|
+
Nested item tree in four shapes — `navigation`, `toc`, `sidebar` (icon rail),
|
|
4
|
+
`files` (explorer). It is the **reference implementation** of the Jotai
|
|
5
|
+
state pattern described in `react/CLAUDE.md` ("State" section) — read that
|
|
6
|
+
first; this file only holds what is specific to the tree.
|
|
7
|
+
|
|
8
|
+
## File map
|
|
9
|
+
|
|
10
|
+
| File | Owns |
|
|
11
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
12
|
+
| `type.ts` | `TreeNode`, `TreeProps` (data form × controller form), `TreeState`/`TreeActions`, `@internal` config/handlers/seed. No imports besides React types. |
|
|
13
|
+
| `tree-atom.ts` | Every atom, keyed by `treeId` (`keyed()` families) and per node (`nodeFamily`). All mutations are write-only action atoms. `debugLabel` = `tree/<id>/<name>`. |
|
|
14
|
+
| `use-tree.ts` | `useTree(options)` — mounts an instance, projects controlled props into the store, returns the `TreeController`. |
|
|
15
|
+
| `use-tree-state.ts` | `useTreeState(id)` (reads, re-renders on any change) and `useTreeActions(id)` (writes only, never re-renders). |
|
|
16
|
+
| `use-tree-dnd.ts` | Stable drag handlers for `files`; they read `draggedId`/`dropTarget` out of the store at call time. |
|
|
17
|
+
| `tree-context.ts` | Context value = `{ treeId, renderTrailing, dnd }` only. Never the controller. |
|
|
18
|
+
| `tree.tsx` | `Tree` (props form → `UncontrolledTree`, controller form → `TreeView`), rail width measurement, roving keyboard nav. |
|
|
19
|
+
| `tree-node.tsx` | `TreeRow` (memoized). Per-node atom subscriptions, row kind resolution, rename input, tooltip, toc overlay toggle, trailing slot, branch animation. |
|
|
20
|
+
| `constants.ts` | Durations, easings, `RAIL_COLLAPSED_WIDTH`, motion variants. |
|
|
21
|
+
| `utils.ts` | Pure tree helpers (`findNode`, `moveNode`, `renameNode`, ancestor walks). |
|
|
22
|
+
| `index.ts` | Barrel. Public atoms are listed explicitly — never `export *`. |
|
|
23
|
+
| `__tests__/` | `tree.test.tsx` (rendering per variant), `use-tree.test.tsx` (controller/hook), `tree-atom.test.tsx` (store + memo guard). |
|
|
24
|
+
|
|
25
|
+
## Row anatomy (as of 2026-08-18)
|
|
26
|
+
|
|
27
|
+
- **Every non-section row is the shared `ui/button` `Button`** (`variant="ghost"`,
|
|
28
|
+
or `"secondary"` for the active sidebar row). Section headings in a 3-level
|
|
29
|
+
`navigation` tree stay a plain `<button>`. **Nothing renders an `<a>` any
|
|
30
|
+
more.**
|
|
31
|
+
- `TreeNode.href` / `target` are **metadata** — they ride along on the node
|
|
32
|
+
handed to `onNavigate`. Routing has exactly one path:
|
|
33
|
+
`selectTreeNodeAtom` → `node.onSelect?.()` → `handlers.onNavigate?.(node)`.
|
|
34
|
+
One native behaviour survives the anchor removal: `toc` rows below the
|
|
35
|
+
route level (depth > 0, or any toc row with a `#hash` href) set
|
|
36
|
+
`window.location.hash` **after** `select` in `handlePress` — same order the
|
|
37
|
+
old `<a>` default gave, same `:target`/history semantics. A non-hash href
|
|
38
|
+
on a toc row disables the jump (it is a route). Controller `select()` from
|
|
39
|
+
outside does NOT jump — the hash write lives in the row, not the atom,
|
|
40
|
+
because it is a DOM concern.
|
|
41
|
+
- Row-level `data-*` (`data-slot="tree-row"`, `data-id`, `data-branch`,
|
|
42
|
+
`data-expanded`, `data-active`, `data-cuelume-press`) come from
|
|
43
|
+
`rowInteractionProps`, spread onto the Button; Base UI's `mergeProps` lets
|
|
44
|
+
them override Button's own `data-slot="button"`. `sound` is forwarded to the
|
|
45
|
+
Button so its press/release cues follow the tree's `sound` prop.
|
|
46
|
+
- Collapsed sidebar rows are `h-10! rounded-xl! justify-center px-0` tiles;
|
|
47
|
+
the rail is `w-10` (40px). `RAIL_COLLAPSED_WIDTH` **must equal** the class
|
|
48
|
+
(a test pins it to 40) — the motion target and the resting class must agree
|
|
49
|
+
or the rail jumps on its first fold.
|
|
50
|
+
- Row kinds (`rowKindOf`): `link` (selectable, `aria-expanded` absent),
|
|
51
|
+
`toggle` (parent in navigation/files, `aria-expanded` on the row),
|
|
52
|
+
`section` (top level of a 3-level navigation tree). `toc` parents are
|
|
53
|
+
`link` rows with a separate overlay `<button aria-label="Expand X">`.
|
|
54
|
+
- The trailing slot (`renderTrailing`, files only) is a **sibling** of the row
|
|
55
|
+
inside `.group/row-actions`, never a child — a button inside a button is
|
|
56
|
+
invalid HTML and React warns.
|
|
57
|
+
|
|
58
|
+
## Lessons learned (chronological)
|
|
59
|
+
|
|
60
|
+
1. **Rows read per-node atoms, not the controller.** Reading the whole
|
|
61
|
+
controller off context re-rendered every row on every toggle.
|
|
62
|
+
`tree-atom.test.tsx` counts `renderTrailing` calls per node to guard the
|
|
63
|
+
memo + per-node subscriptions. (0021dee)
|
|
64
|
+
2. **Controlled props are projected, not mirrored.** `useTree` writes
|
|
65
|
+
controlled `items`/`activeId`/`collapsed` into the store in a layout effect
|
|
66
|
+
with write gates. `treeOwnedItemsAtom` reads empty while a prop owns the
|
|
67
|
+
data — without it an inline `items={[…]}` array loops forever.
|
|
68
|
+
3. **Keep the rail label mounted.** Unmounting the label on collapse killed
|
|
69
|
+
the fold animation; it animates `width/opacity/x/display` in place and the
|
|
70
|
+
test asserts `display: none`, not absence. (12b6a1f)
|
|
71
|
+
4. **Both rail width endpoints must be px.** Motion cannot tween a number
|
|
72
|
+
against `"100%"`; doing so pinned the inline width at the collapsed value
|
|
73
|
+
and the rail never reopened. The expanded width is measured off the
|
|
74
|
+
container, only while expanded, with the list's inline width cleared for
|
|
75
|
+
the sample. (a69072d, 2f7539f)
|
|
76
|
+
5. **Tooltip is disabled, not unmounted, when the rail expands.** Swapping the
|
|
77
|
+
wrapper remounts the row and cuts the label fold short. Test asserts row
|
|
78
|
+
identity across collapse.
|
|
79
|
+
6. **Trailing actions live beside the row.** See "Row anatomy". (2c59e53)
|
|
80
|
+
7. **Rows became `Button`; anchors were removed** (2026-08-18, this branch).
|
|
81
|
+
Consequences that had to follow: tests query `role: "button"` everywhere,
|
|
82
|
+
`href` documentation flipped from "renders an anchor" to "metadata for
|
|
83
|
+
`onNavigate`", `RAIL_COLLAPSED_WIDTH` 44 → 40 to match `w-10`, `sound`
|
|
84
|
+
forwarded to Button, and the toc `#{id}` anchor test was rewritten as
|
|
85
|
+
"row selects, overlay toggle expands". The native `#{id}` jump for toc
|
|
86
|
+
rows was then restored via `location.hash` in `handlePress` (see "Row
|
|
87
|
+
anatomy") — dropping it silently was a regression, not a simplification.
|
|
88
|
+
8. **`settleExit()` must run inside `act`.** AnimatePresence exits finishing
|
|
89
|
+
during a bare `setTimeout` wait produce "not wrapped in act" warnings;
|
|
90
|
+
`act(() => new Promise(r => setTimeout(r, 400)))` keeps them tracked. Query
|
|
91
|
+
**once** after settling — happy-dom serves stale `waitFor` results while an
|
|
92
|
+
exit is in flight (see memory `corpora-ui-happy-dom-exit-animations`).
|
|
93
|
+
|
|
94
|
+
## Testing notes
|
|
95
|
+
|
|
96
|
+
- Run from `react/`: `bun test src/components/composed/tree`.
|
|
97
|
+
- happy-dom cannot drive Base UI hover/focus-visible, so tooltip tests assert
|
|
98
|
+
the wiring (`data-base-ui-tooltip-trigger`) not the popup.
|
|
99
|
+
- happy-dom rects are zero-height, so DnD tests hit the 0.5-midpoint fallback
|
|
100
|
+
("inside" for folders, "after" for leaves).
|
|
101
|
+
- Motion sets no inline styles under happy-dom — the rail width tests lock
|
|
102
|
+
the **class**; the px tween is only verifiable in a real browser.
|
|
103
|
+
- Browser check via the docs demo (`/components/tree`, dev server config
|
|
104
|
+
`corpora-ui-docs`): the demo's Base UI `Select` only takes clicks on the
|
|
105
|
+
option's real viewport rect (screenshot frame is scaled — convert), and if
|
|
106
|
+
the Browser pane tab is hidden (`document.hidden`) both WAAPI and motion's
|
|
107
|
+
frameloop pause, so animated end-states can't be observed there — front the
|
|
108
|
+
tab or use a real browser for the fold.
|
|
109
|
+
|
|
110
|
+
## Change log
|
|
111
|
+
|
|
112
|
+
- **2026-08-18 — fix/tree-collapsed** (on `release/v0.21.0`)
|
|
113
|
+
- `tree-node.tsx`: link/toggle rows render the shared `Button` (ghost /
|
|
114
|
+
secondary when active on sidebar); anchor branch removed; collapsed rail
|
|
115
|
+
rows `h-10! rounded-xl!`; rename input borderless; `cursor-pointer` on
|
|
116
|
+
rows/toggles; `sound` forwarded to Button.
|
|
117
|
+
- `tree.tsx`: rail resting class `w-11` → `w-10`, `gap-y-2!` when collapsed.
|
|
118
|
+
- `constants.ts`: `RAIL_COLLAPSED_WIDTH` 44 → 40.
|
|
119
|
+
- `type.ts` / `registry/components.ts`: `href`, `onNavigate`, `navigation`
|
|
120
|
+
and `toc` docs describe button rows + `onNavigate`-only navigation.
|
|
121
|
+
- `__tests__/tree.test.tsx`: link → button queries; new tests for `href`
|
|
122
|
+
passthrough, `sound={false}`, `RAIL_COLLAPSED_WIDTH` ↔ `w-10`, collapsed
|
|
123
|
+
tile classes, active-row `secondary` variant, toc row-vs-toggle split,
|
|
124
|
+
toc `#{id}` / `#hash` jump after `onNavigate` (route rows leave the hash
|
|
125
|
+
alone).
|
|
126
|
+
- `__tests__/*.test.tsx`: `settleExit` wrapped in `act`.
|
|
127
|
+
- **2026-08-17** — Jotai migration (`ExegiaProvider`, atom families,
|
|
128
|
+
`useTree`/`useTreeState`/`useTreeActions`), rail reopen + measurement fixes,
|
|
129
|
+
label kept mounted, trailing actions moved out of the row, headless
|
|
130
|
+
controller API.
|
|
131
|
+
- **2026-08-16** — Initial four-variant tree, tooltip on collapsed rail rows,
|
|
132
|
+
motion + a11y pass.
|