@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.
Files changed (165) hide show
  1. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  2. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  3. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  4. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  5. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  6. package/dist-lib/components/blocks/layout.d.ts +4 -1
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  9. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  10. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  11. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
  13. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  14. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
  15. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
  16. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  17. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  18. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  19. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  20. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  21. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  22. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  23. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  24. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  25. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  26. package/dist-lib/components/blocks/shell/index.d.ts +4 -12
  27. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  28. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  29. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  30. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  31. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  32. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  33. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  34. package/dist-lib/components/composed/__tests__/logo.test.d.ts +1 -0
  35. package/dist-lib/components/composed/logo.d.ts +34 -0
  36. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  37. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  38. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  39. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  40. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  41. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  42. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  43. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  44. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  45. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  46. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  47. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  48. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  49. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  50. package/dist-lib/components/user-avatar/type.d.ts +53 -0
  51. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  52. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
  53. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
  54. package/dist-lib/components/user-avatar/utils.d.ts +22 -0
  55. package/dist-lib/index.d.ts +6 -3
  56. package/dist-lib/index.js +3732 -2670
  57. package/dist-lib/index.js.map +1 -1
  58. package/dist-lib/state/exegia-provider.d.ts +51 -0
  59. package/dist-lib/state/index.d.ts +4 -0
  60. package/dist-lib/state/store.d.ts +17 -0
  61. package/package.json +15 -12
  62. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  63. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  64. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  65. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  66. package/src/components/blocks/auth/auth-state.ts +52 -0
  67. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  68. package/src/components/blocks/layout.ts +30 -2
  69. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  70. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  71. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  72. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  73. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  74. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  75. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  76. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  77. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  78. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  79. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  80. package/src/components/blocks/profile/index.ts +28 -0
  81. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  82. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  83. package/src/components/blocks/profile/type.ts +95 -0
  84. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  85. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  86. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  87. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  88. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  89. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  90. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  91. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  92. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  93. package/src/components/blocks/shell/index.ts +18 -13
  94. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  95. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  96. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  97. package/src/components/blocks/shell/type.ts +130 -3
  98. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  99. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  100. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  101. package/src/components/blocks/shell/utils.ts +44 -4
  102. package/src/components/composed/__tests__/logo.test.tsx +60 -0
  103. package/src/components/composed/logo.tsx +159 -0
  104. package/src/components/composed/tree/CLAUDE.md +132 -0
  105. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  106. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  107. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  108. package/src/components/composed/tree/constants.ts +4 -4
  109. package/src/components/composed/tree/index.ts +38 -0
  110. package/src/components/composed/tree/tree-atom.ts +590 -0
  111. package/src/components/composed/tree/tree-node.tsx +117 -78
  112. package/src/components/composed/tree/tree.tsx +24 -16
  113. package/src/components/composed/tree/type.ts +125 -15
  114. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  115. package/src/components/composed/tree/use-tree-state.ts +105 -0
  116. package/src/components/composed/tree/use-tree.ts +185 -184
  117. package/src/components/composed/user-avatar.tsx +11 -99
  118. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
  119. package/src/components/user-avatar/component.tsx +151 -22
  120. package/src/components/user-avatar/fallback.tsx +3 -1
  121. package/src/components/user-avatar/index.ts +19 -0
  122. package/src/components/user-avatar/presence-badge.tsx +56 -0
  123. package/src/components/user-avatar/type.ts +58 -0
  124. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  125. package/src/components/user-avatar/use-user-avatar.ts +180 -0
  126. package/src/components/user-avatar/user-avatar-atom.ts +218 -0
  127. package/src/components/user-avatar/utils.ts +94 -0
  128. package/src/index.ts +35 -3
  129. package/src/state/exegia-provider.tsx +79 -0
  130. package/src/state/index.ts +4 -0
  131. package/src/state/store.ts +19 -0
  132. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
  133. package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
  134. package/dist-lib/components/blocks/nav/types.d.ts +0 -60
  135. package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
  136. package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
  137. package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
  138. package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
  139. package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
  140. package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
  141. package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
  142. package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
  143. package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
  144. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
  145. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
  146. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
  147. package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
  148. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
  149. package/src/components/blocks/nav/sidebar-block.tsx +0 -142
  150. package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
  151. package/src/components/blocks/nav/types.ts +0 -65
  152. package/src/components/blocks/nav/utils.ts +0 -4
  153. package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
  154. package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
  155. package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
  156. package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
  157. package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
  158. package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
  159. package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
  160. package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
  161. package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
  162. package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
  163. package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
  164. package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
  165. /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,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
- ...(panels?.left && {
34
- left: panels.left.defaultOpen ?? true,
35
- }),
36
- ...(panels?.right && {
37
- right: panels.right.defaultOpen ?? false,
38
- }),
39
- ...defaultOpen,
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
- <AnimatedPanel
53
- ariaLabel="Primary navigation"
54
- collapsible="icon"
55
- role="navigation"
56
- variant="inset"
57
- >
58
- {panels?.left?.component}
59
- </AnimatedPanel>
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 className="min-w-24">
62
- <header className="flex h-12 items-center justify-between gap-2 border-b px-2">
63
- <div className="flex min-w-0 items-center gap-2">
64
- <AnimatedPanelTrigger>
65
- {panels?.left?.trigger ?? (
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
- </AnimatedPanelTrigger>
69
- </div>
70
- {header && <div className="flex flex-1 items-center">{header}</div>}
71
- <div className="flex shrink-0 items-center gap-2">
72
- <AnimatedPanelTrigger aria-label="Toggle panel" side="right">
73
- {panels?.right?.trigger ?? (
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
- </AnimatedPanelTrigger>
81
- </div>
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
- <AnimatedPanel
87
- ariaLabel={panels?.right?.name ?? "Secondary panel"}
88
- // Below md the panel is portal led over the page, so it carries the
89
- // surface itself; the desktop rail keeps it on the inner panel.
90
- className={cn(
91
- "mr-2 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900",
92
- "outline-offset-0.5 border-t-3 border-white outline-neutral-100 dark:inset-ring-black",
93
- "rounded-lg shadow-md shadow-neutral-200 dark:shadow-neutral-950"
94
- )}
95
- collapsible="offcanvas"
96
- role="complementary"
97
- side="right"
98
- variant="inset"
99
- >
100
- {panels?.right?.component}
101
- </AnimatedPanel>
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
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The shell's layout arithmetic, as plain functions over px.
3
+ *
4
+ * Nothing here touches React or the DOM. This module IS the contract of who
5
+ * gets which column, which keeps the rule unit-testable on its own and lets
6
+ * the shell-fit atoms (`shellFitFitsAtom`, `shellFitPanelWidthAtom`) call it
7
+ * without restating it: `useShellFit` only supplies the measurements.
8
+ */
9
+
10
+ /** The px the shell lays itself out with. Every field is measured from a CSS
11
+ * variable (see `SHELL_WIDTHS`), never hard-coded, so a consumer's override
12
+ * flows straight through the rule. */
13
+ export interface ShellMetrics {
14
+ /** What the left rail occupies right now — its expanded width, its icon
15
+ * width, or 0 when there is no rail at all (or it is off canvas). */
16
+ rail: number
17
+ /** The floor the body refuses to go below. */
18
+ insetMin: number
19
+ /** The secondary panel's floor, which is also the width it opens at. */
20
+ panelMin: number
21
+ /** The viewport all three columns share. */
22
+ viewport: number
23
+ /** px the shell's own frame eats before any column gets a share: its
24
+ * padding, plus the gap between columns.
25
+ *
26
+ * Deliberately absent from `fitsPanel` — that rule is stated against the raw
27
+ * viewport — but subtracted from the resize ceiling, where ignoring it lets
28
+ * a full-width drag push the row past the shell by exactly this much. */
29
+ chrome: number
30
+ }
31
+
32
+ /** px the shell needs before a secondary panel can exist: the rail as it
33
+ * stands, plus the body and the panel at their own floors. */
34
+ export function requiredWidth({ rail, insetMin, panelMin }: ShellMetrics) {
35
+ return rail + insetMin + panelMin
36
+ }
37
+
38
+ /**
39
+ * Whether the shell can hold a secondary panel at all. Strictly `<`: a shell
40
+ * that fits its columns exactly has no room left to give one.
41
+ *
42
+ * An unmeasurable shell fails open. A server render, a `display: none` host
43
+ * or a test environment with no layout engine all report 0, and a panel must
44
+ * never disappear over a reading the layout could not produce.
45
+ */
46
+ export function fitsPanel(metrics: ShellMetrics) {
47
+ if (metrics.viewport <= 0 || metrics.panelMin <= 0) return true
48
+ return requiredWidth(metrics) < metrics.viewport
49
+ }
50
+
51
+ /** How wide the secondary panel may be: never under its own floor, never past
52
+ * the slack the body holds above its floor. `max` never drops below `min`, so
53
+ * a shell that does not fit reports a degenerate range instead of an inverted
54
+ * one — `fitsPanel` is what hides the panel, not a negative bound. */
55
+ export function panelBounds(metrics: ShellMetrics) {
56
+ const { chrome, insetMin, panelMin, rail, viewport } = metrics
57
+ return {
58
+ min: panelMin,
59
+ max: Math.max(panelMin, viewport - chrome - rail - insetMin),
60
+ }
61
+ }
62
+
63
+ export function clampPanelWidth(width: number, metrics: ShellMetrics) {
64
+ const { min, max } = panelBounds(metrics)
65
+ return Math.min(Math.max(width, min), max)
66
+ }
67
+
68
+ /** Resizes fire per pointer move and per resize event, most of them landing
69
+ * on the same numbers — comparing fields keeps those from re-rendering the
70
+ * whole shell. */
71
+ export function metricsEqual(a: ShellMetrics, b: ShellMetrics) {
72
+ return (
73
+ a.rail === b.rail &&
74
+ a.insetMin === b.insetMin &&
75
+ a.panelMin === b.panelMin &&
76
+ a.viewport === b.viewport &&
77
+ a.chrome === b.chrome
78
+ )
79
+ }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react"
1
+ import type { ReactNode, RefObject } from "react"
2
2
 
3
3
  import {
4
4
  type ButtonHTMLAttributes,
@@ -7,6 +7,83 @@ import {
7
7
  } from "react"
8
8
  import type { HTMLMotionProps } from "motion/react"
9
9
 
10
+ import type { ShellMetrics } from "./shell-metrics"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Shell fit — the shell's measurement of itself, kept in Jotai atoms keyed by
14
+ // `shellId` (see `shell-fit-atom.ts`). No atom imports here: this file stays
15
+ // dependency-free so the types can travel without the store.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** The key a shell's fit state is filed under. Pass your own to reach it from
19
+ * anywhere (`useShellFitState("app-shell")`); leave it out and the provider
20
+ * generates one that dies with it. */
21
+ export type ShellFitInstanceId = string
22
+
23
+ /** The range a secondary-panel resize may land in, in px. */
24
+ export interface ShellFitPanelBounds {
25
+ min: number
26
+ max: number
27
+ }
28
+
29
+ /** What the shell measured of itself — readable by id through
30
+ * `useShellFitState`. */
31
+ export interface ShellFitState {
32
+ /** The px the shell last measured, or null before its first measurement
33
+ * (a server render, a host with no layout). */
34
+ metrics: ShellMetrics | null
35
+ /** Whether the shell can hold a secondary panel. True while unmeasured, so
36
+ * a panel never flickers out over a reading that has not happened yet. */
37
+ fits: boolean
38
+ /** The secondary panel's width in px, already clamped to `bounds` — null
39
+ * before the first measurement, where the panel falls back to
40
+ * `--panel-width`. */
41
+ panelWidth: number | null
42
+ /** The range a resize may land in. */
43
+ bounds: ShellFitPanelBounds
44
+ }
45
+
46
+ /** Writes only — drive a shell's secondary panel by id through
47
+ * `useShellFitActions` without re-rendering when it moves. */
48
+ export interface ShellFitActions {
49
+ /** Resize the secondary panel. Clamped on the way in, so a drag past the
50
+ * gutter parks at the bound instead of banking travel it has to give back. */
51
+ resizePanel: (width: number) => void
52
+ /** Back to the width the shell mounted with — `defaultPanelWidth`, or
53
+ * `--panel-width` when there was none. */
54
+ resetPanelWidth: () => void
55
+ }
56
+
57
+ /** What `useShellFit` hands the provider: the state, the actions and the id
58
+ * they are filed under. */
59
+ export interface ShellFitController extends ShellFitState, ShellFitActions {
60
+ shellId: ShellFitInstanceId
61
+ }
62
+
63
+ export interface UseShellFitOptions {
64
+ /** File the fit under this id so it can be read elsewhere and outlive the
65
+ * shell. Generated (and dropped on unmount) when omitted. */
66
+ shellId?: ShellFitInstanceId
67
+ /** The element the shell's CSS variables live on (the provider wrapper). */
68
+ hostRef: RefObject<HTMLElement | null>
69
+ /** Whether the left rail is expanded right now — a render input, so a fold
70
+ * counts the moment React commits it rather than a frame later when the
71
+ * width animation has moved. */
72
+ railOpen: boolean
73
+ /** The width the secondary panel opens at, in px, instead of
74
+ * `--panel-width`. Read once, on mount. */
75
+ defaultPanelWidth?: number
76
+ /** Fired whenever a measurement lands on "no room": the shell uses it to
77
+ * retire the panel's open state instead of parking it. */
78
+ onUnfit?: () => void
79
+ }
80
+
81
+ /** @internal What an instance starts from, restored by `resetShellPanelWidthAtom`. */
82
+ export interface ShellFitSeed {
83
+ /** The requested panel width at mount — null for `--panel-width`. */
84
+ panelWidth: number | null
85
+ }
86
+
10
87
  export interface ShellAction {
11
88
  id: string
12
89
  label: string
@@ -60,14 +137,24 @@ export type ShellPanelControlProps = Pick<
60
137
  | "openMobile"
61
138
  | "defaultOpenMobile"
62
139
  | "onOpenMobileChange"
140
+ | "shellId"
141
+ | "defaultPanelWidth"
63
142
  >
64
143
 
65
144
  export interface UseShellPanelsOptions {
145
+ /** The id the shell's fit state is filed under. Name it to read the same
146
+ * shell from elsewhere (`useShellFitState("app-shell")`) and to keep a
147
+ * dragged panel width across a route change; otherwise the hook generates
148
+ * one that dies with it. */
149
+ shellId?: ShellFitInstanceId
66
150
  /** Initial desktop open state per side, merged over
67
151
  * `{ left: true, right: false }`. */
68
152
  defaultOpen?: AnimatedSidebarProviderProps["defaultOpen"]
69
153
  /** Initial mobile overlay state per side — every side starts closed. */
70
154
  defaultOpenMobile?: AnimatedSidebarProviderProps["defaultOpenMobile"]
155
+ /** The width the secondary panel opens at, in px, instead of
156
+ * `--panel-width`. */
157
+ defaultPanelWidth?: number
71
158
  /** Panel change event returning both the next open state and the side of
72
159
  * the panel the change comes from. Mobile overlay changes report the same
73
160
  * side as their desktop counterpart. */
@@ -75,14 +162,32 @@ export interface UseShellPanelsOptions {
75
162
  }
76
163
 
77
164
  export interface ShellPanelControls {
165
+ /** The id the shell's fit state is filed under — hand it to
166
+ * `useShellFitState` / `useShellFitActions` anywhere below `ExegiaProvider`. */
167
+ shellId: ShellFitInstanceId
168
+ /** The viewport cannot hold the secondary panel beside the rail and the
169
+ * body at their floors, so the shell has dropped the right panel and its
170
+ * trigger — UI outside the shell should stand down with them. Read straight
171
+ * out of the shell's fit atoms; it is only ever `true` once the shell is
172
+ * mounted and measured. */
173
+ isNarrow: boolean
174
+ /** The secondary panel's current width in px, or null until the shell has
175
+ * measured itself (the panel then sits at `--panel-width`). */
176
+ panelWidth: number | null
177
+ /** Resize the secondary panel from outside the shell — clamped to the room
178
+ * the shell has. */
179
+ resizePanel: (width: number) => void
78
180
  /** Live desktop open state, keyed by side. */
79
181
  open: Record<SidebarSide, boolean>
80
182
  /** Live mobile overlay state, keyed by side. */
81
183
  openMobile: Record<SidebarSide, boolean>
184
+ /** Refuses to OPEN the right panel while `isNarrow` — there is nothing on
185
+ * screen to open. Closing it always goes through. */
82
186
  setOpen: (open: boolean, side: SidebarSide) => void
83
187
  setOpenMobile: (open: boolean, side: SidebarSide) => void
84
188
  /** Desktop-only convenience — the in-shell triggers already pick the
85
- * mobile state themselves when the viewport is narrow. */
189
+ * mobile state themselves when the viewport is narrow. Carries the same
190
+ * `isNarrow` refusal as `setOpen`. */
86
191
  toggle: (side: SidebarSide) => void
87
192
  /** Spread onto ShellLayout (or AnimatedPanelProvider directly). */
88
193
  providerProps: ShellPanelControlProps
@@ -98,7 +203,6 @@ export interface ShellLayoutProps extends ShellPanelControlProps {
98
203
  variant?: "web" | "desktop"
99
204
  }
100
205
 
101
-
102
206
  export type SidebarSide = "left" | "right"
103
207
  /** Open flags keyed by the side. Sides left out stay uncontrolled / at their
104
208
  * default — there is no per-side prop, the record IS the API. */
@@ -108,6 +212,10 @@ export type SidebarCollapsible = "offcanvas" | "icon" | "none"
108
212
 
109
213
  export interface AnimatedSidebarContextValue {
110
214
  isMobile: boolean
215
+ /** What the shell measured of itself: whether it can hold a secondary panel
216
+ * at all, how wide that panel is, and the range a resize may land in. The
217
+ * right panel and its trigger stand down when `fit.fits` is false. */
218
+ fit: ShellFitController
111
219
  layoutId: string
112
220
  /** Desktop open state, keyed by side. */
113
221
  open: Record<SidebarSide, boolean>
@@ -122,6 +230,14 @@ export interface AnimatedSidebarContextValue {
122
230
  }
123
231
 
124
232
  export interface AnimatedSidebarProviderProps extends HTMLAttributes<HTMLDivElement> {
233
+ /** The id this shell's fit state (whether a secondary panel fits, how wide
234
+ * it is) is filed under in the store. Name it to read or drive the shell
235
+ * from elsewhere and to keep a dragged width across a route change; omit it
236
+ * and the provider generates one that is dropped on unmount. */
237
+ shellId?: ShellFitInstanceId
238
+ /** The width the secondary panel opens at, in px, instead of
239
+ * `--panel-width`. Read once, on mount. */
240
+ defaultPanelWidth?: number
125
241
  /** Controlled desktop open state, keyed by the side. A side left undefined
126
242
  * stays uncontrolled. */
127
243
  open?: SidebarOpenState
@@ -133,13 +249,24 @@ export interface AnimatedSidebarProviderProps extends HTMLAttributes<HTMLDivElem
133
249
  /** Initial mobile overlay state — every side starts closed. */
134
250
  defaultOpenMobile?: SidebarOpenState
135
251
  onOpenMobileChange?: (open: boolean, side: SidebarSide) => void
252
+ /** Fires when the shell crosses the width a secondary panel needs (rail +
253
+ * body + panel at their floors). An imperative escape hatch for a consumer
254
+ * that mounts the provider on its own; anything under `ExegiaProvider` can
255
+ * subscribe by id instead with `useShellFitState(shellId).fits`. */
256
+ onNarrowChange?: (isNarrow: boolean) => void
136
257
  style?: SidebarProviderStyle
137
258
  }
138
259
 
139
260
  export type SidebarProviderStyle = CSSProperties & {
261
+ /** Left rail, expanded. */
140
262
  "--sidebar-width"?: string
263
+ /** Left rail, folded to icons. */
141
264
  "--sidebar-width-icon"?: string
142
265
  "--sidebar-width-mobile"?: string
266
+ /** The secondary panel's floor, and the width it opens at. */
267
+ "--panel-width"?: string
268
+ /** The body's floor — the secondary panel may never squeeze it past this. */
269
+ "--inset-min-width"?: string
143
270
  }
144
271
 
145
272
  export type AnimatedSidebarInsetProps = HTMLMotionProps<"main">