feat: add pinned and hover sidebar modes

This commit is contained in:
2026-10-01 12:36:39 +09:00
parent 3ee1698957
commit 5c98dbb208
7 changed files with 384 additions and 126 deletions
@@ -42,8 +42,8 @@ async function historyRequestCount(baseUrl: string): Promise<number> {
}
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 toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-frame");
const result = await toggle.evaluate(async (button, { mobile, opening }) => {
const frame = document.querySelector<HTMLElement>(".sidebar-frame")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
@@ -52,11 +52,17 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean):
x: moving.getBoundingClientRect().x,
width: moving.getBoundingClientRect().width,
frameWidth: frame.getBoundingClientRect().width,
panelWidth: frame.querySelector(".sidebar-frame__bevel")!.getBoundingClientRect().width,
mainWidth: main.getBoundingClientRect().width,
mainHeight: main.getBoundingClientRect().height,
});
const before = sample();
(button as HTMLButtonElement).click();
if (mobile) {
(button as HTMLButtonElement).click();
} else {
button.dispatchEvent(new PointerEvent(opening ? "pointerenter" : "pointerleave", { pointerType: "mouse" }));
if (!opening) await new Promise((resolve) => setTimeout(resolve, 190));
}
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const animations = frame.getAnimations({ subtree: true });
const slides = animations.filter((animation) => animation instanceof CSSTransition &&
@@ -70,8 +76,8 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean):
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));
}, { mobile, opening });
assert(result.durations.length >= (mobile ? 1 : 2), `missing sidebar motion: ${JSON.stringify({ mobile, opening, result })}`);
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,
@@ -79,13 +85,13 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean):
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);
assertEquals(before.mainWidth, after.mainWidth, "hover/touch previews must not resize the main content");
assertEquals(before.mainHeight, after.mainHeight);
assertEquals(after.frameWidth, before.frameWidth, "hover mode keeps a fixed rail width");
if (!mobile) {
assert(opening ? before.panelWidth < middle.panelWidth && middle.panelWidth < after.panelWidth
: before.panelWidth > middle.panelWidth && middle.panelWidth > after.panelWidth,
"the overlay panel must expand past the rail and contract again");
}
}
@@ -384,24 +390,55 @@ 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 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.locator(".sidebar-frame").evaluate(async (element) => {
const sidebar = page.locator(".sidebar-frame");
const settleSidebar = () => sidebar.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished));
});
await page.getByRole("button", { name: "Unpin sidebar", exact: true }).click();
await page.mouse.move(1000, 30);
await page.locator(".sidebar-frame.folded").waitFor();
await settleSidebar();
const mainBeforeHover = await page.locator(".app-shell__main").boundingBox();
await checkSidebarSlide(page, false, true);
await checkSidebarSlide(page, false, 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();
await settleSidebar();
assertEquals(await page.locator(".app-shell__main").boundingBox(), mainBeforeHover);
const sidebarLink = sidebar.locator(".sidebar-frame-content a").first();
await sidebarLink.focus();
await page.mouse.move(1000, 30);
await page.waitForTimeout(250);
assertEquals(await page.locator(".sidebar-frame.folded").count(), 0, "keyboard focus must hold the preview open");
await page.getByRole("link", { name: "Open Account", exact: true }).focus();
await page.locator(".sidebar-frame.folded").waitFor();
await settleSidebar();
await sidebar.hover({ position: { x: 10, y: 40 } });
await page.getByRole("button", { name: "Pin sidebar", exact: true }).click();
await page.mouse.move(1000, 30);
await page.waitForTimeout(250);
assertEquals(await page.locator(".sidebar-frame.folded").count(), 0);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
await page.getByRole("button", { name: "Unpin sidebar", exact: true }).click();
// Hold a preview open across reload; only the mode should be restored.
await sidebarLink.focus();
await page.mouse.move(1000, 30);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
await page.reload();
await page.getByRole("button", { name: "Pin sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
await page.setViewportSize({ width: 600, height: viewportHeight });
await settleSidebar();
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");
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
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.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
await settleSidebar();
await page.emulateMedia({ reducedMotion: "reduce" });
await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({
@@ -411,6 +448,31 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
}));
assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" });
assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
if (viewportHeight === 600) {
const touchContext = await browser.newContext({
viewport: { width: 600, height: 600 }, hasTouch: true, isMobile: true,
storageState: await context.storageState(),
});
const touchPage = await touchContext.newPage();
await touchPage.goto(page.url());
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
await touchPage.getByRole("button", { name: "Pin sidebar", exact: true }).tap();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap();
await touchPage.reload();
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
// A wide touchscreen still has a temporary-open control in hover mode.
await touchPage.setViewportSize({ width: 1024, height: 600 });
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
await touchPage.locator(".sidebar-frame.folded").waitFor();
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
await touchPage.locator(".sidebar-frame:not(.folded)").waitFor();
await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap();
await touchPage.locator(".sidebar-frame.folded").waitFor();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
await touchContext.close();
}
assertEquals(errors, []);
await context.close();
} finally {
@@ -1325,12 +1325,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
);
assert(
sidebarFrame.includes("folded = $bindable(false)") &&
sidebarFrame.includes("mode: 'pinned' | 'hover'") &&
sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") &&
sidebarFrame.includes("Unfold sidebar") &&
rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") &&
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
sidebarFrame.includes("Pin sidebar") &&
sidebarFrame.includes("Unpin sidebar") &&
rootLayout.includes("let sidebarMode = $state(loadSidebarMode())") &&
rootLayout.includes("mode={sidebarMode}") &&
!workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") &&
!sidebar.includes("folded?: boolean") &&
@@ -1,24 +1,95 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { onDestroy, type Snippet } from 'svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import './sidebar.css';
type Props = {
children: Snippet<[]>;
folded?: boolean;
mode: 'pinned' | 'hover';
open: boolean;
mobile: boolean;
onModeChange: (mode: 'pinned' | 'hover') => void;
onOpenChange: (open: boolean) => void;
};
let { children, folded = $bindable(false) }: Props = $props();
let { children, mode, open, mobile, onModeChange, onOpenChange }: Props = $props();
let frame: HTMLElement;
let pointerInside = false;
let focusInside = false;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function toggleFold() {
folded = !folded;
function clearCloseTimer() {
clearTimeout(closeTimer);
closeTimer = undefined;
}
function scheduleClose() {
clearCloseTimer();
if (mobile || pointerInside || focusInside) return;
closeTimer = setTimeout(() => {
if (!mobile && mode === 'hover' && !pointerInside && !focusInside) onOpenChange(false);
closeTimer = undefined;
}, 180);
}
function enter(event: PointerEvent) {
if (mobile || event.pointerType !== 'mouse') return;
pointerInside = true;
clearCloseTimer();
if (mode === 'hover') onOpenChange(true);
}
function leave(event: PointerEvent) {
if (event.pointerType !== 'mouse') return;
pointerInside = false;
scheduleClose();
}
function focusIn() {
focusInside = true;
clearCloseTimer();
if (!mobile && mode === 'hover') onOpenChange(true);
}
function focusOut(event: FocusEvent) {
if (event.relatedTarget instanceof Node && frame.contains(event.relatedTarget)) return;
focusInside = false;
scheduleClose();
}
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();
onModeChange(next);
if (!mobile) onOpenChange(next === 'hover');
scheduleClose();
}
$effect(() => {
mobile;
pointerInside = false;
focusInside = false;
clearCloseTimer();
});
onDestroy(clearCloseTimer);
</script>
<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">
<div class="sidebar-frame-content" inert={folded} aria-hidden={folded}>
<aside
class="sidebar-frame"
class:folded={!open}
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>
@@ -26,24 +97,17 @@
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
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'}
onclick={toggleMode}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
<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>
</button>
</div>
</aside>
</div>
</Bevel>
</div>
</aside>
@@ -5,6 +5,7 @@
--sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0);
--sidebar-width: clamp(220px, 20vw, 280px);
--sidebar-rail-width: calc(32px + var(--space-2) + var(--space-2) + 1px);
--sidebar-motion-duration: 220ms;
--sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1);
@@ -38,7 +39,7 @@
}
}
.sidebar-frame.folded {
width: calc(32px + var(--space-2) + var(--space-2) + 1px);
width: var(--sidebar-rail-width);
overflow: hidden;
}
.sidebar-frame-content {
@@ -58,6 +59,25 @@
visibility: hidden;
transition-delay: 0s, var(--sidebar-motion-duration);
}
.sidebar-frame.hover-mode {
position: relative;
z-index: 40;
width: var(--sidebar-rail-width);
overflow: visible;
}
.sidebar-frame.hover-mode .bevel.sidebar-frame__bevel {
position: absolute;
top: 0;
left: 0;
height: 100%;
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);
}
.sidebar-frame-content,
.global-sidebar,
.workspace-sidebar,
@@ -647,7 +667,8 @@
}
@media (max-width: 760px) {
.sidebar-frame {
.sidebar-frame,
.sidebar-frame.hover-mode {
--sidebar-width: 100%;
z-index: 20;
grid-column: 1;
@@ -660,7 +681,8 @@
transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing),
visibility 0s;
}
.sidebar-frame.folded {
.sidebar-frame.folded,
.sidebar-frame.hover-mode.folded {
width: 100%;
transform: translateX(-100%);
visibility: hidden;
@@ -673,8 +695,11 @@
visibility: inherit;
transition: none;
}
.sidebar-control-row {
display: none;
.sidebar-frame.hover-mode .bevel.sidebar-frame__bevel,
.sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel {
position: static;
width: 100%;
transition: none;
}
}
+41 -18
View File
@@ -25,31 +25,42 @@
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
header = activeHeader;
});
// Browser-wide layout preference; independent of authentication and Workspace.
const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1';
function loadSidebarFolded(): boolean {
if (typeof window === 'undefined') return false;
// Only the display mode persists; hover/touch previews never do.
const sidebarModeStorageKey = 'yoi.sidebar.mode.v1';
const legacySidebarStorageKey = 'yoi.sidebar.folded.v1';
function loadSidebarMode(): 'pinned' | 'hover' {
if (typeof window === 'undefined') return 'pinned';
try {
return window.localStorage.getItem(sidebarFoldStorageKey) === 'true';
const mode = window.localStorage.getItem(sidebarModeStorageKey);
if (mode === 'pinned' || mode === 'hover') return mode;
return window.localStorage.getItem(legacySidebarStorageKey) === 'true' ? 'hover' : 'pinned';
} catch {
return false;
return 'pinned';
}
}
let sidebarFolded = $state(loadSidebarFolded());
let sidebarMode = $state(loadSidebarMode());
let sidebarTransientOpen = $state(false);
const mobileLayout = new MediaQuery('(max-width: 760px)');
const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen);
$effect(() => {
const folded = sidebarFolded;
const mode = sidebarMode;
try {
window.localStorage.setItem(sidebarFoldStorageKey, String(folded));
window.localStorage.setItem(sidebarModeStorageKey, mode);
window.localStorage.removeItem(legacySidebarStorageKey);
} catch {
// Storage can be blocked or full; folding must still work for this page.
// Blocked/full storage must not prevent mode changes in this page.
}
});
$effect(() => {
mobileLayout.current;
sidebarTransientOpen = false;
});
function toggleSidebar() {
sidebarFolded = !sidebarFolded;
sidebarTransientOpen = !sidebarOpen;
}
provideHeaderController({
@@ -62,8 +73,14 @@
<WorkspaceAlerts />
<div class="app-shell" class:sidebar-open={!sidebarFolded}>
<SidebarFrame bind:folded={sidebarFolded}>
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
<SidebarFrame
mode={sidebarMode}
open={sidebarOpen}
mobile={mobileLayout.current}
onModeChange={(mode) => { sidebarMode = mode; }}
onOpenChange={(open) => { sidebarTransientOpen = open; }}
>
<GlobalSidebar
currentPath={page.url.pathname}
content={sidebar}
@@ -80,13 +97,13 @@
<button
class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
type="button"
aria-label={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
aria-expanded={!sidebarFolded}
title={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
aria-expanded={sidebarOpen}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onclick={toggleSidebar}
>
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
{#if sidebarFolded}
{#if !sidebarOpen}
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
{:else}
@@ -104,7 +121,7 @@
</nav>
</header>
</Bevel>
<main class="app-shell__main" inert={mobileLayout.current && !sidebarFolded}>
<main class="app-shell__main" inert={mobileLayout.current && sidebarOpen}>
{@render children()}
</main>
</div>
@@ -203,6 +220,12 @@
padding: var(--space-4);
}
@media (hover: none) {
.sidebar-hover-mode .app-shell__mobile-sidebar-toggle {
display: inline-flex;
}
}
@media (max-width: 760px) {
.app-shell {
grid-template-columns: minmax(0, 1fr);
@@ -2,88 +2,172 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createRawSnippet } from "svelte";
import { createRawSnippet, tick } from "svelte";
import { logout } from "$lib/workspace/auth/api";
import Layout from "./+layout.svelte";
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
const storageKey = "yoi.sidebar.folded.v1";
const storageKey = "yoi.sidebar.mode.v1";
const legacyKey = "yoi.sidebar.folded.v1";
const props = {
params: {},
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
};
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 delay = () => vi.advanceTimersByTimeAsync(200);
beforeEach(() => localStorage.clear());
beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); });
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
});
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")!;
test("unpinning changes mode, and pointer departure closes the preview", async () => {
render(Layout, { props });
expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true");
await enter();
screen.getByRole("button", { name: "Unpin sidebar" }).focus();
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
expect(localStorage.getItem(storageKey)).toBe("hover");
expect(folded()).toBe(false);
await leave();
await delay();
expect(folded()).toBe(true);
});
test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => {
localStorage.setItem(storageKey, mode);
render(Layout, { props });
expect(folded()).toBe(mode === "hover");
expect(localStorage.getItem(storageKey)).toBe(mode);
});
test.each([true, false])("migrates legacy folded=%s to a mode", (value) => {
localStorage.setItem(legacyKey, String(value));
render(Layout, { props });
expect(localStorage.getItem(storageKey)).toBe(value ? "hover" : "pinned");
expect(localStorage.getItem(legacyKey)).toBeNull();
expect(folded()).toBe(value);
});
test("a saved mode takes precedence over the legacy preference", () => {
localStorage.setItem(legacyKey, "true");
localStorage.setItem(storageKey, "pinned");
render(Layout, { props });
expect(folded()).toBe(false);
});
test("hover keeps the same content node, delays closing, and cancels closing on reentry", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
const content = frame().querySelector<HTMLElement>(".sidebar-frame-content")!;
expect(content.inert).toBe(true);
await enter();
expect(folded()).toBe(false);
expect(content.inert).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
expect(view.container.querySelector(".sidebar-frame-content")).toBe(content);
await leave();
await vi.advanceTimersByTimeAsync(100);
expect(folded()).toBe(false);
await enter();
await delay();
expect(folded()).toBe(false);
await leave();
await delay();
expect(folded()).toBe(true);
expect(frame().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" }));
expect(localStorage.getItem(storageKey)).toBe("true");
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" }));
expect(localStorage.getItem(storageKey)).toBe("false");
expect(localStorage.getItem(storageKey)).toBe("hover");
});
test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => {
localStorage.setItem(storageKey, String(folded));
const writes = vi.spyOn(Storage.prototype, "setItem");
const view = render(Layout, { props });
expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded));
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded);
expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true);
test("keyboard focus opens the preview and holds it until focus leaves", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
screen.getByRole("button", { name: "Pin sidebar" }).focus();
await tick();
expect(folded()).toBe(false);
await enter();
await leave();
await delay();
expect(folded()).toBe(false);
screen.getByRole("link", { name: "Open Account" }).focus();
await delay();
expect(folded()).toBe(true);
});
test("fold preference survives logout and a fresh app mount", async () => {
test("pinning a preview keeps it open after pointer and focus leave", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
await enter();
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 });
await leave();
await delay();
expect(folded()).toBe(false);
expect(localStorage.getItem(storageKey)).toBe("pinned");
});
test("mode survives logout and remount, but an open preview does not", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
localStorage.setItem(storageKey, "hover");
const view = render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
await enter();
await logout();
view.unmount();
render(Layout, { props });
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull();
expect(localStorage.getItem(storageKey)).toBe("true");
expect(folded()).toBe(true);
expect(localStorage.getItem(storageKey)).toBe("hover");
});
test("invalid storage falls back to unfolded", () => {
localStorage.setItem(storageKey, "not-a-boolean");
test("touch does not hover; mobile header opens temporarily without changing mode", async () => {
const matchMedia = window.matchMedia.bind(window);
vi.spyOn(window, "matchMedia").mockImplementation((query) => {
const result = matchMedia(query);
Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" });
return result;
});
localStorage.setItem(storageKey, "hover");
const view = render(Layout, { props });
await fireEvent.pointerEnter(frame(), { pointerType: "touch" });
expect(folded()).toBe(true);
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
expect(folded()).toBe(false);
expect(view.container.querySelector<HTMLElement>("main")!.inert).toBe(true);
await fireEvent.pointerLeave(frame(), { pointerType: "touch" });
await delay();
expect(folded()).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));
expect(folded()).toBe(true);
expect(view.container.querySelector<HTMLElement>("main")!.inert).toBe(false);
expect(localStorage.getItem(storageKey)).toBe("hover");
});
test("invalid storage falls back to pinned", () => {
localStorage.setItem(storageKey, "not-a-mode");
render(Layout, { props });
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
expect(folded()).toBe(false);
});
test("unavailable storage does not prevent folding", async () => {
test("unavailable storage does not prevent mode switching", async () => {
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
await delay();
expect(folded()).toBe(true);
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
expect(folded()).toBe(false);
});
test("full storage still restores the saved value and allows toggles", async () => {
localStorage.setItem(storageKey, "true");
test("full storage still restores the mode and allows changes", async () => {
localStorage.setItem(storageKey, "hover");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" }));
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
expect(folded()).toBe(false);
});
@@ -93,18 +93,18 @@ Deno.test("Shell regions use selected Bevel edges while open separators use Beve
assert(
rootLayout.includes('class="app-shell__topbar-bevel"') &&
rootLayout.includes("top={false} right={false} left={false}") &&
rootLayout.includes("bind:folded={sidebarFolded}") &&
rootLayout.includes("open={sidebarOpen}") &&
rootLayout.includes("app-shell__mobile-sidebar-toggle") &&
rootLayout.includes("class:sidebar-open={!sidebarFolded}") &&
rootLayout.includes("class:sidebar-open={sidebarOpen}") &&
!rootLayout.includes("depth=") &&
!rootLayout.includes("BevelLine") &&
sidebarFrame.includes('class="sidebar-frame__bevel"') &&
sidebarFrame.includes("top={false} bottom={false} left={false}") &&
sidebarFrame.includes("$bindable(false)") &&
sidebarFrame.includes("class:folded={!open}") &&
!sidebarFrame.includes("depth=") &&
!sidebarFrame.includes("BevelLine") &&
sidebarCss.includes(".sidebar-frame.folded") &&
sidebarCss.includes("display: none") &&
sidebarCss.includes("transform: translateX(-100%)") &&
sidebarCss.includes("display: grid"),
"Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state",
);