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> { 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 toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-frame");
const result = await toggle.evaluate(async (button, mobile) => { const result = await toggle.evaluate(async (button, { mobile, opening }) => {
const frame = document.querySelector<HTMLElement>(".sidebar-frame")!; const frame = document.querySelector<HTMLElement>(".sidebar-frame")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!; const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
const main = document.querySelector<HTMLElement>(".app-shell__main")!; 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, x: moving.getBoundingClientRect().x,
width: moving.getBoundingClientRect().width, width: moving.getBoundingClientRect().width,
frameWidth: frame.getBoundingClientRect().width, frameWidth: frame.getBoundingClientRect().width,
panelWidth: frame.querySelector(".sidebar-frame__bevel")!.getBoundingClientRect().width,
mainWidth: main.getBoundingClientRect().width, mainWidth: main.getBoundingClientRect().width,
mainHeight: main.getBoundingClientRect().height, mainHeight: main.getBoundingClientRect().height,
}); });
const before = sample(); const before = sample();
if (mobile) {
(button as HTMLButtonElement).click(); (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()))); await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const animations = frame.getAnimations({ subtree: true }); const animations = frame.getAnimations({ subtree: true });
const slides = animations.filter((animation) => animation instanceof CSSTransition && 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(); for (const animation of animations) animation.finish();
await Promise.all(animations.map((animation) => animation.finished)); await Promise.all(animations.map((animation) => animation.finished));
return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert }; return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert };
}, mobile); }, { mobile, opening });
assert(result.durations.length >= (mobile ? 1 : 2)); assert(result.durations.length >= (mobile ? 1 : 2), `missing sidebar motion: ${JSON.stringify({ mobile, opening, result })}`);
assert(result.durations.every((duration) => duration === 220)); assert(result.durations.every((duration) => duration === 220));
const { before, middle, after } = result; const { before, middle, after } = result;
assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x, 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(before.width, after.width, "sidebar contents must slide without reflowing");
assertEquals(result.contentInert, !opening); assertEquals(result.contentInert, !opening);
assertEquals(result.mainInert, mobile && opening); assertEquals(result.mainInert, mobile && opening);
if (mobile) { assertEquals(before.mainWidth, after.mainWidth, "hover/touch previews must not resize the main content");
assertEquals(before.mainWidth, after.mainWidth);
assertEquals(before.mainHeight, after.mainHeight); assertEquals(before.mainHeight, after.mainHeight);
assertEquals(after.frameWidth, before.frameWidth); assertEquals(after.frameWidth, before.frameWidth, "hover mode keeps a fixed rail width");
} else { if (!mobile) {
assert(opening ? before.frameWidth < middle.frameWidth && middle.frameWidth < after.frameWidth assert(opening ? before.panelWidth < middle.panelWidth && middle.panelWidth < after.panelWidth
: before.frameWidth > middle.frameWidth && middle.frameWidth > after.frameWidth); : 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); assertEquals(new Set(rowIds).size, rowIds.length);
await page.getByRole("button", { name: "Overview", exact: true }).click(); await page.getByRole("button", { name: "Overview", exact: true }).click();
assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12);
await checkSidebarSlide(page, false, false); const sidebar = page.locator(".sidebar-frame");
await checkSidebarSlide(page, false, true); const settleSidebar = () => sidebar.evaluate(async (element) => {
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) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)); 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, true);
await checkSidebarSlide(page, true, false); await checkSidebarSlide(page, true, false);
await checkSidebarSlide(page, true, true); 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.reload();
await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor(); await page.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1); 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.emulateMedia({ reducedMotion: "reduce" });
await page.getByRole("button", { name: "Hide sidebar", exact: true }).click(); await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({ 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(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" });
assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false); 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, []); assertEquals(errors, []);
await context.close(); await context.close();
} finally { } 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", "Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
); );
assert( assert(
sidebarFrame.includes("folded = $bindable(false)") && sidebarFrame.includes("mode: 'pinned' | 'hover'") &&
sidebarFrame.includes("sidebar-fold-button") && sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") && sidebarFrame.includes("Pin sidebar") &&
sidebarFrame.includes("Unfold sidebar") && sidebarFrame.includes("Unpin sidebar") &&
rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") && rootLayout.includes("let sidebarMode = $state(loadSidebarMode())") &&
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") && rootLayout.includes("mode={sidebarMode}") &&
!workspaceLayout.includes("sidebarFolded") && !workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") && !workspaceLayout.includes("onToggleFold") &&
!sidebar.includes("folded?: boolean") && !sidebar.includes("folded?: boolean") &&
@@ -1,24 +1,95 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import { onDestroy, type Snippet } from 'svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte'; import Bevel from '$lib/workspace/ui/Bevel.svelte';
import './sidebar.css'; import './sidebar.css';
type Props = { type Props = {
children: Snippet<[]>; 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() { function clearCloseTimer() {
folded = !folded; 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> </script>
<div class="sidebar-frame" class:folded> <aside
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false} fill> class="sidebar-frame"
<aside class="sidebar-frame__surface" aria-label="Sidebar"> class:folded={!open}
<div class="sidebar-frame-content" inert={folded} aria-hidden={folded}> 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()} {@render children()}
</div> </div>
@@ -26,24 +97,17 @@
<button <button
class="sidebar-fold-button" class="sidebar-fold-button"
type="button" type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'} aria-label={mode === 'pinned' ? 'Unpin sidebar' : 'Pin sidebar'}
aria-expanded={!folded} aria-pressed={mode === 'pinned'}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'} title={mode === 'pinned' ? 'Always visible — switch to show on hover' : 'Show on hover — pin sidebar'}
onclick={toggleFold} onclick={toggleMode}
> >
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24"> <svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" /> <path d="M9 3H15L14 9L18 13V15H6V13L10 9L9 3ZM12 15V21" />
<path d="m13 17 5-5-5-5" /> {#if mode === 'hover'}<path d="M3 3L21 21" />{/if}
</svg> </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}
</button> </button>
</div> </div>
</aside> </div>
</Bevel> </Bevel>
</div> </aside>
@@ -5,6 +5,7 @@
--sidebar-item-hover: oklch(96% 0 0); --sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0); --sidebar-item-active: oklch(92.5% 0 0);
--sidebar-width: clamp(220px, 20vw, 280px); --sidebar-width: clamp(220px, 20vw, 280px);
--sidebar-rail-width: calc(32px + var(--space-2) + var(--space-2) + 1px);
--sidebar-motion-duration: 220ms; --sidebar-motion-duration: 220ms;
--sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1); --sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1);
@@ -38,7 +39,7 @@
} }
} }
.sidebar-frame.folded { .sidebar-frame.folded {
width: calc(32px + var(--space-2) + var(--space-2) + 1px); width: var(--sidebar-rail-width);
overflow: hidden; overflow: hidden;
} }
.sidebar-frame-content { .sidebar-frame-content {
@@ -58,6 +59,25 @@
visibility: hidden; visibility: hidden;
transition-delay: 0s, var(--sidebar-motion-duration); 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, .sidebar-frame-content,
.global-sidebar, .global-sidebar,
.workspace-sidebar, .workspace-sidebar,
@@ -647,7 +667,8 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.sidebar-frame { .sidebar-frame,
.sidebar-frame.hover-mode {
--sidebar-width: 100%; --sidebar-width: 100%;
z-index: 20; z-index: 20;
grid-column: 1; grid-column: 1;
@@ -660,7 +681,8 @@
transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing), transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing),
visibility 0s; visibility 0s;
} }
.sidebar-frame.folded { .sidebar-frame.folded,
.sidebar-frame.hover-mode.folded {
width: 100%; width: 100%;
transform: translateX(-100%); transform: translateX(-100%);
visibility: hidden; visibility: hidden;
@@ -673,8 +695,11 @@
visibility: inherit; visibility: inherit;
transition: none; transition: none;
} }
.sidebar-control-row { .sidebar-frame.hover-mode .bevel.sidebar-frame__bevel,
display: none; .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) => { const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
header = activeHeader; header = activeHeader;
}); });
// Browser-wide layout preference; independent of authentication and Workspace. // Only the display mode persists; hover/touch previews never do.
const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1'; const sidebarModeStorageKey = 'yoi.sidebar.mode.v1';
function loadSidebarFolded(): boolean { const legacySidebarStorageKey = 'yoi.sidebar.folded.v1';
if (typeof window === 'undefined') return false; function loadSidebarMode(): 'pinned' | 'hover' {
if (typeof window === 'undefined') return 'pinned';
try { 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 { } 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 mobileLayout = new MediaQuery('(max-width: 760px)');
const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen);
$effect(() => { $effect(() => {
const folded = sidebarFolded; const mode = sidebarMode;
try { try {
window.localStorage.setItem(sidebarFoldStorageKey, String(folded)); window.localStorage.setItem(sidebarModeStorageKey, mode);
window.localStorage.removeItem(legacySidebarStorageKey);
} catch { } 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() { function toggleSidebar() {
sidebarFolded = !sidebarFolded; sidebarTransientOpen = !sidebarOpen;
} }
provideHeaderController({ provideHeaderController({
@@ -62,8 +73,14 @@
<WorkspaceAlerts /> <WorkspaceAlerts />
<div class="app-shell" class:sidebar-open={!sidebarFolded}> <div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
<SidebarFrame bind:folded={sidebarFolded}> <SidebarFrame
mode={sidebarMode}
open={sidebarOpen}
mobile={mobileLayout.current}
onModeChange={(mode) => { sidebarMode = mode; }}
onOpenChange={(open) => { sidebarTransientOpen = open; }}
>
<GlobalSidebar <GlobalSidebar
currentPath={page.url.pathname} currentPath={page.url.pathname}
content={sidebar} content={sidebar}
@@ -80,13 +97,13 @@
<button <button
class="app-shell__icon-button app-shell__mobile-sidebar-toggle" class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
type="button" type="button"
aria-label={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'} aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
aria-expanded={!sidebarFolded} aria-expanded={sidebarOpen}
title={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'} title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onclick={toggleSidebar} onclick={toggleSidebar}
> >
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24"> <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="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" /> <path d="m13 17 5-5-5-5" />
{:else} {:else}
@@ -104,7 +121,7 @@
</nav> </nav>
</header> </header>
</Bevel> </Bevel>
<main class="app-shell__main" inert={mobileLayout.current && !sidebarFolded}> <main class="app-shell__main" inert={mobileLayout.current && sidebarOpen}>
{@render children()} {@render children()}
</main> </main>
</div> </div>
@@ -203,6 +220,12 @@
padding: var(--space-4); padding: var(--space-4);
} }
@media (hover: none) {
.sidebar-hover-mode .app-shell__mobile-sidebar-toggle {
display: inline-flex;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.app-shell { .app-shell {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -2,88 +2,172 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; 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 { logout } from "$lib/workspace/auth/api";
import Layout from "./+layout.svelte"; import Layout from "./+layout.svelte";
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } })); vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
const storageKey = "yoi.sidebar.mode.v1";
const storageKey = "yoi.sidebar.folded.v1"; const legacyKey = "yoi.sidebar.folded.v1";
const props = { const props = {
params: {}, params: {},
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })), children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
data: { accessibleWorkspaces: [], workspaceCatalogError: null }, 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(() => { afterEach(() => {
cleanup(); cleanup();
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
localStorage.clear(); localStorage.clear();
}); });
test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => { test("unpinning changes mode, and pointer departure closes the preview", async () => {
const view = render(Layout, { props }); render(Layout, { props });
expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true"); expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true");
const content = view.container.querySelector<HTMLElement>(".sidebar-frame-content")!; 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); expect(content.inert).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); await leave();
expect(view.container.querySelector(".sidebar-frame-content")).toBe(content); 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.inert).toBe(true);
expect(content.getAttribute("aria-hidden")).toBe("true"); expect(content.getAttribute("aria-hidden")).toBe("true");
expect(localStorage.getItem(storageKey)).toBe("true"); expect(localStorage.getItem(storageKey)).toBe("hover");
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");
}); });
test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => { test("keyboard focus opens the preview and holds it until focus leaves", async () => {
localStorage.setItem(storageKey, String(folded)); localStorage.setItem(storageKey, "hover");
const writes = vi.spyOn(Storage.prototype, "setItem"); render(Layout, { props });
const view = render(Layout, { props }); screen.getByRole("button", { name: "Pin sidebar" }).focus();
expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded)); await tick();
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded); expect(folded()).toBe(false);
expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true); 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" }))); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
localStorage.setItem(storageKey, "hover");
const view = render(Layout, { props }); const view = render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); await enter();
await logout(); await logout();
view.unmount(); view.unmount();
render(Layout, { props }); render(Layout, { props });
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); expect(folded()).toBe(true);
expect(localStorage.getItem(storageKey)).toBe("true"); expect(localStorage.getItem(storageKey)).toBe("hover");
}); });
test("invalid storage falls back to unfolded", () => { test("touch does not hover; mobile header opens temporarily without changing mode", async () => {
localStorage.setItem(storageKey, "not-a-boolean"); 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 }); 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"); }); vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
render(Layout, { props }); render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); await delay();
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); expect(folded()).toBe(true);
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); 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 () => { test("full storage still restores the mode and allows changes", async () => {
localStorage.setItem(storageKey, "true"); localStorage.setItem(storageKey, "hover");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); }); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
render(Layout, { props }); render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); expect(folded()).toBe(false);
}); });
@@ -93,18 +93,18 @@ Deno.test("Shell regions use selected Bevel edges while open separators use Beve
assert( assert(
rootLayout.includes('class="app-shell__topbar-bevel"') && rootLayout.includes('class="app-shell__topbar-bevel"') &&
rootLayout.includes("top={false} right={false} left={false}") && 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("app-shell__mobile-sidebar-toggle") &&
rootLayout.includes("class:sidebar-open={!sidebarFolded}") && rootLayout.includes("class:sidebar-open={sidebarOpen}") &&
!rootLayout.includes("depth=") && !rootLayout.includes("depth=") &&
!rootLayout.includes("BevelLine") && !rootLayout.includes("BevelLine") &&
sidebarFrame.includes('class="sidebar-frame__bevel"') && sidebarFrame.includes('class="sidebar-frame__bevel"') &&
sidebarFrame.includes("top={false} bottom={false} left={false}") && sidebarFrame.includes("top={false} bottom={false} left={false}") &&
sidebarFrame.includes("$bindable(false)") && sidebarFrame.includes("class:folded={!open}") &&
!sidebarFrame.includes("depth=") && !sidebarFrame.includes("depth=") &&
!sidebarFrame.includes("BevelLine") && !sidebarFrame.includes("BevelLine") &&
sidebarCss.includes(".sidebar-frame.folded") && sidebarCss.includes(".sidebar-frame.folded") &&
sidebarCss.includes("display: none") && sidebarCss.includes("transform: translateX(-100%)") &&
sidebarCss.includes("display: grid"), sidebarCss.includes("display: grid"),
"Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state", "Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state",
); );