fix: bottom-strip drag no longer leaks the panel width into the host layout
The bottom drag applied the panel's persisted width preference to --dsh-sidebar-width regardless of panelOpen (only the idle push effect gated it through layoutPushSize). With the right panel closed, the first drag frame jumped the variable 0 → ~400px: #root lost 400px instantly, the host frame's viewport dropped across its 1024px auto-collapse breakpoint, and the NATIVE left sidebar snapped to its 56px rail — then snapped back on release. The bottom strip is the only drag reachable with the panel closed, which is why only it showed. applyDrag now rides the same gate as the push effect for the PUSHED width; the panel-side DOM writes (panelRef/bottomRef/lastDragSize) keep the raw width — the bottom panel's right edge derives from it. The e2e regression pins the pose: panel closed + bottom open, drag the strip, and every sampled frame must keep the pushed width at 0, the host grid template, the left column width and #root's right edge unchanged. Verified red on the old code (leaked 504px).
This commit is contained in:
@@ -993,7 +993,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// drag write-only (no React re-render mid-drag).
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
|
||||
const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
|
||||
writeGeometry(width, bottomPush)
|
||||
// The pushed width must ride the same gate as the committed push effect
|
||||
// (layoutPushSize): a collapsed right panel pushes 0. The bottom strip is
|
||||
// the only drag reachable with the panel closed — writing the panel's
|
||||
// persisted width preference here squeezed #root mid-drag, dropped the
|
||||
// host viewport across its 1024px auto-collapse breakpoint, and the
|
||||
// native left sidebar snapped to its 56px rail (and back on release).
|
||||
const pushWidth = !narrow && state?.panelOpen === true ? Math.min(width, window.innerWidth) : 0
|
||||
writeGeometry(pushWidth, bottomPush)
|
||||
}
|
||||
|
||||
// Drags write at most once per frame: pointer events fire several times
|
||||
|
||||
@@ -642,3 +642,140 @@ test('the bottom-push anchor resolves through the composite selectors (at least
|
||||
expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
/* ── bottom-strip drag with the right panel CLOSED must not touch the host
|
||||
layout — the regression test for the width-gate fix ───────────────── */
|
||||
|
||||
test('dragging the bottom strip while the right panel is closed keeps the host layout still', async ({ page }) => {
|
||||
// The bottom drag used to write the panel's persisted width preference into
|
||||
// `--dsh-sidebar-width` regardless of `panelOpen` (only the idle push
|
||||
// effect gated it): with the panel closed the variable jumped 0 → ~400px on the
|
||||
// first drag frame, #root lost 400px instantly, the host frame's viewport
|
||||
// dropped across its 1024px auto-collapse breakpoint, and the NATIVE left
|
||||
// sidebar snapped to its 56px rail — then snapped back on release. The
|
||||
// panel-side DOM writes keep the raw width (the bottom panel's `right`
|
||||
// derives from it); only the PUSHED width must ride the panelOpen gate.
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
await dismissOnboarding(page)
|
||||
|
||||
// The right panel stays CLOSED (openByDefault off): the width variable
|
||||
// must read as the collapsed push before anything happens.
|
||||
const readPushWidth = (): Promise<number> => page.evaluate(() => {
|
||||
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
})
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const height = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
|
||||
return height
|
||||
}, { timeout: 90_000 })
|
||||
.toBe(0)
|
||||
|
||||
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
|
||||
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
|
||||
await bottomExpand.click()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
|
||||
return value
|
||||
}, { timeout: 90_000 })
|
||||
.toBeGreaterThan(0)
|
||||
expect(await readPushWidth(), 'the closed right panel must push 0 width').toBe(0)
|
||||
|
||||
// Per-frame sampler on the HOST side: the pushed width variable, the host
|
||||
// frame's inline grid template (its first track is the native left
|
||||
// sidebar's width), the rendered left-column width (the grid transition
|
||||
// animates mid-states, so this catches even a transient snap), and #root's
|
||||
// right edge (where the width push lands).
|
||||
await page.evaluate(() => {
|
||||
const samples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> = []
|
||||
const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement ?? null
|
||||
const frame = center?.parentElement ?? null
|
||||
const leftCol = frame?.firstElementChild ?? null
|
||||
const root = document.querySelector('#root') as HTMLElement
|
||||
const loop = (): void => {
|
||||
const varW = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
samples.push({
|
||||
t: performance.now(),
|
||||
varW: Number.isNaN(varW) ? 0 : varW,
|
||||
gridCols: frame instanceof HTMLElement ? frame.style.gridTemplateColumns : '',
|
||||
leftColW: leftCol?.getBoundingClientRect().width ?? -1,
|
||||
rootRight: root.getBoundingClientRect().right,
|
||||
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
|
||||
})
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
requestAnimationFrame(loop)
|
||||
;(window as unknown as { __bottomDragSamples: typeof samples }).__bottomDragSamples = samples
|
||||
})
|
||||
|
||||
// The bottom strip is the panel's top-edge row-resize hit strip. The panel
|
||||
// SLIDES DOWN into place on expand: a box read mid-animation sits above the
|
||||
// settled geometry and the pointerdown lands off-strip (the drag never
|
||||
// starts). Wait until the strip's center sits at innerHeight - the pushed
|
||||
// height (the panel's final top edge).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await page.evaluate(() => {
|
||||
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
|
||||
if (bottom === null) return null
|
||||
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
|
||||
if (strip === undefined) return null
|
||||
const r = strip.getBoundingClientRect()
|
||||
const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0
|
||||
return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar))
|
||||
})
|
||||
return probe === null ? Number.NaN : probe
|
||||
}, { timeout: 30_000 })
|
||||
.toBeLessThanOrEqual(8)
|
||||
const stripBox = await page.evaluate(() => {
|
||||
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
|
||||
if (bottom === null) return null
|
||||
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
|
||||
if (strip === undefined) return null
|
||||
const r = strip.getBoundingClientRect()
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
|
||||
})
|
||||
expect(stripBox, 'the bottom drag strip must be present (cursor: row-resize)').not.toBeNull()
|
||||
|
||||
// Drag the strip UP (grow the bottom panel) in steps, like a real resize.
|
||||
await page.mouse.move(stripBox!.x, stripBox!.y)
|
||||
await page.mouse.down()
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await page.mouse.move(stripBox!.x, stripBox!.y - i * 8, { steps: 2 })
|
||||
await page.waitForTimeout(40)
|
||||
}
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const samples = await page.evaluate(
|
||||
() => (window as unknown as { __bottomDragSamples: Array<{ t: number; varW: number; gridCols: string; leftColW: number; rootRight: number; dragging: boolean }> }).__bottomDragSamples,
|
||||
)
|
||||
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
|
||||
const dragging = samples.filter(s => s.dragging)
|
||||
expect(dragging.length, 'the dragging attribute must appear during the drag').toBeGreaterThan(5)
|
||||
|
||||
// The drag must actually have resized the bottom panel (sanity: the height
|
||||
// push committed to a bigger value than where it started).
|
||||
const heightAfter = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
|
||||
expect(heightAfter).toBeGreaterThan(0)
|
||||
|
||||
// Contract: the pushed width stays 0 for EVERY frame — mid-drag AND after
|
||||
// release. The old code let the panel's persisted preference (~400px)
|
||||
// through on the first drag frame and the release commit.
|
||||
for (const s of samples) {
|
||||
expect(s.varW, `pushed width leaked during the bottom drag at t=${Math.round(s.t)} (got ${s.varW}px)`).toBe(0)
|
||||
}
|
||||
// Contract: the host layout never moves — same inline grid template, same
|
||||
// rendered left-column width (the native sidebar would snap to its 56px
|
||||
// rail when #root loses 400px on a 1280px viewport), same #root right edge.
|
||||
const first = samples[0]!
|
||||
for (const s of samples) {
|
||||
expect(s.gridCols, `host grid template changed at t=${Math.round(s.t)}: ${first.gridCols} -> ${s.gridCols}`).toBe(first.gridCols)
|
||||
expect(Math.abs(s.leftColW - first.leftColW), `native left column width changed at t=${Math.round(s.t)}: ${first.leftColW} -> ${s.leftColW}`).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(s.rootRight - first.rootRight), `#root right edge changed at t=${Math.round(s.t)}: ${first.rootRight} -> ${s.rootRight}`).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user