feat: animate main layout and separate sidebar controls

This commit is contained in:
2026-10-01 13:12:13 +09:00
parent 101b021c5f
commit 9579f3e245
7 changed files with 135 additions and 39 deletions
@@ -44,19 +44,26 @@ async function historyRequestCount(baseUrl: string): Promise<number> {
async function checkSidebarModeSwitch(page: Page, name: string): Promise<void> {
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<HTMLElement>(".sidebar-frame")!;
await Promise.all(frame.getAnimations({ subtree: true }).map((animation) => animation.finished));
const panel = frame.querySelector<HTMLElement>(".sidebar-frame__bevel")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
const main = document.querySelector<HTMLElement>(".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<void> {
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<void> {
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<HTMLElement>(".sidebar-frame")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
@@ -440,6 +455,20 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
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();
@@ -1,6 +1,7 @@
<script lang="ts">
import { onDestroy, type Snippet } from 'svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
import './sidebar.css';
type Props = {
@@ -81,16 +82,20 @@
class:hover-mode={mode === 'hover'}
aria-label="Sidebar"
bind:this={frame}
onpointerenter={enter}
onpointerleave={leave}
onpointercancel={leave}
onfocusin={focusIn}
onfocusout={focusOut}
>
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false}>
<div class="sidebar-frame__surface">
<div class="sidebar-frame-content" inert={!open} aria-hidden={!open}>
{@render children()}
<div
class="sidebar-hover-region"
onpointerenter={enter}
onpointerleave={leave}
onpointercancel={leave}
>
<div class="sidebar-frame-content" inert={!open} aria-hidden={!open}>
{@render children()}
</div>
</div>
<div class="sidebar-control-row">
@@ -102,10 +107,7 @@
title={mode === 'pinned' ? 'Always visible — switch to show on hover' : 'Show on hover — pin sidebar'}
onclick={toggleMode}
>
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M9 3H15L14 9L18 13V15H6V13L10 9L9 3ZM12 15V21" />
{#if mode === 'hover'}<path d="M3 3L21 21" />{/if}
</svg>
<SidebarToggleIcon open={mode === 'pinned'} />
</button>
</div>
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
let { open }: { open: boolean } = $props();
</script>
<!-- Lucide panel-right-close / panel-right-open; ISC license: /licenses/lucide.txt -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-icon={open ? 'panel-right-close' : 'panel-right-open'}
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d={open ? 'm8 9 3 3-3 3' : 'm10 15-3-3 3-3'} />
</svg>
@@ -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;
+2 -9
View File
@@ -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}
>
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
{#if !sidebarOpen}
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
{:else}
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
{/if}
</svg>
<SidebarToggleIcon open={sidebarOpen} />
</button>
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
@@ -16,8 +16,9 @@ const props = {
};
const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
const folded = () => frame().classList.contains("folded");
const enter = () => fireEvent.pointerEnter(frame(), { pointerType: "mouse" });
const leave = () => fireEvent.pointerLeave(frame(), { pointerType: "mouse" });
const hoverRegion = () => frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
const delay = () => vi.advanceTimersByTimeAsync(200);
beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); });
@@ -87,6 +88,27 @@ test("hover keeps the same content node, delays closing, and cancels closing on
expect(localStorage.getItem(storageKey)).toBe("hover");
});
test("footer hover does not open or hold the preview, but its button still changes mode", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!;
const button = screen.getByRole("button", { name: "Pin sidebar" });
expect(button.querySelector('[data-icon="panel-right-open"] rect')).not.toBeNull();
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
await fireEvent.pointerEnter(button, { pointerType: "mouse" });
await delay();
expect(folded()).toBe(true);
await enter();
expect(folded()).toBe(false);
await leave();
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
await delay();
expect(folded()).toBe(true);
await fireEvent.click(button, { detail: 1 });
expect(folded()).toBe(false);
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not.toBeNull();
});
test("keyboard focus opens the preview and holds it until focus leaves", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
+19
View File
@@ -0,0 +1,19 @@
Lucide panel-right-close and panel-right-open
https://lucide.dev/icons/panel-right-close
https://lucide.dev/icons/panel-right-open
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.