feat(desktop): hover X close button on zone tabs

Add a hover-to-close X button to PaneTab (the fancy-zones tab shell).
The button slot is always reserved inline (shrink-0) so the tab width
stays stable whether the X is visible or not — no layout shift on hover.
Visible on group-hover/tab, hidden by default via opacity transition.

The dirty dot yields to the X when both are present (closeable + dirty):
the X wins on hover, the dot shows otherwise. Vertical tabs skip the X
(writing-mode:vertical-rl makes an inline button awkward) and keep the
absolute-positioned dirty dot.

Pointerdown on the X is stopped so the tab's drag/activate handlers
never fire — the X is a leaf close action, not a drag start.
This commit is contained in:
Austin Pickett 2026-07-22 10:00:55 -04:00
parent b1201213b7
commit b281134fdd
2 changed files with 83 additions and 3 deletions

View file

@ -90,3 +90,54 @@ describe('PaneTab close gestures', () => {
expect(onPointerDown).toHaveBeenCalledTimes(1)
})
})
describe('PaneTab hover close button', () => {
it('renders a close button when onClose is set on a horizontal tab', () => {
const onClose = vi.fn()
render(
<PaneTab onClose={onClose}>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
expect(screen.getByRole('button', { name: 'Close tab' })).toBeTruthy()
})
it('clicking the close button calls onClose and stops propagation', () => {
const onClose = vi.fn()
const onTabPointerDown = vi.fn()
render(
<PaneTab onClose={onClose} onPointerDown={onTabPointerDown}>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
const closeBtn = screen.getByRole('button', { name: 'Close tab' })
fireEvent.pointerDown(closeBtn)
fireEvent.click(closeBtn)
expect(onClose).toHaveBeenCalledTimes(1)
// The tab's own pointerdown handler must NOT fire — the X is a leaf action.
expect(onTabPointerDown).not.toHaveBeenCalled()
})
it('does not render a close button on vertical tabs', () => {
const onClose = vi.fn()
render(
<PaneTab onClose={onClose} vertical>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
expect(screen.queryByRole('button', { name: 'Close tab' })).toBeNull()
})
it('does not render a close button without onClose', () => {
render(
<PaneTab>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
expect(screen.queryByRole('button', { name: 'Close tab' })).toBeNull()
})
})

View file

@ -1,5 +1,6 @@
import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
/** Inset bottom stroke for a horizontal tab strip — titlebar color, cut by the active tab. */
@ -27,8 +28,8 @@ const TAB_IDLE =
interface PaneTabProps extends React.ComponentProps<'div'> {
active?: boolean
dirty?: boolean
/** Close gesture, no hover X (too easy to hit on small tabs): middle-click,
* or -click as the trackpad-friendly Mac equivalent. */
/** Close gesture: hover X on horizontal tabs, plus middle-click and -click
* (the trackpad-friendly Mac equivalent). */
onClose?: () => void
/** Vertical rail form (collapsed sidebar zones). */
vertical?: boolean
@ -127,7 +128,35 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
{...props}
>
{children}
{dirty && (
{onClose && !vertical && (
<button
aria-label="Close tab"
className={cn(
'grid size-4 shrink-0 place-items-center self-center rounded-sm text-(--ui-text-tertiary) transition-opacity',
// Always reserve the slot; visible on hover. The dirty dot
// (below) yields to the X on hover so the two never overlap.
// Opacity transitions keep the layout stable (no width shift
// when the X appears).
'mr-1.5 opacity-0 group-hover/tab:opacity-100',
'hover:bg-(--ui-control-hover-background) hover:text-foreground'
)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
onClose()
}}
onPointerDown={event => {
// Claim the press so the tab's drag/activate pointerdown handler
// can't fire — the X is a leaf action, never a drag start.
event.preventDefault()
event.stopPropagation()
}}
type="button"
>
<Codicon name="close" size="0.625rem" />
</button>
)}
{dirty && !(onClose && !vertical) && (
<span
aria-hidden
className={cn(