feat: persist sidebar fold preference across logins
This commit is contained in:
@@ -24,7 +24,9 @@ async function freePort(): Promise<number> {
|
||||
async function waitForServer(url: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
try {
|
||||
if ((await fetch(url)).ok) return;
|
||||
const response = await fetch(url);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Retry until the bounded deadline while the owned server starts.
|
||||
}
|
||||
@@ -334,6 +336,17 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
||||
assertEquals(new Set(rowIds).size, rowIds.length);
|
||||
await page.getByRole("button", { name: "Overview", exact: true }).click();
|
||||
assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12);
|
||||
await page.getByRole("button", { name: "Fold sidebar", exact: true }).click();
|
||||
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true");
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor();
|
||||
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
|
||||
await page.setViewportSize({ width: 600, height: viewportHeight });
|
||||
await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
|
||||
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false");
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor();
|
||||
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1);
|
||||
assertEquals(errors, []);
|
||||
await context.close();
|
||||
} finally {
|
||||
|
||||
@@ -1329,7 +1329,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||
sidebarFrame.includes("sidebar-fold-button") &&
|
||||
sidebarFrame.includes("Fold sidebar") &&
|
||||
sidebarFrame.includes("Unfold sidebar") &&
|
||||
rootLayout.includes("let sidebarFolded = $state(false)") &&
|
||||
rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") &&
|
||||
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
|
||||
!workspaceLayout.includes("sidebarFolded") &&
|
||||
!workspaceLayout.includes("onToggleFold") &&
|
||||
|
||||
@@ -24,7 +24,27 @@
|
||||
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
|
||||
header = activeHeader;
|
||||
});
|
||||
let sidebarFolded = $state(false);
|
||||
// Browser-wide layout preference; independent of authentication and Workspace.
|
||||
const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1';
|
||||
function loadSidebarFolded(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return window.localStorage.getItem(sidebarFoldStorageKey) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let sidebarFolded = $state(loadSidebarFolded());
|
||||
|
||||
$effect(() => {
|
||||
const folded = sidebarFolded;
|
||||
try {
|
||||
window.localStorage.setItem(sidebarFoldStorageKey, String(folded));
|
||||
} catch {
|
||||
// Storage can be blocked or full; folding must still work for this page.
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarFolded = !sidebarFolded;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { createRawSnippet } 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 props = {
|
||||
params: {},
|
||||
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
|
||||
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
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");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
|
||||
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(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) => {
|
||||
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("fold preference survives logout and a fresh app mount", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
|
||||
const view = render(Layout, { props });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
|
||||
await logout();
|
||||
view.unmount();
|
||||
render(Layout, { props });
|
||||
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull();
|
||||
expect(localStorage.getItem(storageKey)).toBe("true");
|
||||
});
|
||||
|
||||
test("invalid storage falls back to unfolded", () => {
|
||||
localStorage.setItem(storageKey, "not-a-boolean");
|
||||
render(Layout, { props });
|
||||
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("unavailable storage does not prevent folding", 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();
|
||||
});
|
||||
|
||||
test("full storage still restores the saved value and allows toggles", async () => {
|
||||
localStorage.setItem(storageKey, "true");
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user