Skip to content

feat(ui): Tab detach on vertical drag (Phase 3)#10

Merged
joshuapare merged 4 commits into
mainfrom
feat/tabs-dnd-2
Mar 8, 2026
Merged

feat(ui): Tab detach on vertical drag (Phase 3)#10
joshuapare merged 4 commits into
mainfrom
feat/tabs-dnd-2

Conversation

@joshuapare
Copy link
Copy Markdown
Contributor

@joshuapare joshuapare commented Mar 8, 2026

Summary

  • Adds tab detaching via vertical drag beyond a configurable threshold (detachThresholdPx, default 18px)
  • Fires onDetachCommit({ id, payload, screenX, screenY }) when a tab is dropped in detach mode, enabling the host app (Wails) to spawn a new window
  • Horizontal drag continues to reorder tabs as before (Phase 2 behavior preserved)
  • detachable prop (default true) can disable detach entirely

Key implementation details

  • useTabDetach hook — native pointermove listener for pointer tracking with hysteresis to prevent flicker near the threshold boundary
  • createDetachAwareModifier — single modifier replacing restrictToHorizontalAxis + restrictToVisibleScrollArea, mode-aware (horizontal-only in reorder, free movement in detach)
  • DragOverlay with CSS variable snapshot — snapshots computed --_ov-* variables at drag start and injects as inline styles on the portal, solving the CSS variable loss issue
  • DetachGhostTab — lightweight non-interactive tab clone for the overlay
  • Keyboard drags cannot trigger detach (by design — no pointermove events)

New files

  • hooks/useTabDetach.ts + test
  • modifiers/createDetachAwareModifier.ts + test
  • DetachGhostTab.tsx

Stories

  • Detachable — drag tabs vertically to spawn fake floating windows with draggable title bars, traffic lights, and code preview; closing the window returns the tab
  • DetachDisableddetachable={false}, vertical drag locked to horizontal

Test plan

  • pnpm typecheck passes
  • pnpm test passes (263 tests, 52 files)
  • Storybook: horizontal drag reorders normally
  • Storybook: vertical drag beyond threshold shows ghost overlay
  • Storybook: dropping in detach mode spawns fake window at pointer position
  • Storybook: returning pointer to strip reverts to reorder mode
  • Storybook: detachable={false} locks to horizontal only
  • Close button, keyboard nav, all variants still work

Summary by CodeRabbit

  • New Features

    • Detachable editor tabs: drag past a threshold to spawn detachable windows with a ghost overlay.
    • Configurable detach behavior and events: new props to enable/adjust detaching and receive detach commits.
    • Drag-mode exposed in context so UI can react to reorder vs detach states.
  • Style

    • New detach visuals and reduced-motion handling for the detach overlay and source tab.
  • Tests

    • Comprehensive tests for detach flows, sensors/modifiers, and edge cases.
  • Chores

    • Added interactive stories/demos showcasing detachable behavior.

Drag a tab vertically beyond a threshold to detach it, firing
onDetachCommit with screen coordinates and payload for the host
app to spawn a new window.

- useTabDetach hook with native pointermove tracking and hysteresis
- createDetachAwareModifier replacing restrictToHorizontalAxis
- DragOverlay with CSS variable snapshot for detach ghost
- DetachGhostTab lightweight clone component
- Detachable/DetachDisabled stories with fake window demo
- 15 new tests for detach hook, modifier, and integration
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 8, 2026

Warning

Rate limit exceeded

@joshuapare has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 5 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 284ee217-15b6-4e3b-be7b-ad691f79309d

📥 Commits

Reviewing files that changed from the base of the PR and between 5c74eae and 03ff6d6.

📒 Files selected for processing (1)
  • packages/base-ui/src/components/editor-tabs/EditorTabs.tsx
📝 Walkthrough

Walkthrough

Adds a detach-capable drag flow to EditorTabs: new useTabDetach hook and DragMode/DetachCommit types, a detach-aware modifier, DetachGhostTab UI with CSS and DragOverlay integration, EditorTabs and EditorTabItem updates to expose/use dragMode, storybook demos, and comprehensive tests for detach behavior.

Changes

Cohort / File(s) Summary
Detach UI component & types
packages/base-ui/src/components/editor-tabs/DetachGhostTab.tsx, packages/base-ui/src/components/editor-tabs/types.ts, packages/base-ui/src/components/editor-tabs/index.ts
Adds DetachGhostTab component; introduces DetachCommit and DragMode types and re-exports them from the editor-tabs index.
Detach state hook & tests
packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts, packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.test.ts
New useTabDetach hook implementing idle → reorder → detach-armed modes, pointer tracking, hysteresis, commit/cancel/cleanup; comprehensive unit tests for transitions and commit behavior.
Detach-aware DnD modifier & tests
packages/base-ui/src/components/editor-tabs/modifiers/createDetachAwareModifier.ts, packages/base-ui/src/components/editor-tabs/modifiers/createDetachAwareModifier.test.ts
Adds modifier that clamps X and zeros Y in reorder mode, passes transforms through in detach-armed mode; tests cover clamping, bounds, and passthrough.
EditorTabs integration & context
packages/base-ui/src/components/editor-tabs/EditorTabs.tsx, packages/base-ui/src/components/editor-tabs/EditorTabItem.tsx, packages/base-ui/src/components/editor-tabs/context/EditorTabsContext.tsx
Integrates detach hook/modifier into DnD flow; adds detachable, detachThresholdPx, onDetachCommit props; tracks dragMode, renders DragOverlay with DetachGhostTab, and sets data-detach-source on tab when appropriate.
Styling for detach state
packages/base-ui/src/components/editor-tabs/EditorTabs.module.css
Adds .Tab[data-detach-source] and .DetachGhost selectors and reduced-motion exceptions to support detach visuals and hide the source tab when overlay is active.
Stories & examples
packages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsx
Adds Detachable and DetachDisabled stories with FakeWindow demo, spawn/remove detached-window flow, and detach-related story args.
EditorTabs tests
packages/base-ui/src/components/editor-tabs/EditorTabs.test.tsx
Adds tests verifying normal render, absence of data-detach-source when inactive, listener behavior when detachable is false, and safe wiring of onDetachCommit.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Editor as EditorTabs
    participant Hook as useTabDetach
    participant Overlay as DragOverlay
    participant Parent as ParentComponent

    User->>Editor: pointerdown + drag start
    Editor->>Hook: handleDetachDragStart(event)
    Hook->>Hook: set dragMode='reorder'\nattach pointermove listener

    User->>Hook: pointermove (beyond threshold)
    Hook->>Hook: compute delta\nset dragMode='detach-armed'
    Hook->>Editor: update context.dragMode
    Editor->>Overlay: render DetachGhostTab (DragOverlay)

    User->>Editor: release pointer (drag end)
    Editor->>Hook: handleDetachDragEnd(event)
    alt dragMode == 'detach-armed'
        Hook->>Parent: onDetachCommit({ id, payload, screenX, screenY })
        Parent->>Parent: spawn detached window\nremove tab from main bar
    end

    Hook->>Hook: cleanup() -> remove listener, set dragMode='idle'
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A tiny hop, then off you go—

Ghost tab drifts in silver glow,
I nudge the sky, you spring apart,
A gentle leap, a fluttered heart,
Hooray! New windows — hop, depart!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ui): Tab detach on vertical drag (Phase 3)' directly and clearly summarizes the main change: adding tab detach functionality triggered by vertical drag, with phase context.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/tabs-dnd-2

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/base-ui/src/components/editor-tabs/DetachGhostTab.tsx`:
- Around line 10-15: The ghost clone rendered by DetachGhostTab is decorative
and duplicates the live tab label for assistive tech; update the JSX returned by
the DetachGhostTab component to include aria-hidden="true" on the root div (the
element with className using styles.Tab and styles.DetachGhost) so screen
readers ignore this cloned content while dragging.

In `@packages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsx`:
- Around line 611-623: The close button in the EditorTabs story currently lacks
an explicit type which can cause unintended form submissions; update the button
element (the one rendering LuX and calling onClose) to include type="button" so
it behaves as a non-submit control across contexts—locate the button with
onClick={onClose} inside EditorTabs.stories (the close button JSX) and add the
type attribute.

In `@packages/base-ui/src/components/editor-tabs/EditorTabs.test.tsx`:
- Around line 165-193: Add pointer drag/drop integration tests that exercise the
EditorTabs detach API instead of only mount smoke tests: simulate a pointerdown
on the tab element (use screen.getByRole('tab', { name: 'index.ts' })) then move
the pointer enough to cross the component's detach threshold and fire pointerup
to trigger the detach flow, and assert that the onDetachCommit mock receives an
object with { id, payload, screenX, screenY } matching the tab and pointer
coordinates; also add a test that renders <EditorTabs detachable={false} ... />
and performs the same pointer drag sequence and assert that onDetachCommit is
not called and no [data-detach-source] is added to the document.

In `@packages/base-ui/src/components/editor-tabs/EditorTabs.tsx`:
- Around line 105-106: Replace the useMemo-created mutable containers with
useRef to be idiomatic: change rootElRef (currently defined via useMemo(() => ({
current: null as HTMLDivElement | null }), [])) to useRef<HTMLDivElement |
null>(null) and change cssVarSnapshotRef (currently useMemo(() => ({ current:
null as React.CSSProperties | null }), [])) to useRef<React.CSSProperties |
null>(null); also add useRef to the React import. This preserves the same types
and behavior while communicating intent more clearly.

In
`@packages/base-ui/src/components/editor-tabs/modifiers/createDetachAwareModifier.test.ts`:
- Around line 45-61: Add a symmetric test that verifies the lower-bound clamp
for X in reorder mode by mirroring the existing upper-bound case: call
createDetachAwareModifier with modeRef.current = 'reorder', use makeModifierArgs
with a transform.x negative enough to push the node's left past the
ancestor.left, and assert the returned x is clamped so the node.left equals
ancestor.left (and y clamped to 0 as in the other test). Reference the existing
test pattern and helper functions createDetachAwareModifier and makeModifierArgs
to construct the new "clamps X to ancestor left edge in reorder mode" test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: aec51ab6-3b6b-4d27-86a4-bc00a1b860d1

📥 Commits

Reviewing files that changed from the base of the PR and between cbd2f4c and 8efc1b7.

📒 Files selected for processing (13)
  • packages/base-ui/src/components/editor-tabs/DetachGhostTab.tsx
  • packages/base-ui/src/components/editor-tabs/EditorTabItem.tsx
  • packages/base-ui/src/components/editor-tabs/EditorTabs.module.css
  • packages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsx
  • packages/base-ui/src/components/editor-tabs/EditorTabs.test.tsx
  • packages/base-ui/src/components/editor-tabs/EditorTabs.tsx
  • packages/base-ui/src/components/editor-tabs/context/EditorTabsContext.tsx
  • packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.test.ts
  • packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts
  • packages/base-ui/src/components/editor-tabs/index.ts
  • packages/base-ui/src/components/editor-tabs/modifiers/createDetachAwareModifier.test.ts
  • packages/base-ui/src/components/editor-tabs/modifiers/createDetachAwareModifier.ts
  • packages/base-ui/src/components/editor-tabs/types.ts

Comment thread packages/base-ui/src/components/editor-tabs/DetachGhostTab.tsx
Comment thread packages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsx
Comment on lines +165 to +193
it('renders normally with detachable={false}', () => {
renderWithTheme(
<EditorTabs tabs={baseTabs} activeId="file1" detachable={false} />,
);

expect(screen.getByRole('tab', { name: 'index.ts' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'App.tsx' })).toBeInTheDocument();
});

it('does not apply data-detach-source during normal render', () => {
renderWithTheme(<EditorTabs tabs={baseTabs} activeId="file1" />);

const detachSource = document.querySelector('[data-detach-source]');
expect(detachSource).not.toBeInTheDocument();
});

it('accepts onDetachCommit prop without error', () => {
const onDetachCommit = vi.fn();
renderWithTheme(
<EditorTabs
tabs={baseTabs}
activeId="file1"
onDetachCommit={onDetachCommit}
/>,
);

expect(screen.getByRole('tab', { name: 'index.ts' })).toBeInTheDocument();
expect(onDetachCommit).not.toHaveBeenCalled();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Exercise the detach API, not just mount-time smoke paths.

These assertions only prove the component renders. They will not catch regressions in the detachable={false} gate or the onDetachCommit wiring. Please add at least one pointer drag/drop test here that crosses the detach threshold and asserts the emitted { id, payload, screenX, screenY }, plus one that proves detachable={false} suppresses it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/base-ui/src/components/editor-tabs/EditorTabs.test.tsx` around lines
165 - 193, Add pointer drag/drop integration tests that exercise the EditorTabs
detach API instead of only mount smoke tests: simulate a pointerdown on the tab
element (use screen.getByRole('tab', { name: 'index.ts' })) then move the
pointer enough to cross the component's detach threshold and fire pointerup to
trigger the detach flow, and assert that the onDetachCommit mock receives an
object with { id, payload, screenX, screenY } matching the tab and pointer
coordinates; also add a test that renders <EditorTabs detachable={false} ... />
and performs the same pointer drag sequence and assert that onDetachCommit is
not called and no [data-detach-source] is added to the document.

Comment thread packages/base-ui/src/components/editor-tabs/EditorTabs.tsx Outdated
- Add aria-hidden to DetachGhostTab overlay clone
- Replace useMemo ref containers with idiomatic useRef
- Add type="button" to story close button
- Add left-edge X clamp test for modifier
- Add detach integration tests (pointermove listener, prop wiring)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/base-ui/src/components/editor-tabs/EditorTabs.tsx`:
- Around line 108-115: The dependency array for the useCallback creating
rootRefCb includes rootElRef even though rootElRef is a stable ref from useRef
and never changes; update the useCallback dependencies to only include ref
(i.e., change [ref, rootElRef] to [ref]) so rootRefCb won't be recreated
unnecessarily; keep the function body as-is referencing rootElRef.current.
- Around line 143-147: The snapshot currently uses Record<string,string> then is
double-cast to React.CSSProperties; instead declare snapshot as a combined type
that satisfies both shapes so you can assign directly to
cssVarSnapshotRef.current: e.g. const snapshot: Partial<Record<string,string>> &
React.CSSProperties = {}; then populate it with computed.getPropertyValue(key)
for each key from CSS_VAR_SNAPSHOT_KEYS and set cssVarSnapshotRef.current =
snapshot; this uses symbols CSS_VAR_SNAPSHOT_KEYS, computed.getPropertyValue,
and cssVarSnapshotRef to locate the change.
- Around line 138-153: The useCallback for handleDragStart includes stable refs
rootElRef and cssVarSnapshotRef in its dependency array even though their
.current identities never change; update the dependency array of handleDragStart
to remove rootElRef and cssVarSnapshotRef and only include the actual changing
dependencies (e.g., reorder and detach) so the callback isn't needlessly
recreated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1bd505fb-5e8a-4949-8ca8-42126c406ef6

📥 Commits

Reviewing files that changed from the base of the PR and between 8efc1b7 and c8d31f3.

📒 Files selected for processing (5)
  • packages/base-ui/src/components/editor-tabs/DetachGhostTab.tsx
  • packages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsx
  • packages/base-ui/src/components/editor-tabs/EditorTabs.test.tsx
  • packages/base-ui/src/components/editor-tabs/EditorTabs.tsx
  • packages/base-ui/src/components/editor-tabs/modifiers/createDetachAwareModifier.test.ts

Comment thread packages/base-ui/src/components/editor-tabs/EditorTabs.tsx
Comment thread packages/base-ui/src/components/editor-tabs/EditorTabs.tsx
Comment thread packages/base-ui/src/components/editor-tabs/EditorTabs.tsx Outdated
- Remove stable refs from useCallback dependency arrays
- Use intersection type for CSS var snapshot to avoid double cast
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/base-ui/src/components/editor-tabs/EditorTabs.tsx`:
- Line 139: Replace the verbose Parameters<...> type usage in the DndContext
drag handlers with the direct types exported from `@dnd-kit/core`: import
DragStartEvent and DragEndEvent and use those types for the onDragStart and
onDragEnd handler parameters (the current anonymous parameter at the location
using Parameters<NonNullable<React.ComponentProps<typeof
DndContext>['onDragStart']>>[0] and the corresponding onDragEnd signature around
the other handler). This simplifies the signatures in EditorTabs.tsx and keeps
the handler parameter types clear and explicit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 54f50623-af5c-4586-9bdc-ca90acc11507

📥 Commits

Reviewing files that changed from the base of the PR and between c8d31f3 and 5c74eae.

📒 Files selected for processing (1)
  • packages/base-ui/src/components/editor-tabs/EditorTabs.tsx

Comment thread packages/base-ui/src/components/editor-tabs/EditorTabs.tsx Outdated
Replace verbose Parameters<NonNullable<...>> type extraction with
direct imports from @dnd-kit/core.
@joshuapare joshuapare merged commit ff5a13d into main Mar 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant