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> {
|
async function waitForServer(url: string): Promise<void> {
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
if ((await fetch(url)).ok) return;
|
const response = await fetch(url);
|
||||||
|
await response.body?.cancel();
|
||||||
|
if (response.ok) return;
|
||||||
} catch {
|
} catch {
|
||||||
// Retry until the bounded deadline while the owned server starts.
|
// 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);
|
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 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, []);
|
assertEquals(errors, []);
|
||||||
await context.close();
|
await context.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1329,7 +1329,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
|||||||
sidebarFrame.includes("sidebar-fold-button") &&
|
sidebarFrame.includes("sidebar-fold-button") &&
|
||||||
sidebarFrame.includes("Fold sidebar") &&
|
sidebarFrame.includes("Fold sidebar") &&
|
||||||
sidebarFrame.includes("Unfold sidebar") &&
|
sidebarFrame.includes("Unfold sidebar") &&
|
||||||
rootLayout.includes("let sidebarFolded = $state(false)") &&
|
rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") &&
|
||||||
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
|
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
|
||||||
!workspaceLayout.includes("sidebarFolded") &&
|
!workspaceLayout.includes("sidebarFolded") &&
|
||||||
!workspaceLayout.includes("onToggleFold") &&
|
!workspaceLayout.includes("onToggleFold") &&
|
||||||
|
|||||||
@@ -24,7 +24,27 @@
|
|||||||
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
|
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
|
||||||
header = 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() {
|
function toggleSidebar() {
|
||||||
sidebarFolded = !sidebarFolded;
|
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