feat: slide sidebar when expanding and folding
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user