@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.
Files changed (123) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  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 -0
  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/tree/__tests__/tree-atom.test.d.ts +1 -0
  35. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  36. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  37. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  38. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  39. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  40. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  41. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  42. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  43. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  44. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  45. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  46. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  47. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  48. package/dist-lib/components/user-avatar/type.d.ts +45 -0
  49. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  50. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  51. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  52. package/dist-lib/index.d.ts +5 -2
  53. package/dist-lib/index.js +3004 -1796
  54. package/dist-lib/index.js.map +1 -1
  55. package/dist-lib/state/exegia-provider.d.ts +51 -0
  56. package/dist-lib/state/index.d.ts +4 -0
  57. package/dist-lib/state/store.d.ts +17 -0
  58. package/package.json +15 -12
  59. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  60. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  61. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  62. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  63. package/src/components/blocks/auth/auth-state.ts +52 -0
  64. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  65. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  66. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  67. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  68. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  69. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  70. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  71. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  72. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  73. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  74. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  75. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  76. package/src/components/blocks/profile/index.ts +28 -0
  77. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  78. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  79. package/src/components/blocks/profile/type.ts +95 -0
  80. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  81. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  82. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  83. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  84. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  85. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  86. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  87. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  88. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  89. package/src/components/blocks/shell/index.ts +18 -0
  90. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  91. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  92. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  93. package/src/components/blocks/shell/type.ts +130 -3
  94. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  95. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  96. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  97. package/src/components/blocks/shell/utils.ts +44 -4
  98. package/src/components/composed/tree/CLAUDE.md +132 -0
  99. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  100. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  101. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  102. package/src/components/composed/tree/constants.ts +4 -4
  103. package/src/components/composed/tree/index.ts +38 -0
  104. package/src/components/composed/tree/tree-atom.ts +590 -0
  105. package/src/components/composed/tree/tree-node.tsx +117 -78
  106. package/src/components/composed/tree/tree.tsx +24 -16
  107. package/src/components/composed/tree/type.ts +125 -15
  108. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  109. package/src/components/composed/tree/use-tree-state.ts +105 -0
  110. package/src/components/composed/tree/use-tree.ts +185 -184
  111. package/src/components/composed/user-avatar.tsx +11 -99
  112. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  113. package/src/components/user-avatar/component.tsx +121 -22
  114. package/src/components/user-avatar/index.ts +19 -0
  115. package/src/components/user-avatar/presence-badge.tsx +56 -0
  116. package/src/components/user-avatar/type.ts +50 -0
  117. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  118. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  119. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  120. package/src/index.ts +34 -2
  121. package/src/state/exegia-provider.tsx +79 -0
  122. package/src/state/index.ts +4 -0
  123. package/src/state/store.ts +19 -0
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import {
4
+ clampPanelWidth,
5
+ fitsPanel,
6
+ metricsEqual,
7
+ panelBounds,
8
+ requiredWidth,
9
+ type ShellMetrics,
10
+ } from "../shell-metrics"
11
+
12
+ /** The columns the shell ships with: rail 256 (56 folded), body floor 360,
13
+ * secondary panel floor 320 — 936px before a panel can exist — plus the 32px
14
+ * of shell padding and column gaps that only the ceiling pays for. */
15
+ function shell(over: Partial<ShellMetrics> = {}): ShellMetrics {
16
+ return {
17
+ rail: 256,
18
+ insetMin: 360,
19
+ panelMin: 320,
20
+ viewport: 1024,
21
+ chrome: 32,
22
+ ...over,
23
+ }
24
+ }
25
+
26
+ describe("fitsPanel", () => {
27
+ test("wants the rail plus both floors to clear the viewport", () => {
28
+ expect(requiredWidth(shell())).toBe(936)
29
+ expect(fitsPanel(shell())).toBe(true)
30
+ expect(fitsPanel(shell({ viewport: 800 }))).toBe(false)
31
+ })
32
+
33
+ test("is strict — a viewport that fits exactly has nothing left to give", () => {
34
+ expect(fitsPanel(shell({ viewport: 936 }))).toBe(false)
35
+ expect(fitsPanel(shell({ viewport: 937 }))).toBe(true)
36
+ })
37
+
38
+ test("a folded rail hands its column back", () => {
39
+ expect(fitsPanel(shell({ rail: 56, viewport: 800 }))).toBe(true)
40
+ })
41
+
42
+ test("a shell with no rail spends nothing on one", () => {
43
+ expect(fitsPanel(shell({ rail: 0, viewport: 700 }))).toBe(true)
44
+ })
45
+
46
+ test("an unmeasurable shell fails open rather than hiding the panel", () => {
47
+ expect(
48
+ fitsPanel({
49
+ rail: 0,
50
+ insetMin: 0,
51
+ panelMin: 0,
52
+ viewport: 0,
53
+ chrome: 0,
54
+ })
55
+ ).toBe(true)
56
+ expect(fitsPanel(shell({ panelMin: 0 }))).toBe(true)
57
+ })
58
+
59
+ test("ignores the shell's own frame — the rule is stated on the viewport", () => {
60
+ expect(fitsPanel(shell({ chrome: 0 }))).toBe(
61
+ fitsPanel(shell({ chrome: 300 }))
62
+ )
63
+ })
64
+ })
65
+
66
+ describe("panelBounds", () => {
67
+ test("the panel may take every px the body holds above its floor", () => {
68
+ // 1024 - 32 frame - 256 rail - 360 body floor.
69
+ expect(panelBounds(shell())).toEqual({ min: 320, max: 376 })
70
+ expect(panelBounds(shell({ rail: 56 })).max).toBe(576)
71
+ })
72
+
73
+ test("the shell's frame comes out of the ceiling, not the floor", () => {
74
+ expect(panelBounds(shell({ chrome: 0 })).max).toBe(408)
75
+ expect(panelBounds(shell({ chrome: 0 })).min).toBe(320)
76
+ })
77
+
78
+ test("never inverts — a shell with no room reports its floor twice", () => {
79
+ expect(panelBounds(shell({ viewport: 800 }))).toEqual({
80
+ min: 320,
81
+ max: 320,
82
+ })
83
+ })
84
+ })
85
+
86
+ describe("clampPanelWidth", () => {
87
+ test("holds a drag inside the bounds and leaves the middle alone", () => {
88
+ expect(clampPanelWidth(600, shell())).toBe(376)
89
+ expect(clampPanelWidth(100, shell())).toBe(320)
90
+ expect(clampPanelWidth(360, shell())).toBe(360)
91
+ })
92
+
93
+ test("a narrowing viewport pulls a resized panel back down", () => {
94
+ const resized = clampPanelWidth(500, shell({ rail: 56 }))
95
+ expect(resized).toBe(500)
96
+ expect(clampPanelWidth(resized, shell({ rail: 56, viewport: 900 }))).toBe(
97
+ 452
98
+ )
99
+ })
100
+ })
101
+
102
+ describe("metricsEqual", () => {
103
+ test("compares fields, so a fresh object off the same layout is quiet", () => {
104
+ expect(metricsEqual(shell(), shell())).toBe(true)
105
+ expect(metricsEqual(shell(), shell({ viewport: 1025 }))).toBe(false)
106
+ expect(metricsEqual(shell(), shell({ rail: 56 }))).toBe(false)
107
+ })
108
+ })
@@ -13,7 +13,11 @@ export const AnimatedPanelInset = forwardRef<
13
13
  ref={forwardedRef}
14
14
  data-slot="sidebar-inset"
15
15
  className={cn(
16
- "relative flex min-w-0 flex-1 flex-col bg-neutral-50 dark:bg-neutral-900",
16
+ // The body takes every column the panels leave (flex-1 = width 100%)
17
+ // but never yields past its own floor: a resized panel may only take
18
+ // the slack above --inset-min-width, and the shell drops the secondary
19
+ // panel entirely once even that no longer fits.
20
+ "relative flex min-w-(--inset-min-width) flex-1 flex-col bg-neutral-50 dark:bg-neutral-900",
17
21
  "outline-offset-0.5 overflow-hidden border-t-3 border-white outline-neutral-100 dark:border-neutral-800 dark:inset-ring-black",
18
22
  "rounded-lg shadow-md shadow-neutral-200 dark:shadow-neutral-950",
19
23
  className
@@ -1,24 +1,26 @@
1
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
2
- import { useReducedMotion } from "motion/react"
2
+ import { useIsomorphicLayoutEffect, useReducedMotion } from "motion/react"
3
3
  import { cn } from "@/lib/utils.ts"
4
- import type {
5
- AnimatedSidebarProviderProps,
6
- SidebarSide,
7
- } from "./type"
4
+ import type { AnimatedSidebarProviderProps, SidebarSide } from "./type"
5
+ import { useShellFit } from "./use-shell-fit"
8
6
  import {
9
7
  AnimatedSidebarContext,
8
+ SHELL_WIDTHS,
10
9
  SIDEBAR_KEYBOARD_SHORTCUT,
11
10
  useIsMobile,
12
11
  } from "./utils"
13
12
 
14
13
  export function AnimatedPanelProvider({
15
14
  children,
15
+ shellId,
16
+ defaultPanelWidth,
16
17
  open,
17
18
  defaultOpen,
18
19
  onOpenChange,
19
20
  openMobile,
20
21
  defaultOpenMobile,
21
22
  onOpenMobileChange,
23
+ onNarrowChange,
22
24
  className,
23
25
  style,
24
26
  ...props
@@ -40,6 +42,12 @@ export function AnimatedPanelProvider({
40
42
  const generatedId = useId()
41
43
  const leftTriggerRef = useRef<HTMLButtonElement>(null)
42
44
  const rightTriggerRef = useRef<HTMLButtonElement>(null)
45
+ const wrapperRef = useRef<HTMLDivElement>(null)
46
+ // The narrow gate below is consulted by the setters, but the verdict comes
47
+ // from `useShellFit` further down — which needs those very setters to retire
48
+ // the panel. A ref unwinds the cycle: a setter only ever runs from an event,
49
+ // by which time the layout effect has published the current verdict.
50
+ const fitsRef = useRef(true)
43
51
 
44
52
  // Per-side controlled/uncontrolled merge — a side is controlled exactly
45
53
  // when its key is present on the controlled record.
@@ -63,8 +71,14 @@ export function AnimatedPanelProvider({
63
71
  [controlledMobileLeft, controlledMobileRight, internalOpenMobile]
64
72
  )
65
73
 
74
+ // The right panel is not on screen while the shell is too narrow to hold
75
+ // it, so opening it would only ever surface later as a panel nobody asked
76
+ // for. Closing always goes through — that is how state left over from a
77
+ // wider viewport clears.
66
78
  const setOpen = useCallback(
67
79
  (nextOpen: boolean, side: SidebarSide) => {
80
+ if (nextOpen && side === "right" && !fitsRef.current) return
81
+
68
82
  const controlled = side === "left" ? controlledLeft : controlledRight
69
83
  if (controlled === undefined) {
70
84
  setInternalOpen((prev) =>
@@ -78,6 +92,8 @@ export function AnimatedPanelProvider({
78
92
 
79
93
  const setOpenMobile = useCallback(
80
94
  (nextOpen: boolean, side: SidebarSide) => {
95
+ if (nextOpen && side === "right" && !fitsRef.current) return
96
+
81
97
  const controlled =
82
98
  side === "left" ? controlledMobileLeft : controlledMobileRight
83
99
  if (controlled === undefined) {
@@ -103,6 +119,58 @@ export function AnimatedPanelProvider({
103
119
  []
104
120
  )
105
121
 
122
+ // A measurement that lands on "no room" retires the right panel's open
123
+ // state instead of parking it: a panel that survived the crossing would
124
+ // spring back the moment the viewport widened, long after the user asked
125
+ // for it. Closing is the one write the narrow gate lets through, and the
126
+ // guards keep a repeated verdict from re-reporting a panel already shut.
127
+ //
128
+ // It rides a ref so `useShellFit` can call the latest one from a listener
129
+ // it bound once, and it is a layout effect declared FIRST so the ref is
130
+ // filled before that listener takes its opening measurement.
131
+ const retireRightPanelRef = useRef<() => void>(() => {})
132
+ useIsomorphicLayoutEffect(() => {
133
+ retireRightPanelRef.current = () => {
134
+ if (openState.right) setOpen(false, "right")
135
+ if (openMobileState.right) setOpenMobile(false, "right")
136
+ }
137
+ })
138
+ const retireRightPanel = useCallback(() => retireRightPanelRef.current(), [])
139
+
140
+ // Everything about the shell's columns — whether a secondary panel fits,
141
+ // how wide it is, how far it may be dragged — lives in this one hook, and
142
+ // in the shell-fit atoms it writes to under `shellId`. The rail's fold is an
143
+ // input because its column is part of the room the panel needs.
144
+ const fit = useShellFit({
145
+ shellId,
146
+ hostRef: wrapperRef,
147
+ railOpen: openState.left,
148
+ defaultPanelWidth,
149
+ onUnfit: retireRightPanel,
150
+ })
151
+ const isNarrow = !fit.fits
152
+ useIsomorphicLayoutEffect(() => {
153
+ fitsRef.current = fit.fits
154
+ })
155
+
156
+ // The verdict has to travel up to whoever renders the provider —
157
+ // useShellPanels, a title bar — since context only flows down. The
158
+ // listener above is bound once, so the callback rides a ref rather than
159
+ // rebinding it whenever a consumer passes a fresh arrow.
160
+ const onNarrowChangeRef = useRef(onNarrowChange)
161
+ useEffect(() => {
162
+ onNarrowChangeRef.current = onNarrowChange
163
+ })
164
+
165
+ // Only real crossings are news: a resize that leaves the verdict alone is
166
+ // silent, and so is the mount default the consumer already holds.
167
+ const reportedNarrowRef = useRef(false)
168
+ useEffect(() => {
169
+ if (reportedNarrowRef.current === isNarrow) return
170
+ reportedNarrowRef.current = isNarrow
171
+ onNarrowChangeRef.current?.(isNarrow)
172
+ }, [isNarrow])
173
+
106
174
  useEffect(() => {
107
175
  const handleShortcut = (event: KeyboardEvent) => {
108
176
  if (
@@ -122,6 +190,7 @@ export function AnimatedPanelProvider({
122
190
  <AnimatedSidebarContext.Provider
123
191
  value={{
124
192
  isMobile,
193
+ fit,
125
194
  layoutId: `${generatedId}-active`,
126
195
  open: openState,
127
196
  openMobile: openMobileState,
@@ -134,15 +203,12 @@ export function AnimatedPanelProvider({
134
203
  >
135
204
  <div
136
205
  {...props}
206
+ ref={wrapperRef}
137
207
  data-slot="sidebar-wrapper"
208
+ data-narrow={isNarrow ? "" : undefined}
138
209
  data-state-left={openState.left ? "expanded" : "collapsed"}
139
210
  data-state-right={openState.right ? "expanded" : "collapsed"}
140
- style={{
141
- "--sidebar-width": "19rem",
142
- "--sidebar-width-icon": "4.25rem",
143
- "--sidebar-width-mobile": "18rem",
144
- ...style,
145
- }}
211
+ style={{ ...SHELL_WIDTHS, ...style }}
146
212
  className={cn(
147
213
  "group/sidebar-wrapper flex w-full min-w-0 gap-x-2",
148
214
  className
@@ -16,6 +16,10 @@ export const AnimatedPanelTrigger = forwardRef<
16
16
  : context.open[side]
17
17
  const triggerRef = context.triggerRefs[side]
18
18
 
19
+ // The right panel drops out of a viewport too narrow to hold it, so its
20
+ // toggle would otherwise sit in the header driving nothing.
21
+ if (side === "right" && !context.fit.fits) return null
22
+
19
23
  return (
20
24
  <Button
21
25
  {...props}
@@ -1,18 +1,30 @@
1
1
  "use client"
2
2
  // beui.dev/components/motion/animated-sidebar
3
3
 
4
- import { motion } from "motion/react"
4
+ import { motion, type PanInfo } from "motion/react"
5
+ import {
6
+ useCallback,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type KeyboardEvent,
11
+ } from "react"
5
12
  import { EASE_OUT } from "@/lib/ease.ts"
6
13
  import { cn } from "@/lib/utils"
7
14
  import type { AnimatedSidebarProps } from "./type"
8
15
  import {
9
16
  AnimatedSidebarPanelContext,
17
+ expandedWidthVar,
10
18
  SIDEBAR_MORPH_TRANSITION,
11
19
  PANEL_TRANSITION,
12
20
  REDUCED_TRANSITION,
13
- useAnimatedSidebar
21
+ useAnimatedSidebar,
14
22
  } from "./utils"
15
23
 
24
+ /** How far one arrow key moves the panel edge (shift = coarse step). */
25
+ const RESIZE_STEP = 16
26
+ const RESIZE_STEP_COARSE = 64
27
+
16
28
  export function AnimatedPanel({
17
29
  side = "left",
18
30
  variant = "sidebar",
@@ -26,21 +38,97 @@ export function AnimatedPanel({
26
38
  ...props
27
39
  }: AnimatedSidebarProps) {
28
40
  const context = useAnimatedSidebar()
41
+ const { bounds, panelWidth, resetPanelWidth, resizePanel } = context.fit
29
42
  const sideOpen = context.open[side]
30
43
  const collapsed = collapsible !== "none" && !sideOpen
31
44
  const offcanvas = collapsed && collapsible === "offcanvas"
32
- const width = offcanvas
33
- ? "0px"
45
+
46
+ // Only the expanded secondary panel is resizable: the rail is pinned to its
47
+ // own columns, and a collapsed panel has no edge left to grab.
48
+ const resizable =
49
+ side === "right" && collapsible === "offcanvas" && !collapsed
50
+
51
+ const resizeStartRef = useRef(0)
52
+ const [resizing, setResizing] = useState(false)
53
+
54
+ const baseWidth = offcanvas
55
+ ? 0
34
56
  : collapsed
35
57
  ? "var(--sidebar-width-icon)"
36
- : "var(--sidebar-width)"
58
+ : expandedWidthVar(side)
59
+ // The secondary panel's width is shell state, not panel state: the shell
60
+ // measured it, re-clamps it when the room changes, and hands back the same
61
+ // width after a collapse. Everything else rides the CSS variables, which
62
+ // also covers the server render, where nothing has been measured yet.
63
+ const width = resizable && panelWidth !== null ? panelWidth : baseWidth
64
+
65
+ const setPanelRef = useCallback(
66
+ (node: HTMLElement | null) => {
67
+ if (typeof ref === "function") ref(node)
68
+ else if (ref) ref.current = node
69
+ },
70
+ [ref]
71
+ )
72
+
73
+ const handleResizeStart = useCallback(() => {
74
+ resizeStartRef.current = panelWidth ?? bounds.min
75
+ setResizing(true)
76
+ }, [bounds.min, panelWidth])
77
+
78
+ const handleResize = useCallback(
79
+ (_event: PointerEvent, info: PanInfo) => {
80
+ // The handle rides the panel's inner edge, so the panel grows as the
81
+ // pointer travels left. `offset` is measured from the pointer-down
82
+ // point, so the edge tracks it 1:1 instead of accumulating rounding.
83
+ resizePanel(resizeStartRef.current - info.offset.x)
84
+ },
85
+ [resizePanel]
86
+ )
87
+
88
+ const handleResizeEnd = useCallback(() => setResizing(false), [])
89
+
90
+ const handleResizeKeyDown = useCallback(
91
+ (event: KeyboardEvent<HTMLDivElement>) => {
92
+ const direction =
93
+ event.key === "ArrowLeft" ? 1 : event.key === "ArrowRight" ? -1 : 0
94
+ if (!direction) return
95
+
96
+ event.preventDefault()
97
+ const step = event.shiftKey ? RESIZE_STEP_COARSE : RESIZE_STEP
98
+ resizePanel((panelWidth ?? bounds.min) + direction * step)
99
+ },
100
+ [bounds.min, panelWidth, resizePanel]
101
+ )
102
+
103
+ // The pointer leaves the 2px handle the moment the drag starts, so the
104
+ // cursor and the text selection have to be held on the document instead.
105
+ useEffect(() => {
106
+ if (!resizing) return
107
+
108
+ const { body } = document
109
+ const previousCursor = body.style.cursor
110
+ const previousUserSelect = body.style.userSelect
111
+ body.style.cursor = "col-resize"
112
+ body.style.userSelect = "none"
113
+
114
+ return () => {
115
+ body.style.cursor = previousCursor
116
+ body.style.userSelect = previousUserSelect
117
+ }
118
+ }, [resizing])
119
+
120
+ // A secondary panel exists only while the viewport can carry it beside the
121
+ // rail and the body at their own floors. Below that there is nothing to dock
122
+ // into, so the panel stands down rather than squeezing the content column —
123
+ // and its trigger stands down with it.
124
+ if (side === "right" && !context.fit.fits) return null
37
125
 
38
126
  if (context.isMobile) return <div>TODO: Create Mobile nav experience</div>
39
127
 
40
128
  return (
41
129
  <motion.aside
42
130
  {...props}
43
- ref={ref}
131
+ ref={setPanelRef}
44
132
  initial={false}
45
133
  aria-label={ariaLabel}
46
134
  data-slot="sidebar"
@@ -48,6 +136,7 @@ export function AnimatedPanel({
48
136
  data-collapsible={collapsible}
49
137
  data-variant={variant}
50
138
  data-side={side}
139
+ data-resizing={resizing ? "" : undefined}
51
140
  animate={{
52
141
  width,
53
142
  // Swallow the shell's gap-x-2 column gap while off canvas so the
@@ -57,7 +146,11 @@ export function AnimatedPanel({
57
146
  ? { marginLeft: offcanvas ? "-0.5rem" : "0rem" }
58
147
  : { marginRight: offcanvas ? "-0.5rem" : "0rem" }),
59
148
  }}
60
- transition={context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION}
149
+ // A drag has to land on the frame it happened — the morph spring would
150
+ // trail the pointer and keep settling after the handle is released.
151
+ transition={
152
+ resizing || context.reduce ? { duration: 0 } : SIDEBAR_MORPH_TRANSITION
153
+ }
61
154
  style={style}
62
155
  className={cn(
63
156
  "group/sidebar relative hidden h-full shrink-0 will-change-[width] md:block",
@@ -66,14 +159,28 @@ export function AnimatedPanel({
66
159
  className
67
160
  )}
68
161
  >
69
- {side === "right" && !collapsed && (
162
+ {resizable && (
70
163
  // The handle occupies the shell's gap-x-2 column gap. It must stay
71
164
  // outside the clipping mask below or the gap would never receive
72
165
  // hover — clipping cuts hit-testing, not just painting.
73
166
  <motion.div
74
- className="absolute inset-y-0 -left-2 z-10 w-2 cursor-col-resize"
167
+ role="separator"
168
+ aria-label={`Resize ${ariaLabel}`}
169
+ aria-orientation="vertical"
170
+ tabIndex={0}
171
+ className="absolute inset-y-0 -left-2 z-10 w-2 cursor-col-resize touch-none"
75
172
  initial="idle"
173
+ // Hover alone would drop the glow the instant the drag leaves the
174
+ // handle, so the gesture pins it open for the whole drag.
175
+ animate={resizing ? "hover" : "idle"}
76
176
  whileHover="hover"
177
+ whileFocus="hover"
178
+ onPanStart={handleResizeStart}
179
+ onPan={handleResize}
180
+ onPanEnd={handleResizeEnd}
181
+ onKeyDown={handleResizeKeyDown}
182
+ // The usual escape hatch out of a width you dragged to.
183
+ onDoubleClick={resetPanelWidth}
77
184
  >
78
185
  <motion.div
79
186
  variants={{ idle: { scaleY: 0 }, hover: { scaleY: 1 } }}
@@ -86,30 +193,20 @@ export function AnimatedPanel({
86
193
  />
87
194
  </motion.div>
88
195
  )}
89
- {/* The off canvas panel keeps its full width and slides out of the
90
- zero-width rail, so mask it to the aside's box here instead of
91
- clipping the aside itself — the aside must stay unclipped for the
92
- gap handle above to be hoverable. */}
93
- <div
94
- className={
95
- collapsible === "offcanvas"
96
- ? "absolute inset-0 overflow-hidden rounded-[inherit]"
97
- : "contents"
98
- }
99
- >
196
+ {/* The aside's own width animation is the whole layout story: the
197
+ inner panel just fills it and fades, so it is masked to the aside's
198
+ box here instead of clipping the aside itself — the aside must stay
199
+ unclipped for the gap handle above to be hoverable. */}
200
+ <div className={"contents"}>
100
201
  <motion.div
101
202
  initial={false}
102
- animate={{
103
- opacity: offcanvas ? 0 : 1,
104
- x: offcanvas ? (side === "left" ? "-100%" : "100%") : "0%",
105
- }}
203
+ animate={{ opacity: offcanvas ? 0 : 1 }}
106
204
  transition={context.reduce ? REDUCED_TRANSITION : PANEL_TRANSITION}
107
205
  className={cn(
108
- "sticky top-0 flex flex-col overflow-hidden",
109
- // collapsible === "offcanvas" && "w-(--sidebar-width)",
110
- // Pin the right panel to the aside's trailing edge so the width
111
- // animation expands the panel out of it instead of revealing a
112
- // detached strip.
206
+ "sticky top-0 flex w-full flex-col overflow-hidden",
207
+ // Pin the right panel to the aside's trailing edge so the aside's
208
+ // width animation expands the panel out of it instead of
209
+ // revealing a detached strip.
113
210
  side === "right" && "ml-auto",
114
211
 
115
212
  // Panels fill the shell's container, not the viewport — the shell
@@ -1,7 +1,25 @@
1
1
  import { ShellLayout } from "./shell-layout"
2
2
 
3
3
  export type * from "./type"
4
+ export * from "./shell-metrics"
4
5
  export * from "./use-shell-panels"
6
+ export { useShellFit } from "./use-shell-fit"
7
+ export { useShellFitActions, useShellFitState } from "./use-shell-fit-state"
8
+ // The public atom surface. `@internal` atoms (measure, mount) stay unexported
9
+ // on purpose — `export *` would make every internal a breaking-change surface
10
+ // for consumer apps.
11
+ export {
12
+ removeShellFitInstance,
13
+ resetShellPanelWidthAtom,
14
+ resizeShellPanelAtom,
15
+ shellFitFitsAtom,
16
+ shellFitMeasuredAtom,
17
+ shellFitMetricsAtom,
18
+ shellFitPanelBoundsAtom,
19
+ shellFitPanelWidthAtom,
20
+ shellFitRequestedWidthAtom,
21
+ shellFitStateAtom,
22
+ } from "./shell-fit-atom"
5
23
  export * from "./utils"
6
24
 
7
25
  export * from "./animated-panel"