Repository navigation
feat(panel): add a resizable Panel with keep-mounted panes - #116
Conversation
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.
6c54d1d to
3838113
Compare
3838113 to
5271394
Compare
TheGreatAxios
left a comment
There was a problem hiding this comment.
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()runscommit(startWidth), andcommitseesresolved ≠ widthRef.current. A stray focus-click rewrites controlled state. Smallest fix: amovedflag set past a small px threshold, andend()a no-op when the pointer never moved. - minor
src/ui/panel.tsx:125, 169, 173— programmatic focus on the overlay panel (atabindex="-1"aside) matches no:focus-visiblerule, 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— duplicatePanelPanevalues both render visible in the same cell, and aPanelContent.valuematching no pane renders an empty body, both silently. Add dev-onlyconsole.errorguards, mirroringpanel-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. Onedir="rtl"unit test asserting ArrowRight widens would pin it.
5271394 to
5e768c0
Compare
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.
5e768c0 to
7e91d60
Compare
|
Thanks for the review. Here's what happened to each point. Fixed1. Clicking the resize handle without dragging saved a width (minor)
2. Duplicate or unmatched
3. Right-to-left had no tests (test gap)
Not changed, and why
Commit: |
TheGreatAxios
left a comment
There was a problem hiding this comment.
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()onpointerdownruns before the 4px check, so the browser does not firemousedownand does not move focus. The comment at:245says 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 readsdocument.activeElementafter React has already focused anautoFocuscontrol in the pane. On close that node is disconnected, theisConnectedcheck at:154-160fails, and focus lands ondocument.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 thetabIndex={-1}aside and inerts its siblings. Leaving only clears inert. The programmatic focus has no:focus-visiblering, 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.
7e91d60 to
f14c2a7
Compare
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.
f14c2a7 to
4e5df13
Compare
|
Thanks for the review. All three points are fixed. 1. A press on the resize handle didn't focus it
2. A pane that autofocuses a field became the saved opener, so closing dropped focus on the body
3. Leaving overlay mode left focus on the panel root
Commit: |
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.
4e5df13 to
febcd41
Compare
Round 3 fixesA 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.
|
febcd41 to
0aeed71
Compare
Superseded by the completed stacked review; blocking fixes were addressed and the rebased stack is being landed.
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
@container.@container relative flexrow (no gap) holding amin-w-0 flex-1main area and the panel.valueis controlled only: the consumer's tabs own which pane shows.labelon PanelHandle/PanelClose. README Chat table gains a Panel row.Verification
bun run typecheckandbun run linton its own (the stack is checked commit by commit).bun run check,bun run build,bun run dep-guard,bun run contrast-testandbun run test:e2e(Playwright specs and the Storybook axe sweep in light and dark) pass.Closes #106