feat: add pinned and hover sidebar modes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user