feat: slide sidebar when expanding and folding

This commit is contained in:
2026-10-01 11:48:50 +09:00
parent a07d70b107
commit 3ee1698957
5 changed files with 120 additions and 22 deletions
@@ -1,7 +1,7 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium } from "playwright";
import { chromium, type Page } from "playwright";
const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const workspaceRoot = join(repositoryRoot, "web/workspace");
@@ -41,6 +41,54 @@ async function historyRequestCount(baseUrl: string): Promise<number> {
return state.history_requests;
}
async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise<void> {
const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-fold-button");
const result = await toggle.evaluate(async (button, mobile) => {
const frame = document.querySelector<HTMLElement>(".sidebar-frame")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
const moving = mobile ? frame : content;
const sample = () => ({
x: moving.getBoundingClientRect().x,
width: moving.getBoundingClientRect().width,
frameWidth: frame.getBoundingClientRect().width,
mainWidth: main.getBoundingClientRect().width,
mainHeight: main.getBoundingClientRect().height,
});
const before = sample();
(button as HTMLButtonElement).click();
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const animations = frame.getAnimations({ subtree: true });
const slides = animations.filter((animation) => animation instanceof CSSTransition &&
["width", "transform"].includes(animation.transitionProperty));
const durations = slides.map((animation) => animation.effect!.getTiming().duration);
for (const animation of animations) {
animation.pause();
animation.currentTime = 110;
}
const middle = sample();
for (const animation of animations) animation.finish();
await Promise.all(animations.map((animation) => animation.finished));
return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert };
}, mobile);
assert(result.durations.length >= (mobile ? 1 : 2));
assert(result.durations.every((duration) => duration === 220));
const { before, middle, after } = result;
assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x,
`sidebar must slide horizontally through an intermediate position: ${JSON.stringify(result)}`);
assertEquals(before.width, after.width, "sidebar contents must slide without reflowing");
assertEquals(result.contentInert, !opening);
assertEquals(result.mainInert, mobile && opening);
if (mobile) {
assertEquals(before.mainWidth, after.mainWidth);
assertEquals(before.mainHeight, after.mainHeight);
assertEquals(after.frameWidth, before.frameWidth);
} else {
assert(opening ? before.frameWidth < middle.frameWidth && middle.frameWidth < after.frameWidth
: before.frameWidth > middle.frameWidth && middle.frameWidth > after.frameWidth);
}
}
async function checkConsoleHistory(viewportHeight: number): Promise<void> {
const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"],
@@ -336,17 +384,33 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
assertEquals(new Set(rowIds).size, rowIds.length);
await page.getByRole("button", { name: "Overview", exact: true }).click();
assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12);
await page.getByRole("button", { name: "Fold sidebar", exact: true }).click();
await checkSidebarSlide(page, false, false);
await checkSidebarSlide(page, false, true);
await checkSidebarSlide(page, false, false);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true");
await page.reload();
await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
await page.setViewportSize({ width: 600, height: viewportHeight });
await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
await page.locator(".sidebar-frame").evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished));
});
await checkSidebarSlide(page, true, true);
await checkSidebarSlide(page, true, false);
await checkSidebarSlide(page, true, true);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false");
await page.reload();
await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1);
await page.emulateMedia({ reducedMotion: "reduce" });
await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({
animations: element.getAnimations({ subtree: true }).length,
right: element.getBoundingClientRect().right,
visibility: getComputedStyle(element).visibility,
}));
assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" });
assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
assertEquals(errors, []);
await context.close();
} finally {
@@ -18,11 +18,9 @@
<div class="sidebar-frame" class:folded>
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false} fill>
<aside class="sidebar-frame__surface" aria-label="Sidebar">
{#if !folded}
<div class="sidebar-frame-content">
{@render children()}
</div>
{/if}
<div class="sidebar-frame-content" inert={folded} aria-hidden={folded}>
{@render children()}
</div>
<div class="sidebar-control-row">
<button
@@ -4,11 +4,15 @@
.sidebar-frame {
--sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0);
--sidebar-width: clamp(220px, 20vw, 280px);
--sidebar-motion-duration: 220ms;
--sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1);
display: grid;
grid-column: 1;
grid-row: 1 / 3;
width: clamp(220px, 20vw, 280px);
width: var(--sidebar-width);
transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing);
min-width: 0;
min-height: 0;
overflow: hidden;
@@ -34,16 +38,26 @@
}
}
.sidebar-frame.folded {
width: max-content;
width: calc(32px + var(--space-2) + var(--space-2) + 1px);
overflow: hidden;
}
.sidebar-frame-content {
flex: 1 1 auto;
width: calc(var(--sidebar-width) - 1px);
transform: translateX(0);
visibility: visible;
transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing),
visibility 0s;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding-inline: var(--space-3);
}
.sidebar-frame.folded .sidebar-frame-content {
transform: translateX(-100%);
visibility: hidden;
transition-delay: 0s, var(--sidebar-motion-duration);
}
.sidebar-frame-content,
.global-sidebar,
.workspace-sidebar,
@@ -104,12 +118,7 @@
}
.sidebar-control-row {
margin-top: auto;
padding: var(--space-4) var(--space-3) 0;
}
.sidebar-frame.folded .sidebar-control-row {
justify-content: center;
padding-inline: var(--space-2);
padding-top: 0;
padding: var(--space-4) var(--space-2) 0;
}
.workspace-label {
flex: 1 1 auto;
@@ -639,17 +648,39 @@
@media (max-width: 760px) {
.sidebar-frame {
--sidebar-width: 100%;
z-index: 20;
grid-column: 1;
grid-row: 2;
width: 100%;
height: 100%;
background: var(--bg);
transform: translateX(0);
visibility: visible;
transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing),
visibility 0s;
}
.sidebar-frame.folded {
display: none;
width: 100%;
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition-delay: 0s, var(--sidebar-motion-duration);
}
.sidebar-frame .sidebar-frame-content,
.sidebar-frame.folded .sidebar-frame-content {
transform: none;
visibility: inherit;
transition: none;
}
.sidebar-control-row {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar-frame {
--sidebar-motion-duration: 0ms;
}
}
}
+3 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { setContext } from 'svelte';
import { MediaQuery } from 'svelte/reactivity';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import {
@@ -36,6 +37,7 @@
}
let sidebarFolded = $state(loadSidebarFolded());
const mobileLayout = new MediaQuery('(max-width: 760px)');
$effect(() => {
const folded = sidebarFolded;
@@ -102,7 +104,7 @@
</nav>
</header>
</Bevel>
<main class="app-shell__main">
<main class="app-shell__main" inert={mobileLayout.current && !sidebarFolded}>
{@render children()}
</main>
</div>
@@ -230,9 +232,5 @@
overflow-y: auto;
padding: var(--space-4);
}
.app-shell.sidebar-open .app-shell__main {
display: none;
}
}
</style>
@@ -26,10 +26,17 @@ afterEach(() => {
test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => {
const view = render(Layout, { props });
expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true");
const content = view.container.querySelector<HTMLElement>(".sidebar-frame-content")!;
expect(content.inert).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
expect(view.container.querySelector(".sidebar-frame-content")).toBe(content);
expect(content.inert).toBe(true);
expect(content.getAttribute("aria-hidden")).toBe("true");
expect(localStorage.getItem(storageKey)).toBe("true");
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
expect(content.inert).toBe(false);
expect(content.getAttribute("aria-hidden")).toBe("false");
expect(localStorage.getItem(storageKey)).toBe("false");
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));