fix: close mobile sidebar after navigation

This commit is contained in:
2026-10-04 21:03:16 +09:00
parent b0d4973717
commit 8a2a82d13c
3 changed files with 88 additions and 1 deletions
@@ -596,6 +596,16 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
await touchPage.reload();
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
// Different-route and same-page links both dismiss the mobile overlay.
for (let visit = 0; visit < 2; visit++) {
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
await touchPage.getByRole("link", { name: "Workspace home", exact: true }).tap();
await touchPage.waitForURL(`**/w/${workspaceId}`);
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await touchPage.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
}
await touchPage.goto(page.url());
// 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();
@@ -1,5 +1,6 @@
<script lang="ts">
import { onDestroy, type Snippet } from 'svelte';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { on } from 'svelte/events';
import Bevel from '#lib/workspace/ui/Bevel.svelte';
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
import './sidebar.css';
@@ -77,6 +78,19 @@
focusInside = false;
clearCloseTimer();
});
function closeAfterNavigation(event: MouseEvent) {
if (!mobile || !open || event.defaultPrevented || event.button !== 0 ||
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (!(link instanceof HTMLAnchorElement) || !frame.contains(link) ||
link.hasAttribute('download') || (link.target && link.target !== '_self')) return;
clearCloseTimer();
onOpenChange(false);
}
// Handle nested navigation links, including activation of the current page.
// Svelte's listener preserves ordering with delegated child click handlers.
onMount(() => on(frame, 'click', closeAfterNavigation));
onDestroy(clearCloseTimer);
</script>
@@ -0,0 +1,63 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from "@testing-library/svelte";
import { createRawSnippet } from "svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import SidebarFrame from "./SidebarFrame.svelte";
// Stop happy-dom navigation after the component has handled the bubbling click.
const preventBrowserNavigation = (event: MouseEvent) => event.preventDefault();
beforeEach(() => document.addEventListener("click", preventBrowserNavigation));
afterEach(() => {
cleanup();
document.removeEventListener("click", preventBrowserNavigation);
});
function mount(mobile = true, mode: "pinned" | "hover" = "pinned") {
const onOpenChange = vi.fn();
const onModeChange = vi.fn();
const children = createRawSnippet(() => ({
render: () => `<nav>
<a href="/w/example"><span>Workspace home</span></a>
<a href="${location.href}">Current page</a>
<a href="#section">Section</a>
<a href="/account" target="_blank">New tab</a>
<a href="/export" download>Download</a>
<button type="button">Expand group</button>
</nav>`,
}));
return { ...render(SidebarFrame, {
mobile, mode, open: true, children, onOpenChange, onModeChange,
}), onOpenChange, onModeChange };
}
test.each(["pinned", "hover"] as const)("mobile %s closes on nested, current-page and hash links", async (mode) => {
const view = mount(true, mode);
for (const label of ["Workspace home", "Current page", "Section"]) {
view.onOpenChange.mockClear();
await fireEvent.click(view.getByText(label));
expect(view.onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
expect(view.onModeChange).not.toHaveBeenCalled();
}
});
test("mobile ignores disclosure buttons, modified clicks, new tabs, downloads and cancelled navigation", async () => {
const view = mount();
for (const label of ["Expand group", "New tab", "Download"]) {
await fireEvent.click(view.getByText(label));
}
const link = view.getByText("Workspace home");
for (const options of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
await fireEvent.click(link, options);
}
link.addEventListener("click", (event) => event.preventDefault(), { once: true });
await fireEvent.click(link);
expect(view.onOpenChange).not.toHaveBeenCalled();
});
test.each(["pinned", "hover"] as const)("desktop %s navigation does not change sidebar mode or open state", async (mode) => {
const view = mount(false, mode);
await fireEvent.click(view.getByText("Workspace home"));
expect(view.onOpenChange).not.toHaveBeenCalled();
expect(view.onModeChange).not.toHaveBeenCalled();
});