Skip to content

feat(panel): add a resizable Panel with keep-mounted panes - #116

Merged
TheGreatAxios merged 7 commits into
conversation/08-prompt-inputfrom
conversation/12-panel
Oct 28, 2026
Merged

TheGreatAxios merged 7 commits into
conversation/08-prompt-inputfrom
conversation/12-panel

Conversation

@pratikbuilds

Copy link
Copy Markdown
Collaborator

Panel resizes from PanelHandle by pointer without a React render while dragging, by arrow keys, and resets on double-click or Enter. The main area keeps a minimum width and a narrow container gets an overlay. PanelContent with keepMounted keeps a hidden pane's scroll position and typed text. useResizable holds the width logic.

Stacked on #115: review only this commit. Part of #94.

Notes for review

  • Width is a CSS variable written by useResizable; zero React renders while dragging (e2e counts renders), one save on release.
  • Overlay uses a container query (row narrower than 48rem), not a viewport breakpoint, so a sidebar beside the row counts; the consumer marks the row @container.
  • Layout rule: Panel is the last child of a @container relative flex row (no gap) holding a min-w-0 flex-1 main area and the panel.
  • PanelContent value is controlled only: the consumer's tabs own which pane shows.
  • Extra props: minWidth (320), mainMinWidth (440), defaultWidth; label on PanelHandle/PanelClose. README Chat table gains a Panel row.

Verification

  • This commit passes bun run typecheck and bun run lint on its own (the stack is checked commit by commit).
  • At the top of the stack: bun run check, bun run build, bun run dep-guard, bun run contrast-test and bun run test:e2e (Playwright specs and the Storybook axe sweep in light and dark) pass.

Closes #106

Panel resizes from PanelHandle by pointer without a React render while
dragging, by arrow keys, and resets on double-click or Enter. The main
area keeps a minimum width and a narrow container gets an overlay.
PanelContent with keepMounted keeps a hidden pane's scroll position and
typed text. useResizable holds the width logic.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated multi-agent review: independent review + second-agent validation; only confirmed items listed (all minors/nits — no blockers found in this PR).

  • minor src/hooks/use-resizable.ts:240, 281-284 — a pointer click with no movement commits the currently rendered, CSS-clamped width (or silently resets to the measured default when the clamped value equals it): move() never runs, end() runs commit(startWidth), and commit sees resolved ≠ widthRef.current. A stray focus-click rewrites controlled state. Smallest fix: a moved flag set past a small px threshold, and end() a no-op when the pointer never moved.
  • minor src/ui/panel.tsx:125, 169, 173 — programmatic focus on the overlay panel (a tabindex="-1" aside) matches no :focus-visible rule, so the focus move is invisible. Apply a static ring/data-attribute while overlay mode is active, or focus the panel's first control.
  • minor src/ui/panel.tsx:333-334 — duplicate PanelPane values both render visible in the same cell, and a PanelContent.value matching no pane renders an empty body, both silently. Add dev-only console.error guards, mirroring panel-tabs.tsx:137-145.
  • minor (test gap) src/hooks/use-resizable.ts — RTL mirroring is fully implemented (drag inversion, arrow-key flip, rtl:translate-x-px) with zero test coverage anywhere on the branch. One dir="rtl" unit test asserting ArrowRight widens would pin it.

A press on the resize handle that never moved still committed the
rendered width on release. When the container had clamped a stored
width, that click silently overwrote the user's choice. When the stored
width equalled the default, the click reset it. The pointer now has to
travel 4px before the press counts as a drag, and only a drag commits.

In development, PanelContent now logs a console error when two panes
share a value or when its value names no pane. Each pane registers its
value from an effect, so this check also catches a missing value
without keepMounted, where panes that are not shown never reach the DOM.

Adds tests for both, and for the right-to-left mirroring of keys and
drags, which had none.
@pratikbuilds
pratikbuilds force-pushed the conversation/12-panel branch from 5e768c0 to 7e91d60 Compare October 27, 2026 13:54
@pratikbuilds

pratikbuilds commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Here's what happened to each point.

Fixed

1. Clicking the resize handle without dragging saved a width (minor)

  • The fix: a press now counts as a drag only after the pointer moves 4 px, the same as the tab drag. Only a real drag saves a width, so a click can no longer overwrite a stored width with the clamped one.
  • Test: "a press that never moves past the drag threshold commits nothing". It failed before the fix.

2. Duplicate or unmatched PanelPane values failed silently (minor)

  • The fix: in development, PanelContent logs an error when two panes share a value, or when value matches no pane. This uses registration rather than a DOM count, so a panel that has no panes yet doesn't warn.
  • Test: covers duplicates, a missing value, and the empty case staying quiet.

3. Right-to-left had no tests (test gap)

  • The fix: added tests for the arrow keys and dragging under direction: rtl. The behaviour was already right.

Not changed, and why

  • The focus ring on the overlay panel: the ring does show when the overlay is opened from the keyboard. After a mouse click, the browser hides it on purpose, which is how :focus-visible is meant to work.

Commit: fix(panel): ignore handle clicks and flag pane value mistakes

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review · Request changes

Adds a resizable Panel. Panes stay mounted, and a drag commits only after the pointer moves 4px.

Findings

  • src/hooks/use-resizable.ts:229 — A pointer press on the separator never focuses it. preventDefault() on pointerdown runs before the 4px check, so the browser does not fire mousedown and does not move focus. The comment at :245 says a press that does not travel is a focusing click, but focus stays on whatever was active, and the arrow keys do nothing until Tab reaches the handle. Prevent the default only once the press has crossed the drag threshold.

  • src/ui/panel.tsx:121 — The saved opener is an autofocused field inside the panel. This layout effect reads document.activeElement after React has already focused an autoFocus control in the pane. On close that node is disconnected, the isConnected check at :154-160 fails, and focus lands on document.body. Capture the opener during render, before the autofocus commit.

  • src/ui/panel.tsx:133 — Leaving overlay mode leaves focus on the panel root. Entering overlay focuses the tabIndex={-1} aside and inerts its siblings. Leaving only clears inert. The programmatic focus has no :focus-visible ring, and the arrow keys do not resize until Tab reaches the handle. Remember what was focused when the row became an overlay, and return there when it is side by side again if focus is still on the panel.

@pratikbuilds
pratikbuilds force-pushed the conversation/12-panel branch from 7e91d60 to f14c2a7 Compare October 28, 2026 08:41
A press on the resize handle never focused it: preventing the
pointerdown default also cancelled the focus a press gives, so the
arrow keys did nothing until Tab reached the handle. The drag already
blocks text selection with user-select, so let the press focus it.

A pane that autofocuses a field takes focus before the panel's mount
effect runs, so the panel saved that field as its opener and focus
fell to the body on close. Record where focus came from when it moved
into the panel and use that as the opener in this case. Under
StrictMode the simulated remount would hand the field's focus to the
opener, so give it back when the effect sets up again.

When the row widened and the overlay went away, focus stayed on the
panel root. Give it back to what the overlay took it from, if it is
still on the panel itself.
@pratikbuilds
pratikbuilds force-pushed the conversation/12-panel branch from f14c2a7 to 4e5df13 Compare October 28, 2026 11:28
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. All three points are fixed.

1. A press on the resize handle didn't focus it

  • The fix: the handle no longer prevents the press's default, so the press focuses it the way it would any focusable element. The drag already blocks text selection with user-select: none, so nothing else needed the preventDefault.
    • I also tried calling focus() by hand instead. It works too, but after keyboard use it made a mouse click on the handle show the keyboard focus ring. The browser's own focus doesn't do that.
  • Test: the Playwright case "a click on the handle focuses it for the arrow keys". It clicks the handle without moving, then checks that ArrowLeft resizes. It failed before the fix.

2. A pane that autofocuses a field became the saved opener, so closing dropped focus on the body

  • The fix: when focus moves into the panel from outside, the panel records where it came from (the focus event's relatedTarget). React focuses an autoFocus field before the panel's mount effect runs, and that focus event names the real opener. The mount effect uses it when focus is already inside the panel, and the active element otherwise. Nothing is read during render.
    • Under StrictMode, the simulated remount moved focus from the field back to the opener. The effect now gives focus back to the field when it sets up again, and a real close still returns it to the opener.
  • Tests:
    • "closing returns focus to the opener when a pane autofocused a field" failed before the fix.
    • "under StrictMode an autofocused field keeps focus, and close returns it" failed against the first version of this fix.

3. Leaving overlay mode left focus on the panel root

  • The fix: when the row turns into an overlay, the panel remembers what had focus outside it. When the row is side by side again, focus goes back there, but only if it is still on the panel root itself. If focus has moved on inside the panel, it stays where it is.
  • Test: the Playwright case "narrowing the row while the panel is open turns it into an overlay" now checks that focus is back on "Show notes" after the row widens. It failed before the fix.

Commit: fix(panel): keep focus where a press, autofocus or resize leaves it

useResizable wrote the width and the bounds into refs in the hook body.
A render React later discards, such as a transition that suspends,
still left its width there, so the next key press on the handle wrote
that uncommitted width onto the element and compared against it.

Set both refs in the layout effect that already runs when the width or
the bounds change, before it writes the width to the element.
PanelPane passed `inert={!shown}`. React 18 does not know the attribute:
it dropped the boolean and logged a warning, so a hidden pane kept
mounted was never inert there, and the shown pane passed `inert={false}`
for nothing.

Pass `inert` only on a hidden pane, as the string "inert", which both
React 18 and 19 write to the DOM without a warning.
Panel read `ref` from its props, which only React 19 passes to a
function component. On React 18, which the peer range allows, the ref
was dropped with a warning, so a host could not reach the panel
element.

Wrap Panel in forwardRef so the ref reaches the aside on both.
The panel stories showed "Open thread" only while the panel was closed,
so the button left the page in the same commit that mounted the panel.
The panel found no opener to record, and closing it sent focus to the
body instead of back to the button.

Keep a toggle in the main area in every panel story, with aria-expanded
and aria-controls, so the panel records it and returns focus to it.
@pratikbuilds
pratikbuilds force-pushed the conversation/12-panel branch from 4e5df13 to febcd41 Compare October 28, 2026 14:47
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Round 3 fixes

A fresh review pass over the stack turned up the points below; each is fixed in a new commit at the end of this branch, and the stack is rebased onto it.

  • dd97728: useResizable wrote widthRef and boundsRef during render, so a render React threw away (a suspended transition) could leave the handle working from its values. They are now set in the layout effect.
  • 7965439: inert={!shown} is dropped by React 18. A hidden pane now gets inert="inert", which works on 18 and 19, and the shown pane gets none.
  • 2bc344f: React 18 refs: components that read ref from props only receive it on React 19, while the peer range allows ^18.2.0. They are now wrapped in forwardRef, so a host ref reaches the element on both; the peer range is unchanged. Covers Panel.
  • febcd41: The stories' opener unmounted while the panel was open, so closing it dropped focus to the page. They now use an always-mounted toggle with aria-expanded/aria-controls.

bun run check and dep-guard pass on every branch of the stack, and the full e2e suite passes at the top of the stack (163/163, including the stories axe sweep). Each behaviour fix comes with a test that fails without it; the forwardRef tests can only guard, since the suite runs on React 19.

@TheGreatAxios
TheGreatAxios removed this pull request from stack #130 October 28, 2026 21:38
@TheGreatAxios
TheGreatAxios added this pull request to stack #139 October 28, 2026 21:38
@TheGreatAxios
TheGreatAxios dismissed their stale review October 28, 2026 21:49

Superseded by the completed stacked review; blocking fixes were addressed and the rebased stack is being landed.

@TheGreatAxios
TheGreatAxios merged commit 123b531 into main Oct 28, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Panel

2 participants