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 37311df44..462a75746 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -44,19 +44,26 @@ async function historyRequestCount(baseUrl: string): Promise { async function checkSidebarModeSwitch(page: Page, name: string): Promise { const button = page.getByRole("button", { name, exact: true }); await button.hover(); + await button.focus(); const samples = await button.evaluate(async (button) => { const frame = button.closest(".sidebar-frame")!; await Promise.all(frame.getAnimations({ subtree: true }).map((animation) => animation.finished)); const panel = frame.querySelector(".sidebar-frame__bevel")!; const content = frame.querySelector(".sidebar-frame-content")!; + const main = document.querySelector(".app-shell__main")!; const sample = () => ({ - panel: panel.getBoundingClientRect().toJSON(), - content: content.getBoundingClientRect().toJSON(), - button: button.getBoundingClientRect().toJSON(), - folded: frame.classList.contains("folded"), - motion: frame.getAnimations({ subtree: true }).filter((animation) => - animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty) - ).length, + mainLeft: main.getBoundingClientRect().left, + mainWidth: main.getBoundingClientRect().width, + stationary: { + panel: panel.getBoundingClientRect().toJSON(), + content: content.getBoundingClientRect().toJSON(), + button: button.getBoundingClientRect().toJSON(), + folded: frame.classList.contains("folded"), + unclipped: panel.contains(document.elementFromPoint(panel.getBoundingClientRect().right - 8, 40)), + motion: panel.getAnimations({ subtree: true }).filter((animation) => + animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty) + ).length, + }, }); const samples = [sample()]; (button as HTMLButtonElement).click(); @@ -68,14 +75,22 @@ async function checkSidebarModeSwitch(page: Page, name: string): Promise { return samples; }); for (const sample of samples) { - assertEquals(sample, samples[0], `${name} must keep the open panel stationary without slide animation`); - assertEquals(sample.folded, false); - assertEquals(sample.motion, 0); + assertEquals(sample.stationary, samples[0].stationary, `${name} must keep the open panel stationary without slide animation`); + assertEquals(sample.stationary.folded, false); + assertEquals(sample.stationary.motion, 0); + assertEquals(sample.stationary.unclipped, true); } + const first = samples[0]; + const last = samples.at(-1)!; + assert(samples.some((sample) => sample.mainLeft > Math.min(first.mainLeft, last.mainLeft) && + sample.mainLeft < Math.max(first.mainLeft, last.mainLeft)), "main position must animate through intermediate coordinates"); + assert(samples.some((sample) => sample.mainWidth > Math.min(first.mainWidth, last.mainWidth) && + sample.mainWidth < Math.max(first.mainWidth, last.mainWidth)), "main width must animate through intermediate sizes"); + await page.locator(".sidebar-fold-button").evaluate((button) => (button as HTMLButtonElement).blur()); } async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise { - const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-frame"); + const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-hover-region"); const result = await toggle.evaluate(async (button, { mobile, opening }) => { const frame = document.querySelector(".sidebar-frame")!; const content = frame.querySelector(".sidebar-frame-content")!; @@ -440,6 +455,20 @@ async function checkConsoleHistory(viewportHeight: number): Promise { const mainBeforeHover = await page.locator(".app-shell__main").boundingBox(); await checkSidebarSlide(page, false, true); await checkSidebarSlide(page, false, false); + // The entire footer, including its button, is outside hover activation. + const footer = sidebar.locator(".sidebar-control-row"); + await footer.hover({ position: { x: 4, y: 4 } }); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 1); + await page.getByRole("button", { name: "Pin sidebar", exact: true }).hover(); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 1); + await sidebar.locator(".sidebar-hover-region").hover({ position: { x: 10, y: 24 } }); + await page.locator(".sidebar-frame:not(.folded)").waitFor(); + await settleSidebar(); + await footer.hover({ position: { x: 4, y: 4 } }); + await page.locator(".sidebar-frame.folded").waitFor(); + await settleSidebar(); // 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 364bfceed..a6682187e 100644 --- a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte +++ b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte @@ -1,6 +1,7 @@ + + + diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar.css b/web/workspace/src/lib/workspace/sidebar/sidebar.css index e222b9bcd..52536ab09 100644 --- a/web/workspace/src/lib/workspace/sidebar/sidebar.css +++ b/web/workspace/src/lib/workspace/sidebar/sidebar.css @@ -13,14 +13,18 @@ grid-column: 1; grid-row: 1 / 3; width: var(--sidebar-width); - /* Reserving layout space is a mode switch, not a panel open/close. */ + /* Animate reserved main-content space, independently of the panel. */ + transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing); + position: relative; + z-index: 40; min-width: 0; min-height: 0; - overflow: hidden; + overflow: visible; } .sidebar-frame .bevel.sidebar-frame__bevel { /* Keep the open panel's width identical in pinned and overlay modes. */ width: var(--sidebar-width); + background: var(--bg); min-width: 0; min-height: 0; } @@ -31,7 +35,7 @@ height: 100%; min-width: 0; min-height: 0; - padding-block: var(--space-4); + padding-top: var(--space-4); } @media (prefers-color-scheme: dark) { .sidebar-frame { @@ -43,8 +47,14 @@ width: var(--sidebar-rail-width); overflow: hidden; } + .sidebar-hover-region { + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; + } .sidebar-frame-content { - flex: 1 1 auto; + height: 100%; width: calc(var(--sidebar-width) - 1px); transform: translateX(0); visibility: visible; @@ -134,12 +144,11 @@ } .sidebar-control-row { display: flex; + flex: 0 0 auto; justify-content: flex-start; gap: var(--space-1); - } - .sidebar-control-row { - margin-top: auto; - padding: var(--space-4) var(--space-2) 0; + border-top: 1px solid var(--line); + padding: var(--space-4) var(--space-2); } .workspace-label { flex: 1 1 auto; diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index d9e088ee3..5f09acf68 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -11,6 +11,7 @@ } from '$lib/workspace/header/context'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte'; import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte'; + import SidebarToggleIcon from '$lib/workspace/sidebar/SidebarToggleIcon.svelte'; import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context'; import { createOverrideStack } from '$lib/workspace/sidebar/override-stack'; import '../app.css'; @@ -102,15 +103,7 @@ title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'} onclick={toggleSidebar} > - +