@exegia/corpora-ui 0.20.0 → 0.22.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/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/layout.d.ts +4 -1
- 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 -12
- 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/__tests__/logo.test.d.ts +1 -0
- package/dist-lib/components/composed/logo.d.ts +34 -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 +53 -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 +37 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
- package/dist-lib/components/user-avatar/utils.d.ts +22 -0
- package/dist-lib/index.d.ts +6 -3
- package/dist-lib/index.js +3732 -2670
- 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/layout.ts +30 -2
- 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 -13
- 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/__tests__/logo.test.tsx +60 -0
- package/src/components/composed/logo.tsx +159 -0
- 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 +286 -0
- package/src/components/user-avatar/component.tsx +151 -22
- package/src/components/user-avatar/fallback.tsx +3 -1
- 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 +58 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +180 -0
- package/src/components/user-avatar/user-avatar-atom.ts +218 -0
- package/src/components/user-avatar/utils.ts +94 -0
- package/src/index.ts +35 -3
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
- package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
- package/dist-lib/components/blocks/nav/types.d.ts +0 -60
- package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
- package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
- package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
- package/src/components/blocks/nav/sidebar-block.tsx +0 -142
- package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
- package/src/components/blocks/nav/types.ts +0 -65
- package/src/components/blocks/nav/utils.ts +0 -4
- package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
- package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
- package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
- package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
- package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
- package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
- /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -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,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
|
|
4
|
+
import { Logo } from "../logo"
|
|
5
|
+
|
|
6
|
+
const root = () => document.querySelector<HTMLElement>('[data-slot="logo"]')
|
|
7
|
+
const wordmark = () =>
|
|
8
|
+
document.querySelector<HTMLElement>('[data-slot="logo-wordmark"]')
|
|
9
|
+
const monogram = () =>
|
|
10
|
+
document.querySelector<HTMLElement>('[data-slot="logo-monogram"]')
|
|
11
|
+
|
|
12
|
+
describe("Logo", () => {
|
|
13
|
+
test("defaults: monogram tile from the name, name as the wordmark", () => {
|
|
14
|
+
render(<Logo name="Corpora Codex" />)
|
|
15
|
+
expect(monogram()?.textContent).toBe("CC")
|
|
16
|
+
expect(wordmark()?.textContent).toBe("Corpora Codex")
|
|
17
|
+
expect(root()?.tagName).toBe("SPAN")
|
|
18
|
+
expect(root()?.getAttribute("data-variant")).toBe("full")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("a custom mark wins over src; src renders a decorative image", () => {
|
|
22
|
+
const { rerender } = render(
|
|
23
|
+
<Logo name="Corpora" src="https://example.com/mark.png" />
|
|
24
|
+
)
|
|
25
|
+
expect(monogram()).toBeNull()
|
|
26
|
+
expect(document.querySelector("img")?.getAttribute("alt")).toBe("")
|
|
27
|
+
rerender(
|
|
28
|
+
<Logo
|
|
29
|
+
name="Corpora"
|
|
30
|
+
src="https://example.com/mark.png"
|
|
31
|
+
mark={<svg data-testid="mark" />}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
expect(document.querySelector("img")).toBeNull()
|
|
35
|
+
expect(screen.getByTestId("mark")).toBeTruthy()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("href renders a link named for AT regardless of variant", () => {
|
|
39
|
+
render(<Logo name="Corpora" href="/" variant="mark" />)
|
|
40
|
+
const link = screen.getByRole("link", { name: "Corpora" })
|
|
41
|
+
expect(link.getAttribute("href")).toBe("/")
|
|
42
|
+
expect(link.getAttribute("data-slot")).toBe("logo")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("variant=mark folds the wordmark: hidden from AT, root keeps the name", () => {
|
|
46
|
+
const { rerender } = render(<Logo name="Corpora" variant="mark" />)
|
|
47
|
+
expect(wordmark()?.getAttribute("aria-hidden")).toBe("true")
|
|
48
|
+
expect(root()?.getAttribute("aria-label")).toBe("Corpora")
|
|
49
|
+
expect(root()?.getAttribute("data-variant")).toBe("mark")
|
|
50
|
+
// Unfolded, the visible wordmark is the name — no double label.
|
|
51
|
+
rerender(<Logo name="Corpora" variant="full" />)
|
|
52
|
+
expect(wordmark()?.getAttribute("aria-hidden")).toBeNull()
|
|
53
|
+
expect(root()?.getAttribute("aria-label")).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("a custom wordmark replaces the name text", () => {
|
|
57
|
+
render(<Logo name="Corpora" wordmark={<span>corpora/ui</span>} />)
|
|
58
|
+
expect(wordmark()?.textContent).toBe("corpora/ui")
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { motion, useReducedMotion } from "motion/react"
|
|
5
|
+
import type { Variants } from "motion/react"
|
|
6
|
+
|
|
7
|
+
import { EASE_OUT } from "@/lib/ease.ts"
|
|
8
|
+
import { cn } from "@/lib/utils"
|
|
9
|
+
import { initialsFrom } from "@/components/user-avatar"
|
|
10
|
+
|
|
11
|
+
/** `full` shows mark + wordmark; `mark` folds the wordmark away — an icon
|
|
12
|
+
* rail, a favicon-sized corner. */
|
|
13
|
+
export type LogoVariant = "full" | "mark"
|
|
14
|
+
|
|
15
|
+
export interface LogoProps
|
|
16
|
+
extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
|
|
17
|
+
/**
|
|
18
|
+
* Brand name. Labels the logo for AT (and the link, when `href` renders
|
|
19
|
+
* one), drives the default wordmark, and the monogram tile when no mark
|
|
20
|
+
* is given.
|
|
21
|
+
*/
|
|
22
|
+
name: string
|
|
23
|
+
/** Custom mark — an inline SVG sized to fill its box. Wins over `src`. */
|
|
24
|
+
mark?: React.ReactNode
|
|
25
|
+
/** Image URL for the mark. Decorative — `name` labels the logo. */
|
|
26
|
+
src?: string
|
|
27
|
+
/** Wordmark content. Defaults to `name`. */
|
|
28
|
+
wordmark?: React.ReactNode
|
|
29
|
+
/** `full` (default) or `mark` — the wordmark folds away, animated. */
|
|
30
|
+
variant?: LogoVariant
|
|
31
|
+
/** Renders the logo as a link — the usual "mark goes home" affordance. */
|
|
32
|
+
href?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wordmark fold/unfold — the same envelope the profile card's identity
|
|
36
|
+
* lines use, so a logo and a profile card folding in the same rail move as
|
|
37
|
+
* one. Kept local: composed components do not reach into blocks. Expressed
|
|
38
|
+
* as named variants for consistency with the profile card's fold envelope —
|
|
39
|
+
* house style for multi-property fold states, not a motion requirement. */
|
|
40
|
+
const WORDMARK_VARIANTS: Variants = {
|
|
41
|
+
open: {
|
|
42
|
+
width: "auto",
|
|
43
|
+
opacity: 1,
|
|
44
|
+
x: 0,
|
|
45
|
+
transition: { duration: 0.2, delay: 0.08, ease: EASE_OUT },
|
|
46
|
+
},
|
|
47
|
+
folded: {
|
|
48
|
+
width: 0,
|
|
49
|
+
opacity: 0,
|
|
50
|
+
x: -4,
|
|
51
|
+
transition: { duration: 0.12, ease: EASE_OUT },
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const WORDMARK_VARIANTS_REDUCED: Variants = {
|
|
56
|
+
open: { width: "auto", opacity: 1, x: 0, transition: { duration: 0.16, ease: EASE_OUT } },
|
|
57
|
+
folded: { width: 0, opacity: 0, x: 0, transition: { duration: 0.16, ease: EASE_OUT } },
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Brand lockup: a mark beside a wordmark. The mark is whatever the brand
|
|
62
|
+
* has — an SVG (`mark`), an image (`src`), or, given neither, a monogram
|
|
63
|
+
* tile derived from `name`. `variant="mark"` folds the wordmark away with
|
|
64
|
+
* the same motion a collapsing rail uses, so the lockup can sit in one and
|
|
65
|
+
* follow its fold. With `href` the whole lockup is a link named by `name`.
|
|
66
|
+
*
|
|
67
|
+
* Sizing rides on the mark: it defaults to `size-8`; restyle via
|
|
68
|
+
* `[&_[data-slot=logo-mark]]:size-*` or wrap in a text-size context for the
|
|
69
|
+
* wordmark, which inherits.
|
|
70
|
+
*/
|
|
71
|
+
export function Logo({
|
|
72
|
+
name,
|
|
73
|
+
mark,
|
|
74
|
+
src,
|
|
75
|
+
wordmark,
|
|
76
|
+
variant = "full",
|
|
77
|
+
href,
|
|
78
|
+
className,
|
|
79
|
+
...props
|
|
80
|
+
}: LogoProps): React.ReactElement {
|
|
81
|
+
const reduce = useReducedMotion()
|
|
82
|
+
const folded = variant === "mark"
|
|
83
|
+
|
|
84
|
+
const markNode = mark ?? (
|
|
85
|
+
src !== undefined ? (
|
|
86
|
+
// Decorative: the root's `name` (visible wordmark or aria-label)
|
|
87
|
+
// already names the brand.
|
|
88
|
+
<img alt="" className="size-full object-contain" src={src} />
|
|
89
|
+
) : (
|
|
90
|
+
<span
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
className="flex size-full items-center justify-center rounded-lg bg-foreground font-semibold text-background text-[45cqw]"
|
|
93
|
+
data-slot="logo-monogram"
|
|
94
|
+
>
|
|
95
|
+
{initialsFrom(name)}
|
|
96
|
+
</span>
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const content = (
|
|
101
|
+
<>
|
|
102
|
+
<span
|
|
103
|
+
className="size-8 shrink-0 @container [&_svg]:size-full"
|
|
104
|
+
data-slot="logo-mark"
|
|
105
|
+
>
|
|
106
|
+
{markNode}
|
|
107
|
+
</span>
|
|
108
|
+
<motion.span
|
|
109
|
+
animate={folded ? "folded" : "open"}
|
|
110
|
+
variants={reduce ? WORDMARK_VARIANTS_REDUCED : WORDMARK_VARIANTS}
|
|
111
|
+
// Folded, the wordmark is gone for AT too — the root's label names
|
|
112
|
+
// the brand on its own.
|
|
113
|
+
aria-hidden={folded || undefined}
|
|
114
|
+
className={cn(
|
|
115
|
+
"min-w-0 overflow-hidden font-semibold whitespace-nowrap tracking-tight",
|
|
116
|
+
folded && "pointer-events-none",
|
|
117
|
+
// 0px wide while folded, but the flex gap before it would still
|
|
118
|
+
// offset the mark — pull it back by that gap.
|
|
119
|
+
folded && "-ml-2"
|
|
120
|
+
)}
|
|
121
|
+
data-slot="logo-wordmark"
|
|
122
|
+
initial={false}
|
|
123
|
+
>
|
|
124
|
+
{wordmark ?? name}
|
|
125
|
+
</motion.span>
|
|
126
|
+
</>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const rootClassName = cn(
|
|
130
|
+
"inline-flex shrink-0 items-center gap-2 text-foreground",
|
|
131
|
+
className
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return href !== undefined ? (
|
|
135
|
+
<a
|
|
136
|
+
// Always labelled: the folded state hides the wordmark, and a link
|
|
137
|
+
// must not lose its name mid-animation.
|
|
138
|
+
aria-label={name}
|
|
139
|
+
className={rootClassName}
|
|
140
|
+
data-slot="logo"
|
|
141
|
+
data-variant={variant}
|
|
142
|
+
href={href}
|
|
143
|
+
{...props}
|
|
144
|
+
>
|
|
145
|
+
{content}
|
|
146
|
+
</a>
|
|
147
|
+
) : (
|
|
148
|
+
<span
|
|
149
|
+
aria-label={folded ? name : undefined}
|
|
150
|
+
className={rootClassName}
|
|
151
|
+
data-slot="logo"
|
|
152
|
+
data-variant={variant}
|
|
153
|
+
role={folded ? "img" : undefined}
|
|
154
|
+
{...props}
|
|
155
|
+
>
|
|
156
|
+
{content}
|
|
157
|
+
</span>
|
|
158
|
+
)
|
|
159
|
+
}
|