diff --git a/AGENTS.md b/AGENTS.md index 424a4b23..a157a417 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,7 @@ return a `Command`, so `.then(publish)` is usually all you need. - Use `$state.snapshot` if calling component might unmount - Navigate with `navigate` from `app/modal.ts` rather than `goto` — an open modal owns the current history entry, so a navigation that drops it replaces that entry instead of stacking on it +- Pass `keepModal` to `navigate` to change the page under a modal and leave it open ## Development Workflow diff --git a/e2e/specs/routing.spec.ts b/e2e/specs/routing.spec.ts index d68feaf3..a5c9a8e6 100644 --- a/e2e/specs/routing.spec.ts +++ b/e2e/specs/routing.spec.ts @@ -146,3 +146,39 @@ test("takes over the space menu's history entry when you navigate out of it", as await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) await expect(drawer).toHaveCount(0) }) + +test("switches spaces inside the space menu without closing it", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("lounge", {name: "Space Lounge"}) + other.room("garden", {name: "Other Garden"}) + space.join(user.alice, "lounge") + other.join(user.alice, "garden") + }) + + const space = scenario.space("space") + const other = scenario.space("other") + const page = await as(users.alice, roomPath(space.url, "lounge"), { + context: {viewport: {width: 390, height: 844}, hasTouch: true}, + }) + + const drawer = page.locator(".drawer") + + await page.getByRole("button", {name: "Open space menu"}).click() + + await expect(drawer.getByRole("link", {name: "Space Lounge"})).toBeVisible() + + // The rail is in the menu on a phone, so a space is picked with the menu still open and the room + // is picked after it. Navigating out from under the menu would close it on the first tap. + await drawer.locator('.primary-nav [data-tip^="other"]').click() + + await expect(page).toHaveURL(new RegExp(spacePath(other.url))) + await expect(drawer.getByRole("link", {name: "Other Garden"})).toBeVisible() + + await drawer.getByRole("link", {name: "Other Garden"}).click() + + await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "garden")}$`)) + await expect(drawer).toHaveCount(0) +}) diff --git a/src/app/components/PrimaryNavItemSpace.svelte b/src/app/components/PrimaryNavItemSpace.svelte index 3b52b46e..20c38a21 100644 --- a/src/app/components/PrimaryNavItemSpace.svelte +++ b/src/app/components/PrimaryNavItemSpace.svelte @@ -17,7 +17,7 @@ const onClick = () => { const entryPath = makeSpaceEntryPath(url) - navigate(entryPath, {replaceState: entryPath === $page.url.pathname}) + navigate(entryPath, {keepModal: true, replaceState: entryPath === $page.url.pathname}) } const path = makeSpacePath(url) diff --git a/src/app/components/SpaceMenuDrawer.svelte b/src/app/components/SpaceMenuDrawer.svelte index 7e0c3fb7..2fd1ef14 100644 --- a/src/app/components/SpaceMenuDrawer.svelte +++ b/src/app/components/SpaceMenuDrawer.svelte @@ -3,6 +3,7 @@ import SecondaryNav from "@lib/components/SecondaryNav.svelte" import SpaceMenu from "@app/components/SpaceMenu.svelte" import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte" + import {lastSpaceUrl} from "@app/routes" type Props = { url: string @@ -10,6 +11,8 @@ const {url}: Props = $props() + const activeUrl = $derived($lastSpaceUrl ?? url) + const duration = 200 const parallax = 10 @@ -32,7 +35,9 @@
- + {#key activeUrl} + + {/key}
diff --git a/src/app/modal.ts b/src/app/modal.ts index 63ee2694..3ba4f581 100644 --- a/src/app/modal.ts +++ b/src/app/modal.ts @@ -64,8 +64,19 @@ const closesModal = (path: string) => { return hash !== "" && !path.endsWith(hash) } -export const navigate = (path: string, options?: Parameters[1]) => - goto(path, {...options, replaceState: options?.replaceState || closesModal(path)}) +export type NavigateOptions = Parameters[1] & {keepModal?: boolean} + +// The modal hash is written with the History API, so it never reaches `page` — carrying it onto +// the path is what navigates underneath an open modal rather than closing it +export const navigate = (path: string, {keepModal, ...options}: NavigateOptions = {}) => { + const hash = get(modalHash) + + if (keepModal && hash) { + return goto(path + hash, {...options, replaceState: true}) + } + + return goto(path, {...options, replaceState: options.replaceState || closesModal(path)}) +} export const modalStack = deriveDeduplicated([modalHash, modals], ([$hash, $modals]) => { return getIdsFromHash($hash)