Skip to content

feat(panel): add tabs for the Panel - #119

Merged
TheGreatAxios merged 7 commits into
conversation/09-prompt-input-toolbarfrom
conversation/13-panel-tabs
Oct 28, 2026
Merged

TheGreatAxios merged 7 commits into
conversation/09-prompt-input-toolbarfrom
conversation/13-panel-tabs

Conversation

@pratikbuilds

Copy link
Copy Markdown
Collaborator

Add PanelTabs, PanelTab and PanelTabAdd. Tabs select by click, close by the close button, middle-click or Delete, reorder by drag and by Ctrl/Cmd+Shift+Arrow, and move by Arrow, Home and End; a long row keeps the selected tab in view. useTabs owns the open tabs and which one is selected, and useTabDrag reorders without rendering until release. These are separate from the existing Tabs.

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

Notes for review

  • Selection and the open-tab list live in useTabs (defaultTabs/onTabsChange; value/defaultValue/onValueChange), so one place decides which tab takes over on close (right neighbour, else left).
  • The × is aria-hidden and not focusable (a tablist may only contain tabs); keyboard and screen-reader users close with Delete, which each tab announces.
  • Drag is mouse/pen only (touch scrolls the row); no auto-scroll at the edges while dragging; Ctrl/Cmd+Shift+Arrow moves anywhere.
  • Layout: give the tab PanelHeader items-end bg-muted pt-1.5 pb-0; PanelTabAdd sits beside PanelTabs; spread paneProps(value) on each PanelPane.

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 #107

Add PanelTabs, PanelTab and PanelTabAdd. Tabs select by click, close
by the close button, middle-click or Delete, reorder by drag and by
Ctrl/Cmd+Shift+Arrow, and move by Arrow, Home and End; a long row keeps
the selected tab in view. useTabs owns the open tabs and which one is
selected, and useTabDrag reorders without rendering until release.
These are separate from the existing Tabs.

@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. (Severity note: two agents confirmed the top finding; one rated it major, the validator judged it should-fix — recorded as should-fix.)

  • should-fix (cross-confirmed by two agents) src/ui/panel-tabs.tsx:176-188 — closing a tab while focus sits inside its pane drops focus to document.body instead of the tab row. Verified code path: close() only recovers when focus is inside the tab row; the stronger reproduction needs no host code at all — focus a control in the selected pane's contents, click the × on the active tab (its onMouseDown preventDefault keeps focus out of the row), the pane unmounts, focus lands on <body>. Middle-click and host-driven closes hit the same path. Smallest fix: capture document.activeElement before the flushSync and run the same refocus when it's no longer connected; target the newly selected tab. Permanent regression test: "closing the selected tab while a control inside its pane has focus moves focus to the newly selected tab."
  • minor (deliberate, unmitigated in docs/stories) panel-tabs.tsx:424-439 — the × affordance is aria-hidden + tabIndex={-1}, so the built-in close is keyboard-only (Delete/Backspace on a focused tab); touch screen-reader users have no reachable close unless the host adds the documented header control — and no story or fixture demonstrates that control. Ship the documented host-side pattern as an example (or offer an optional accessible close slot).
  • minor (documented deviation) src/hooks/use-tab-drag.ts:77-79 — touch pointers are excluded from drag-to-reorder while plan R40 says "reorder by drag" unqualified; note the tradeoff where the requirement is tracked, alongside the close-control example.
  • minor — drag-to-reorder announces nothing (no aria-live/role="status" in the module): a polite live region "Moved to position N" on onMove commit would cover SR users driving pointer drags.
  • nits — paneProps(value) omits value, so callers state it twice and a drift silently shows/hides the wrong pane (use-tabs.ts:178-184); the isProduction snippet duplicates use-controllable-state.ts:15-16 verbatim (panel-tabs.tsx:21-22); the header-layout class contract is prose hand-copied into the story and the fixture (panel-tabs.tsx:106-110) — export a preset constant.

Closing the selected tab with its × or a middle-click while focus sat
in its pane dropped focus to the body: neither control takes focus,
and only focus in the row was put back. Keep the focused element
before the close renders and refocus the newly selected tab when that
element left the document with its pane.

Tab moves by drag or Ctrl/Cmd+Shift+Arrow were silent to screen
readers. A polite status region beside the row now says "Moved to
position N of M", worded by an optional movedMessage prop.

Export panelTabsHeaderClassName so hosts stop copying the header
classes by hand. The story and e2e fixture now show the header
"Close tab" button that touch screen-reader users need, since the
tab's × is hidden from them. The docs also say why touch does not
drag.
useTabDrag wrote the latest onMove into a ref during render. Write it
from a layout effect instead, which runs before any pointer event can
reach the handler.
@pratikbuilds
pratikbuilds force-pushed the conversation/13-panel-tabs branch from 2dbd57b to 54d44e9 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. Closing a tab while focus was inside its pane dropped focus to the page (should-fix)

  • The fix: as you suggested, close() remembers which element had focus. If that element left the page along with the closed pane, focus moves to the newly selected tab. This covers the × and middle-click.
  • Test: "closing the selected tab while a control inside its pane has focus moves focus to the newly selected tab", as a unit test for both the × and middle-click, plus a Playwright case.

2. No accessible way to close a tab was shown (minor)

  • The fix: the story and the test fixture now have a "Close tab" button in the panel header that calls useTabs().close.

3. Touch doesn't drag (minor)

  • The fix: the docs now say only mouse and pen drag, and touch scrolls the row instead, because a long row has no other way to scroll on a phone.

4. Moving a tab announced nothing (minor)

  • The fix: a polite status next to the tab row says "Moved to position 2 of 3" after a drag or Ctrl/Cmd+Shift+Arrow. It says nothing if the host refuses the move. A movedMessage prop changes the wording.

5. The header classes were copied by hand (nit)

  • The fix: they're now exported as panelTabsHeaderClassName.

Not changed, and why

  • Adding value to paneProps(): panes are often plain <div>s, so this would put a stray value attribute on them.
  • Sharing the isProduction snippet: that code lives in another PR's file.

Also in this PR

A refactor(panel-tabs) commit stops useTabDrag from writing a ref during render. The ref is now updated in a layout effect.

Commits: fix(panel-tabs): keep focus and announce moves in the tab row, refactor(panel-tabs): keep the drag's onMove current from an effect

@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 Panel tabs: select, close, keyboard reorder, and pointer drag.

Findings

  • src/ui/panel-tabs.tsx:234 — Dragging a tab that is not selected leaves focus on it after the drop, while tabIndex={0} stays on the selected tab (panel-tabs.tsx:451). The press focuses the dragged tab, and the release click is swallowed (use-tab-drag.ts:145-149), so it never becomes selected. move only runs when the index changes, and then it focuses the moved tab rather than the selected one. Tab and the arrows then start from a tabIndex={-1} tab and skip the selected one. After a drag, put focus on the selected tab unless the drag itself changed the selection.

  • src/hooks/use-tab-drag.ts:145-148 — Escape cancels the reorder but the later mouseup still selects that tab. finish(false) arms swallowClick and removes it on a zero-delay timeout. Escape is not the pointer release, so the timeout runs while the button is still down, and the click on mouseup is a normal select (panel-tabs.tsx:452-454). The same finish(false) path is pointercancel. Swallow the next click whenever it arrives, not the click that happens to land in this turn.

@pratikbuilds
pratikbuilds force-pushed the conversation/13-panel-tabs branch from 54d44e9 to 27e0953 Compare October 28, 2026 08:41
The press that starts a drag focuses the pressed tab, and the drag
never selects it, so after a drop focus sat on an unselected tab
outside the row's tab stop: the arrows started from it and Delete
closed it while another tab was shown. useTabDrag now reports the end
of every drag, and PanelTabs sends focus in the row back to the
selected tab whether the tab moved or not.

Escape cancelled a drag while the button was still down, and the
click swallow was disarmed before the release, so letting go selected
the tab after all. Swallow the click of that press's own release
instead, and stop waiting if another press comes first.
@pratikbuilds
pratikbuilds force-pushed the conversation/13-panel-tabs branch from 27e0953 to 0b465da Compare October 28, 2026 11:28
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Both points are fixed.

1. After dragging a tab that isn't selected, focus stayed on it

  • The fix: useTabDrag takes a new optional onDragEnd. It is called once after every drag: one dropped somewhere new, one dropped where it began, and one cancelled. PanelTabs uses it to send focus in the row back to the selected tab. A drag still doesn't select the tab it moves.
  • Tests:
    • The Playwright case "dragging a tab to the last slot moves it there" now also expects the selected tab to have focus.
    • The new case "a drag dropped where it began leaves focus on the selected tab" also checks that Delete then closes the selected tab, not the dragged one.
    • Both failed before the fix.

2. Releasing a drag cancelled with Escape still selected the tab

  • The fix: only the click from that press's own release is swallowed.
    • After Escape, the hook waits for the drag's pointer to come up, then swallows the click that release makes.
    • If the release never reaches the page and another press comes first, it stops waiting. So a later, unrelated click is never eaten, which "the next click whenever it arrives" could do.
    • pointercancel no longer arms the swallow. No click follows it, so arming there could only eat an unrelated click.
  • Test: the Playwright case "releasing a drag cancelled with Escape does not select the tab". It pauses before the release, then checks that a plain click on the same tab still selects it. It failed before the fix.

Commit: fix(panel-tabs): return focus to the selected tab after a drag

useTabs' close always passed an updater to the selection setter, which
reports whatever the updater returns. Closing a tab that was not
selected still called onValueChange with the unchanged selection. With
a controlled value the updater saw the value from the last render, so
several closes in one event did not see each other: closing every tab
reported a selection that had already closed instead of null.

Track the selection the current event has reached in a ref that every
commit sets back to the rendered value, and select on close only when
the closing tab is that selection. A controlled parent can turn down a
plain select without rendering, so that select leaves the ref alone.
The panel tabs story showed "Open panel" 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, or
closing its last tab, sent focus to the body.

Keep a toggle in the main area, with aria-expanded and aria-controls,
so the panel records it and returns focus to it however it closes.
PanelTabs and PanelTab read `ref` from their 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 row or a tab's root.

Wrap both in forwardRef so the refs land on both versions.
@pratikbuilds
pratikbuilds force-pushed the conversation/13-panel-tabs branch from 0b465da to 7884629 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.

  • 6472f74: useTabs().close reported the selection on every close, and with a controlled value a close-all reported an already-closed tab (b, a, a, a). It now reports only when the closed tab was the selected one (b, c, d, null), and a select the controlled parent declined no longer steers a later close.
  • 7ac7601: The story's opener unmounted while the panel was open; it now uses an always-mounted toggle.
  • 7884629: 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 PanelTabs and PanelTab.

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 force-pushed the conversation/13-panel-tabs branch from 7884629 to 3f4621e Compare October 28, 2026 21:42
@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 75035c2 into main Oct 28, 2026
7 of 10 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 tabs for the Panel

2 participants