@exegia/corpora-ui 0.27.0 → 0.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -151,9 +151,11 @@ describe("ProfileCardBlock", () => {
151
151
  const identity = trigger.querySelector('[data-slot="profile-card-identity"]');
152
152
  expect(identity?.getAttribute("aria-hidden")).toBe("true");
153
153
  expect(identity?.textContent).toContain("Jenny Hamilton");
154
- // 40px tile, like the tree rail: no inset, avatar centred.
154
+ // Full-width h-10 row, like the tree rail's tiles: avatar centred on
155
+ // the rail's own centre line, no inset.
155
156
  expect(trigger.className).toContain("justify-center");
156
- expect(trigger.className).toContain("size-10");
157
+ expect(trigger.className).toContain("h-10");
158
+ expect(trigger.className).toContain("w-full");
157
159
  expect(trigger.className).toContain("p-0");
158
160
 
159
161
  rerender(<ProfileCardBlock user={USER} variant="expanded" />);
@@ -233,18 +233,18 @@ export function ProfileCardBlock({
233
233
  // Quiet at rest — the background only surfaces on hover or
234
234
  // while the menu is open.
235
235
  "h-auto w-full justify-between py-1.5 pl-1.5 data-popup-open:bg-accent sm:h-auto",
236
- // Folded: a 40px avatar tile, the size the tree rail uses. It
237
- // is 8px wider than a footer's 32px inner box on purpose — the
238
- // -mx-1 lets it borrow the footer padding so the 32px avatar
239
- // clears the 1px border and sits dead centre. The label spans
240
- // below fold to zero width rather than unmounting so the change
241
- // animates. Button's own gap-2 stays: its always-mounted loading
242
- // slot carries a -ms-2 that cancels it, so zeroing the gap would
243
- // pull the avatar 8px left; the folded chevron cancels its own
244
- // gap instead (see below). `sm:size-10` because the base carries
245
- // an `sm:h-auto` that an unprefixed size would not beat.
236
+ // Folded: a full-width h-10 row matching the tree rail's tiles,
237
+ // avatar centred — a fixed-width tile drifted off the rail's
238
+ // centre line whenever the collapsed panel was wider than it.
239
+ // The label spans below fold to zero width rather than
240
+ // unmounting so the change animates. Button's own gap-2 stays:
241
+ // its always-mounted loading slot carries a -ms-2 that cancels
242
+ // it, so zeroing the gap would pull the avatar 8px left; the
243
+ // folded chevron cancels its own gap instead (see below).
244
+ // `sm:h-10` because the base carries an `sm:h-auto` that an
245
+ // unprefixed height would not beat.
246
246
  collapsed &&
247
- "-mx-1 size-10 shrink-0 justify-center rounded-xl p-0 sm:size-10",
247
+ "h-10 w-full shrink-0 justify-center rounded-xl p-0 sm:h-10",
248
248
  className
249
249
  )}
250
250
  data-collapsed={collapsed ? "" : undefined}
@@ -15,9 +15,9 @@ first; this file only holds what is specific to the tree.
15
15
  | `use-tree-state.ts` | `useTreeState(id)` (reads, re-renders on any change) and `useTreeActions(id)` (writes only, never re-renders). |
16
16
  | `use-tree-dnd.ts` | Stable drag handlers for `files`; they read `draggedId`/`dropTarget` out of the store at call time. |
17
17
  | `tree-context.ts` | Context value = `{ treeId, renderTrailing, dnd }` only. Never the controller. |
18
- | `tree.tsx` | `Tree` (props form → `UncontrolledTree`, controller form → `TreeView`), rail width measurement, roving keyboard nav. |
18
+ | `tree.tsx` | `Tree` (props form → `UncontrolledTree`, controller form → `TreeView`), roving keyboard nav. |
19
19
  | `tree-node.tsx` | `TreeRow` (memoized). Per-node atom subscriptions, row kind resolution, rename input, tooltip, toc overlay toggle, trailing slot, branch animation. |
20
- | `constants.ts` | Durations, easings, `RAIL_COLLAPSED_WIDTH`, motion variants. |
20
+ | `constants.ts` | Durations, easings, motion variants. |
21
21
  | `utils.ts` | Pure tree helpers (`findNode`, `moveNode`, `renameNode`, ancestor walks). |
22
22
  | `index.ts` | Barrel. Public atoms are listed explicitly — never `export *`. |
23
23
  | `__tests__/` | `tree.test.tsx` (rendering per variant), `use-tree.test.tsx` (controller/hook), `tree-atom.test.tsx` (store + memo guard). |
@@ -43,10 +43,10 @@ first; this file only holds what is specific to the tree.
43
43
  `rowInteractionProps`, spread onto the Button; Base UI's `mergeProps` lets
44
44
  them override Button's own `data-slot="button"`. `sound` is forwarded to the
45
45
  Button so its press/release cues follow the tree's `sound` prop.
46
- - Collapsed sidebar rows are `h-10! rounded-xl! justify-center px-0` tiles;
47
- the rail is `w-10` (40px). `RAIL_COLLAPSED_WIDTH` **must equal** the class
48
- (a test pins it to 40) — the motion target and the resting class must agree
49
- or the rail jumps on its first fold.
46
+ - Collapsed sidebar rows are `h-10! rounded-xl! justify-center px-0` tiles
47
+ spanning the **full rail width** — the rail itself stays `w-full` in both
48
+ states; its fold is the container's width change (an icon-collapsed panel)
49
+ plus the labels folding. Folded icons step up to `size-5 text-foreground`.
50
50
  - Row kinds (`rowKindOf`): `link` (selectable, `aria-expanded` absent),
51
51
  `toggle` (parent in navigation/files, `aria-expanded` on the row),
52
52
  `section` (top level of a 3-level navigation tree). `toc` parents are
@@ -68,11 +68,11 @@ first; this file only holds what is specific to the tree.
68
68
  3. **Keep the rail label mounted.** Unmounting the label on collapse killed
69
69
  the fold animation; it animates `width/opacity/x/display` in place and the
70
70
  test asserts `display: none`, not absence. (12b6a1f)
71
- 4. **Both rail width endpoints must be px.** Motion cannot tween a number
71
+ 4. **Both rail width endpoints must be px** *(historical — the rail no
72
+ longer animates its own width; the container's fold carries it, see the
73
+ fix/tree-collapsed-behavior entry)*. Motion cannot tween a number
72
74
  against `"100%"`; doing so pinned the inline width at the collapsed value
73
- and the rail never reopened. The expanded width is measured off the
74
- container, only while expanded, with the list's inline width cleared for
75
- the sample. (a69072d, 2f7539f)
75
+ and the rail never reopened. (a69072d, 2f7539f)
76
76
  5. **Tooltip is disabled, not unmounted, when the rail expands.** Swapping the
77
77
  wrapper remounts the row and cuts the label fold short. Test asserts row
78
78
  identity across collapse.
@@ -109,6 +109,19 @@ first; this file only holds what is specific to the tree.
109
109
 
110
110
  ## Change log
111
111
 
112
+ - **2026-08-22 — fix/tree-collapsed-behavior** (on `release/v0.28.0`)
113
+ - `tree.tsx`: the rail no longer narrows itself — the `w-10` collapsed
114
+ class, the width measurement (`railRef`/ResizeObserver) and the px width
115
+ tween are gone. The list is `w-full` in both states (root `motion.ul` →
116
+ plain `ul`); the nav wrapper dropped `justify-center` for `w-full`. The
117
+ fold is the hosting panel's width change plus the labels folding.
118
+ - `tree-node.tsx`: folded rail icons step up to `size-5` and take
119
+ `text-foreground` (rows are icon-only, so muted-at-rest read as disabled).
120
+ - `constants.ts`: `RAIL_COLLAPSED_WIDTH` removed (was internal-only).
121
+ - `blocks/profile/profile-card-block.tsx`: the folded card is a full-width
122
+ `h-10 w-full` row (was a `-mx-1 size-10` tile) so the avatar centres on
123
+ the rail's own centre line regardless of panel width.
124
+
112
125
  - **2026-08-18 — fix/tree-collapsed** (on `release/v0.21.0`)
113
126
  - `tree-node.tsx`: link/toggle rows render the shared `Button` (ghost /
114
127
  secondary when active on sidebar); anchor branch removed; collapsed rail
@@ -3,7 +3,6 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
3
3
  import userEvent from "@testing-library/user-event"
4
4
  import { FileTextIcon, FolderIcon, SearchIcon } from "lucide-react"
5
5
 
6
- import { RAIL_COLLAPSED_WIDTH } from "../constants"
7
6
  import { Tree } from "../index"
8
7
  import type { TreeNode } from "../type"
9
8
  import { moveNode } from "../utils"
@@ -271,12 +270,10 @@ describe("Tree · toc", () => {
271
270
  })
272
271
 
273
272
  describe("Tree · sidebar", () => {
274
- test("the rail's width class tracks collapsed in both directions", async () => {
275
- // Locks the resting widths to the class, so the rail is right even when
276
- // the animation never applies. NB this does NOT reproduce the bug it was
277
- // written alongside — that was an inline width pinned at 44px by a
278
- // px-against-"100%" motion target, and motion sets no inline styles
279
- // under happy-dom. Only a real browser catches that one.
273
+ test("the rail spans its container in both states", async () => {
274
+ // The rail no longer narrows itself to a fixed px column — its fold is
275
+ // the container's width change (an icon-collapsed panel). Both states
276
+ // must keep w-full so rows span the rail edge to edge.
280
277
  const { rerender } = render(<Tree items={RAIL} variant="sidebar" />)
281
278
  const rail = () => document.querySelector('[data-slot="tree"]')
282
279
 
@@ -285,30 +282,30 @@ describe("Tree · sidebar", () => {
285
282
 
286
283
  rerender(<Tree collapsed items={RAIL} variant="sidebar" />)
287
284
  await settleExit()
288
- expect(rail()?.className).toContain("w-10")
285
+ expect(rail()?.className).toContain("w-full")
286
+ expect(rail()?.className).not.toContain("w-10")
289
287
  expect(rail()?.getAttribute("data-collapsed")).toBe("")
290
- // Collapsed rows are square-ish tiles that fill the 40px rail.
288
+ // Collapsed rows are full-width tiles with the icon centred, stepped up
289
+ // to size-5 and foreground colour — the icon is the whole row.
291
290
  const row = screen.getByRole("button", { name: "Search" })
292
291
  expect(row.className).toContain("h-10!")
293
292
  expect(row.className).toContain("rounded-xl!")
294
293
  expect(row.className).toContain("justify-center")
294
+ expect(row.className).toContain("w-full")
295
+ const icon = row.querySelector('[data-slot="tree-row-icon"]')
296
+ expect(icon?.className).toContain("size-5")
297
+ expect(icon?.className).toContain("text-foreground")
295
298
 
296
- // Back open — the direction that was broken.
299
+ // Back open — rows drop the tile treatment, icons step back down.
297
300
  rerender(<Tree items={RAIL} variant="sidebar" />)
298
301
  await settleExit()
299
302
  expect(rail()?.className).toContain("w-full")
300
- expect(rail()?.className).not.toContain("w-10")
301
303
  expect(rail()?.getAttribute("data-collapsed")).toBeNull()
302
- expect(screen.getByRole("button", { name: "Search" }).className).not.toContain(
303
- "h-10!"
304
- )
305
- })
306
-
307
- test("the collapsed px target matches the resting width class", () => {
308
- // tree.tsx animates the rail to RAIL_COLLAPSED_WIDTH once measured, and
309
- // falls back to the class before that. Tailwind's w-10 is 2.5rem = 40px;
310
- // the two must agree or the rail jumps on its first collapse.
311
- expect(RAIL_COLLAPSED_WIDTH).toBe(40)
304
+ const reopened = screen.getByRole("button", { name: "Search" })
305
+ expect(reopened.className).not.toContain("h-10!")
306
+ expect(
307
+ reopened.querySelector('[data-slot="tree-row-icon"]')?.className
308
+ ).not.toContain("size-5")
312
309
  })
313
310
 
314
311
  test("collapsed shows icon-only rows that keep an accessible name", async () => {
@@ -15,11 +15,6 @@ export const TREE_MICRO_DURATION = 0.15
15
15
  /** Sidebar rail label reveal/hide when `collapsed` flips. */
16
16
  export const TREE_COLLAPSE_DURATION = 0.2
17
17
 
18
- /** The collapsed rail's width in px — one `h-10 rounded-xl` icon row, edge
19
- * to edge. Kept in sync with the `w-10` resting class the rail falls back to
20
- * before its expanded width has been measured. */
21
- export const RAIL_COLLAPSED_WIDTH = 40
22
-
23
18
  /** Rail label text fade — starts as the width growth is finishing, so the
24
19
  * label never reads as clipped mid-grow. */
25
20
  export const TREE_LABEL_REVEAL_DELAY = 0.01
@@ -295,9 +295,15 @@ function TreeRowImpl({ node, depth }: TreeRowProps): React.ReactElement {
295
295
  {kind !== "section" && node.icon && (
296
296
  <span
297
297
  className={cn(
298
- "flex size-4 shrink-0 items-center justify-center [&>svg]:size-4",
298
+ "flex size-4 shrink-0 items-center justify-center transition-[width,height,color] duration-150 [&>svg]:size-4",
299
+ // Folded rail: the icon is the whole row, so it steps up a size
300
+ // and takes the foreground colour instead of muted.
301
+ variant === "sidebar" &&
302
+ collapsed &&
303
+ "size-5 text-foreground [&>svg]:size-5",
299
304
  active && "text-accent-foreground"
300
305
  )}
306
+ data-slot="tree-row-icon"
301
307
  >
302
308
  {node.icon}
303
309
  </span>
@@ -13,14 +13,8 @@ import type {
13
13
  TreeNode,
14
14
  TreeProps,
15
15
  } from "./type"
16
- import {
17
- RAIL_COLLAPSED_WIDTH,
18
- TREE_COLLAPSE_DURATION,
19
- TREE_EASE,
20
- } from "./constants"
21
16
  import { useTree } from "./use-tree"
22
17
  import { useTreeDndHandlers } from "./use-tree-dnd"
23
- import { motion, useReducedMotion } from "motion/react"
24
18
 
25
19
  const DEFAULT_LABELS: Record<TreeController["variant"], string> = {
26
20
  navigation: "Main",
@@ -109,43 +103,6 @@ function TreeView({
109
103
  }: TreeViewProps): React.ReactElement {
110
104
  const { variant, items, sectioned, collapsed } = tree
111
105
  const rootRef = React.useRef<HTMLUListElement>(null)
112
- const reduce = useReducedMotion()
113
-
114
- // The rail animates between two px widths, so the expanded one has to be
115
- // measured off the container. Watched rather than read once: the rail is
116
- // as wide as whatever holds it, which can change on resize.
117
- //
118
- // A container with no width of its own sizes to the list — so it shrinks
119
- // around the 40px rail once collapsed and follows the list mid-tween. Both
120
- // would feed the list's own width back in as its target and pin it. So:
121
- // no observing while collapsed (the last expanded width stands), and each
122
- // sample is taken with the list's inline width cleared, so the classes'
123
- // resting width — not the tween — is what the container is holding.
124
- const railRef = React.useRef<HTMLElement>(null)
125
- const [railWidth, setRailWidth] = React.useState<number | null>(null)
126
- React.useLayoutEffect(() => {
127
- const container = railRef.current
128
- const list = rootRef.current
129
- if (variant !== "sidebar" || collapsed || !container || !list) return
130
- const measure = () => {
131
- const inline = list.style.width
132
- list.style.width = ""
133
- const width = container.clientWidth
134
- list.style.width = inline
135
- setRailWidth(width)
136
- }
137
- measure()
138
- const observer = new ResizeObserver(measure)
139
- observer.observe(container)
140
- return () => observer.disconnect()
141
- }, [variant, collapsed])
142
-
143
- // Until the measurement lands the className carries the width, so the
144
- // rail is never wrong — just not animated on its very first frame.
145
- const railTarget =
146
- variant === "sidebar" && railWidth !== null
147
- ? { width: collapsed ? RAIL_COLLAPSED_WIDTH : railWidth }
148
- : undefined
149
106
 
150
107
  // Roving over the DOM rather than a parallel model: collapsed branches
151
108
  // unmount, so a row query is exactly the visible set in order.
@@ -225,26 +182,17 @@ function TreeView({
225
182
 
226
183
  const label = ariaLabel ?? DEFAULT_LABELS[variant]
227
184
  let rendered = (
228
- <motion.ul
185
+ <ul
229
186
  aria-label={label}
230
187
  className={cn(
231
- "flex h-full min-w-0 flex-col",
232
- // Resting widths, and the fallback until the rail is measured. The
233
- // motion target below overrides these with an inline width.
234
- collapsed ? "w-10 gap-y-2!" : "w-full",
188
+ // The list always spans its container — collapsed included. The
189
+ // rail's fold is its container's width change (an icon-collapsed
190
+ // panel) plus the labels folding; rows keep the full width so their
191
+ // hit areas and hover surfaces span the rail edge to edge.
192
+ "flex h-full w-full min-w-0 flex-col",
193
+ collapsed && "gap-y-2!",
235
194
  variant === "files" ? "gap-px" : sectioned ? "gap-3" : "gap-0.5"
236
195
  )}
237
- // Both endpoints must be px: motion cannot interpolate a number
238
- // against "100%", and animating 40 -> "100%" pinned the inline width
239
- // at 40px, so the rail collapsed once and never reopened. The
240
- // expanded target is the rail's own measured width.
241
- initial={false}
242
- animate={railTarget}
243
- transition={
244
- reduce
245
- ? { duration: 0 }
246
- : { duration: TREE_COLLAPSE_DURATION, ease: TREE_EASE }
247
- }
248
196
  data-collapsed={collapsed ? "" : undefined}
249
197
  data-slot="tree"
250
198
  data-variant={variant}
@@ -255,7 +203,7 @@ function TreeView({
255
203
  {items.map((node) => (
256
204
  <TreeRow depth={0} key={node.id} node={node} />
257
205
  ))}
258
- </motion.ul>
206
+ </ul>
259
207
  )
260
208
 
261
209
  // The collapsed rail's rows carry tooltips — one provider groups their
@@ -274,9 +222,8 @@ function TreeView({
274
222
  ) : (
275
223
  <nav
276
224
  aria-label={label}
277
- className={cn("flex h-full min-w-0 justify-center", className)}
225
+ className={cn("flex h-full w-full min-w-0", className)}
278
226
  data-slot="tree-root"
279
- ref={railRef}
280
227
  >
281
228
  {rendered}
282
229
  </nav>