fix(desktop): keep the free titlebar band draggable when tabs drop below the controls
The salvaged fix reserved a fixed 48px drag handle whenever a top-edge tab
strip is visible. That is right when the tabs share the titlebar band (every
tab is `no-drag` and the list scrolls, so a crowded strip covers all the
draggable pixels — #112964), but it also shrank the handle in the layout
6cc51b407c introduced for narrow zones: tabs drop to a second row below the
window controls and the whole titlebar row above them is free. There the
flexible handle must keep spanning the row, or a narrow window loses most of
its drag area.
Scope the fixed width to `headerVisible && tabsInTitlebar`; otherwise keep
the flex-1 handle exactly as origin/main had it.
Tests: replace the contributor's single assertion with two invariants in
tree-group.test.tsx — a crowded titlebar strip renders a fixed-width handle
outside the `role="tablist"` scroller (red on origin/main), and the
below-controls layout keeps a flexible, width-less handle (red on the PR as
submitted). The `TITLEBAR_DRAG_HANDLE_WIDTH === 48` pin in titlebar.test.ts
is dropped: it is a change-detector on a constant, not a behaviour.
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
MACOS_TAHOE_DARWIN_MAJOR,
|
||||
TITLEBAR_CONTROL_OFFSET_X,
|
||||
TITLEBAR_CONTROL_SIZE,
|
||||
TITLEBAR_DRAG_HANDLE_WIDTH,
|
||||
TITLEBAR_EDGE_INSET,
|
||||
TITLEBAR_FALLBACK_WINDOW_BUTTON_X,
|
||||
TITLEBAR_ICON_SIZE,
|
||||
@@ -23,10 +22,6 @@ describe('titlebar sizing', () => {
|
||||
expect(titlebarIconSizeCss()).toBe('13.9px')
|
||||
})
|
||||
|
||||
it('reserves a fixed native drag target beside titlebar tabs', () => {
|
||||
expect(TITLEBAR_DRAG_HANDLE_WIDTH).toBe(48)
|
||||
})
|
||||
|
||||
it('reserves width from abutting hit targets only', () => {
|
||||
expect(titlebarToolsWidthCss(4)).toBe('calc(4 * var(--titlebar-control-size))')
|
||||
})
|
||||
|
||||
@@ -59,53 +59,81 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('TreeGroup', () => {
|
||||
it('keeps a fixed native drag handle beside crowded top-edge tabs', () => {
|
||||
// Titlebar geometry (#112964): tabs sharing the native titlebar band are all
|
||||
// `no-drag`, so once the list overflows they can cover every draggable pixel.
|
||||
// A window must keep a drag target that no tab can occupy — a fixed-width
|
||||
// handle OUTSIDE the scrolling tablist. When the tabs instead drop below the
|
||||
// window controls, the free band above them stays the (flexible) handle.
|
||||
describe('top-edge window drag handle', () => {
|
||||
const paneIds = ['terminal', 'terminal-2', 'terminal-3', 'terminal-4', 'terminal-5']
|
||||
|
||||
const disposers = paneIds.map(id =>
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main' },
|
||||
id,
|
||||
render: () => <div>{id}</div>,
|
||||
title: id
|
||||
function mountCrowdedStrip(titlebarWidth: number) {
|
||||
const disposers = paneIds.map(id =>
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main' },
|
||||
id,
|
||||
render: () => <div>{id}</div>,
|
||||
title: id
|
||||
})
|
||||
)
|
||||
|
||||
disposePane = () => disposers.forEach(dispose => dispose())
|
||||
vi.stubGlobal('CSS', { escape: (value: string) => value })
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
// jsdom has no layout: give usePanelTitlebar real chrome rects so it
|
||||
// picks the tabs-in-titlebar layout (wide) or below-controls (narrow).
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.matches('[data-titlebar-cluster="left"]')) {
|
||||
return { left: 0, right: 100 } as DOMRect
|
||||
}
|
||||
|
||||
if (this.matches('[data-titlebar-cluster="right"]')) {
|
||||
return { left: titlebarWidth - 100, right: titlebarWidth } as DOMRect
|
||||
}
|
||||
|
||||
return { left: 0, right: titlebarWidth, width: titlebarWidth } as DOMRect
|
||||
})
|
||||
)
|
||||
const leftControls = globalThis.document.createElement('div')
|
||||
leftControls.dataset.titlebarCluster = 'left'
|
||||
const rightControls = globalThis.document.createElement('div')
|
||||
rightControls.dataset.titlebarCluster = 'right'
|
||||
globalThis.document.body.append(leftControls, rightControls)
|
||||
|
||||
disposePane = () => disposers.forEach(dispose => dispose())
|
||||
vi.stubGlobal('CSS', { escape: (value: string) => value })
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
|
||||
if (this.matches('[data-titlebar-cluster="left"]')) {
|
||||
return { left: 0, right: 100 } as DOMRect
|
||||
}
|
||||
render(<TreeGroup leftEdge node={{ ...terminalGroup(false), panes: paneIds }} rightEdge topEdge />)
|
||||
|
||||
if (this.matches('[data-titlebar-cluster="right"]')) {
|
||||
return { left: 700, right: 800 } as DOMRect
|
||||
}
|
||||
const zone = container!.querySelector<HTMLElement>('[data-tree-group]')!
|
||||
expect(zone.querySelectorAll('[data-tree-tab]').length).toBe(paneIds.length)
|
||||
|
||||
return { left: 0, right: 800, width: 800 } as DOMRect
|
||||
return {
|
||||
handles: [...zone.querySelectorAll<HTMLElement>('[data-window-drag-handle]')],
|
||||
strip: zone.querySelector<HTMLElement>('[data-zone-tabstrip]')!
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps a fixed drag handle outside the tablist when tabs share the titlebar', () => {
|
||||
const { handles, strip } = mountCrowdedStrip(800)
|
||||
const fixed = handles.filter(handle => !handle.closest('[role="tablist"]') && handle.style.width !== '')
|
||||
|
||||
expect(fixed.length).toBeGreaterThan(0)
|
||||
expect(fixed[0]!.className).toContain('shrink-0')
|
||||
// The strip itself still spans the band, so the handle is ADDITIONAL to it.
|
||||
expect(strip.className).toContain('flex-1')
|
||||
})
|
||||
const leftControls = globalThis.document.createElement('div')
|
||||
leftControls.dataset.titlebarCluster = 'left'
|
||||
const rightControls = globalThis.document.createElement('div')
|
||||
rightControls.dataset.titlebarCluster = 'right'
|
||||
globalThis.document.body.append(leftControls, rightControls)
|
||||
|
||||
render(<TreeGroup leftEdge node={{ ...terminalGroup(false), panes: paneIds }} rightEdge topEdge />)
|
||||
it('leaves the whole free band draggable when tabs drop below the controls', () => {
|
||||
const { handles, strip } = mountCrowdedStrip(300)
|
||||
|
||||
const handle = container!.querySelector<HTMLElement>('[data-window-drag-handle]')!
|
||||
expect(container!.querySelectorAll('[data-tree-tab]').length).toBe(paneIds.length)
|
||||
expect(handle).toBeTruthy()
|
||||
expect(handle.style.width).not.toBe('')
|
||||
expect(handle.className).toContain('shrink-0')
|
||||
expect(strip.className).toContain('bottom-0')
|
||||
expect(handles.some(handle => handle.className.includes('flex-1') && handle.style.width === '')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a top-edge strip inside its panel and yields native drag while moving a pane', () => {
|
||||
|
||||
@@ -696,17 +696,22 @@ export function TreeGroup({
|
||||
</PaneTabStrip>
|
||||
</ZoneMenu>
|
||||
) : null}
|
||||
{/* Tabs sharing the titlebar band are all `no-drag` and the strip's
|
||||
list scrolls, so a crowded strip can cover every draggable pixel
|
||||
(#112964). Keep one fixed handle OUTSIDE the list. When the tabs
|
||||
drop below the controls the band above them is free — the handle
|
||||
stays flexible and the whole row moves the window. */}
|
||||
{topEdge && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'self-start [-webkit-app-region:drag]',
|
||||
headerVisible ? 'shrink-0' : 'min-w-0 flex-1'
|
||||
headerVisible && tabsInTitlebar ? 'shrink-0' : 'min-w-0 flex-1'
|
||||
)}
|
||||
data-window-drag-handle=""
|
||||
style={{
|
||||
height: TITLEBAR_HEIGHT,
|
||||
width: headerVisible ? TITLEBAR_DRAG_HANDLE_WIDTH : undefined
|
||||
width: headerVisible && tabsInTitlebar ? TITLEBAR_DRAG_HANDLE_WIDTH : undefined
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user