diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index 36e06cedf..f83a5f125 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -89,6 +89,53 @@ async function checkSidebarModeSwitch(page: Page, name: string): Promise { await page.locator(".sidebar-fold-button").evaluate((button) => (button as HTMLButtonElement).blur()); } +async function checkSidebarPointerModeSwitch(page: Page, pinning: boolean): Promise { + const button = page.getByRole("button", { name: pinning ? "Pin sidebar" : "Unpin sidebar", exact: true }); + await button.hover(); + await page.mouse.down(); + await page.waitForTimeout(100); + assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 1 : 0, + "pointer-down must not open a preview before the mode changes on click"); + const sampling = button.evaluate(async (button) => { + const frame = button.closest(".sidebar-frame")!; + const panel = frame.querySelector(".sidebar-frame__bevel")!; + const main = document.querySelector(".app-shell__main")!; + const content = frame.querySelector(".sidebar-frame-content")!; + const sample = () => ({ + frameWidth: frame.getBoundingClientRect().width, + panelWidth: panel.getBoundingClientRect().width, + mainWidth: main.getBoundingClientRect().width, + expectedMainWidth: Math.min(parseFloat(getComputedStyle(main).maxWidth), innerWidth - panel.getBoundingClientRect().right), + contentWidth: content.getBoundingClientRect().width, + buttonLeft: button.getBoundingClientRect().left, + buttonBottom: button.getBoundingClientRect().bottom, + }); + const samples = [sample()]; + const start = performance.now(); + do { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + samples.push(sample()); + } while (performance.now() - start < 450); + return samples; + }); + await page.mouse.up(); + const samples = await sampling; + for (const sample of samples) { + assert(Math.abs(sample.frameWidth - sample.panelWidth) <= 1, + `sidebar and main must move together throughout the click: ${JSON.stringify(sample)}`); + assert(Math.abs(sample.mainWidth - sample.expectedMainWidth) <= 1); + assertEquals(sample.contentWidth, samples[0].contentWidth); + assertEquals(sample.buttonLeft, samples[0].buttonLeft); + assertEquals(sample.buttonBottom, samples[0].buttonBottom); + } + const first = samples[0].frameWidth; + const last = samples.at(-1)!.frameWidth; + assert(pinning ? last > first : last < first); + assert(samples.some((sample) => sample.frameWidth > Math.min(first, last) && sample.frameWidth < Math.max(first, last)), + "mode switch must animate through intermediate widths"); + assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 0 : 1); +} + async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise { const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-hover-region"); const result = await toggle.evaluate(async (button, { mobile, opening }) => { @@ -486,6 +533,8 @@ async function checkConsoleHistory(viewportHeight: number): Promise { await footer.hover({ position: { x: 4, y: 4 } }); await page.locator(".sidebar-frame.folded").waitFor(); await settleSidebar(); + await checkSidebarPointerModeSwitch(page, true); + await checkSidebarPointerModeSwitch(page, false); // Use real mouse and keyboard events as well as deterministic motion sampling. await sidebar.hover({ position: { x: 10, y: 40 } }); await page.locator(".sidebar-frame:not(.folded)").waitFor(); diff --git a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte index a6682187e..cc935c951 100644 --- a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte +++ b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte @@ -61,10 +61,14 @@ function toggleMode(event: MouseEvent) { const next = mode === 'pinned' ? 'hover' : 'pinned'; // A pointer click should not leave keyboard focus holding the preview open. - if (event.detail > 0) (event.currentTarget as HTMLButtonElement).blur(); + if (event.detail > 0 && document.activeElement instanceof HTMLElement && frame.contains(document.activeElement)) { + document.activeElement.blur(); + } + clearCloseTimer(); onModeChange(next); - if (!mobile) onOpenChange(next === 'hover'); - scheduleClose(); + // Footer clicks close immediately with the layout change; only a real + // hover or keyboard focus keeps an existing preview open. + if (!mobile) onOpenChange(next === 'hover' && (pointerInside || focusInside)); } $effect(() => { @@ -105,6 +109,11 @@ aria-label={mode === 'pinned' ? 'Unpin sidebar' : 'Pin sidebar'} aria-pressed={mode === 'pinned'} title={mode === 'pinned' ? 'Always visible — switch to show on hover' : 'Show on hover — pin sidebar'} + onpointerdown={(event) => { + // Do not open a focus preview before a pointer click changes mode. + // Keyboard focus still opens and holds the preview as usual. + if (event.button === 0) event.preventDefault(); + }} onclick={toggleMode} > diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar.css b/web/workspace/src/lib/workspace/sidebar/sidebar.css index dc2eaf3c9..1acca85b8 100644 --- a/web/workspace/src/lib/workspace/sidebar/sidebar.css +++ b/web/workspace/src/lib/workspace/sidebar/sidebar.css @@ -24,6 +24,7 @@ .sidebar-frame .bevel.sidebar-frame__bevel { /* Keep the open panel's width identical in pinned and overlay modes. */ width: var(--sidebar-width); + transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing); background: var(--bg); min-width: 0; min-height: 0; @@ -84,7 +85,6 @@ width: var(--sidebar-width); overflow: hidden; background: var(--bg); - transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing); } .sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel { width: var(--sidebar-rail-width); diff --git a/web/workspace/src/routes/sidebar-fold.browser.spec.ts b/web/workspace/src/routes/sidebar-fold.browser.spec.ts index 185961462..d71217811 100644 --- a/web/workspace/src/routes/sidebar-fold.browser.spec.ts +++ b/web/workspace/src/routes/sidebar-fold.browser.spec.ts @@ -43,6 +43,38 @@ test("unpinning changes mode, and pointer departure closes the preview", async ( expect(folded()).toBe(true); }); +test("footer unpin closes in the same update as the mode change, without the hover delay", async () => { + render(Layout, { props }); + const button = screen.getByRole("button", { name: "Unpin sidebar" }); + button.focus(); + await fireEvent.click(button, { detail: 1 }); + expect(localStorage.getItem(storageKey)).toBe("hover"); + expect(folded()).toBe(true); + await delay(); + expect(folded()).toBe(true); + await fireEvent.click(button, { detail: 1 }); + expect(localStorage.getItem(storageKey)).toBe("pinned"); + expect(folded()).toBe(false); +}); + +test("footer pointer clicks release previous sidebar link focus before unpinning", async () => { + render(Layout, { props }); + frame().querySelector("a")!.focus(); + await tick(); + await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 }); + expect(folded()).toBe(true); +}); + +test("keyboard mode switching preserves the focused preview", async () => { + render(Layout, { props }); + const button = screen.getByRole("button", { name: "Unpin sidebar" }); + button.focus(); + await fireEvent.click(button, { detail: 0 }); + expect(localStorage.getItem(storageKey)).toBe("hover"); + await delay(); + expect(folded()).toBe(false); +}); + test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => { localStorage.setItem(storageKey, mode); render(Layout, { props });