From 5ca8eeff516605ed546792d9ba5f6c319a0a8475 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Thu, 17 Sep 2026 18:26:01 +0000 Subject: [PATCH] Scroll the space rail instead of hiding spaces behind an overflow menu --- e2e/USER_STORIES.md | 27 +++-- e2e/specs/notifications.spec.ts | 15 +-- e2e/specs/spaces.spec.ts | 95 ++++++++---------- src/app/components/PrimaryNavSpaces.svelte | 99 +++++++++---------- .../PrimaryNavSpacesOverflow.svelte | 16 --- src/app/components/SpaceMenuDrawer.svelte | 4 +- src/lib/components/primary-nav.css | 35 +++++++ src/routes/home/+page.svelte | 14 ++- src/routes/spaces/+page.svelte | 46 +-------- 9 files changed, 156 insertions(+), 195 deletions(-) delete mode 100644 src/app/components/PrimaryNavSpacesOverflow.svelte diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 3c32ed7e..7c1a537f 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -141,20 +141,20 @@ Acceptance: ## Spaces -### US-009 — Browse, search, and reorder your spaces +### US-009 — Browse and search spaces, and reorder your own -As alice, I want to see the spaces I've joined and find new ones, so that I can -get where I'm going and discover communities. +As alice, I want to find new spaces and keep the ones I'm in in the order I +want, so that I can discover communities and get where I'm going. Acceptance: -- `/spaces` shows a "Your spaces" section listing every space alice has joined - and a "Browse Spaces" section of the rest. -- Typing a term filters both sections live, matching name, url, or description. -- Clicking a joined space opens it; clicking one she hasn't joined opens a join - prompt instead. -- Dragging a joined space above another, on the page or in the sidebar rail, - reorders the list immediately, and the order survives a reload. +- `/spaces` lists the spaces alice hasn't joined, under "Browse Spaces". The + ones she has are in the sidebar rail, all of them, and the rail scrolls. +- Typing a term filters the list live, matching name, url, or description. +- Clicking a space she hasn't joined opens a join prompt; a space in the rail + opens. +- Dragging a space above another in the rail reorders the list immediately, and + the order survives a reload. ### US-010 — Join a space from an invite link @@ -1564,10 +1564,9 @@ check first. Acceptance: - After alice posts in a room bob hasn't opened, an unread dot appears on that - room and on its space in his sidebar, and on the space's row in `/spaces`, - without a reload. -- Opening the room clears its dot, and the dot stays cleared when he returns to - the space list. + room and on its space in his sidebar, without a reload. +- Opening the room clears its dot, and the dot stays cleared when he leaves the + space. ### US-104 — Mute a room or a whole space diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 00fbe4f8..e7dac0e5 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -135,23 +135,19 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => { const bob = await as(users.bob, "/spaces") const alice = await as(users.alice, roomPath(space.url, "general")) - const spaceRow = bob.getByRole("listitem").filter({hasText: space.url}) const navItem = spaceNavItem(bob, space.name) - await expect(spaceRow).toBeVisible() await expect(navItem).toBeVisible() - await expect(unreadDot(spaceRow)).toHaveCount(0) await expect(unreadDot(navItem)).toHaveCount(0) await post(alice, "the server is on fire") - // Bob is sitting on the space list the whole time, so both dots arrive without a navigation - await expect(unreadDot(spaceRow)).toBeVisible() + // Bob is sitting on the spaces page the whole time, so the dot arrives without a navigation await expect(unreadDot(navItem)).toBeVisible() // Inside the space, the dot points at the room the message landed in. A space's room list only - // exists in its own menu, so this is the one indicator the list above can't show. - await spaceRow.click() + // exists in its own menu, so this is the one indicator the rail can't show. + await navItem.click() const general = roomLink(bob, "General") const random = roomLink(bob, "Random") @@ -172,11 +168,10 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => { await expect(general).toBeVisible() await expect(unreadDot(general)).toHaveCount(0) - // ...and it stays cleared back on the space list he started from + // ...and it stays cleared back on the page he started from await bob.locator('.primary-nav a[href="/spaces"]').click() - await expect(spaceRow).toBeVisible() - await expect(unreadDot(spaceRow)).toHaveCount(0) + await expect(navItem).toBeVisible() await expect(unreadDot(navItem)).toHaveCount(0) }) diff --git a/e2e/specs/spaces.spec.ts b/e2e/specs/spaces.spec.ts index ae9a173d..0552e2eb 100644 --- a/e2e/specs/spaces.spec.ts +++ b/e2e/specs/spaces.spec.ts @@ -1,6 +1,7 @@ import {DAY, HOUR, WEEK, sortBy} from "@welshman/lib" import {ROOMS} from "@welshman/util" import type {Page} from "@playwright/test" +import type {SeededSpace} from "../harness" import {expect, readCachedEvents, roomPath, spacePath, test, users} from "../harness" // The space this user's room list names first, read from the copy on disk the app restores itself @@ -13,17 +14,27 @@ const cachedFirstSpace = async (page: Page, pubkey: string) => { return newest?.tags.find(tag => tag[0] === "r")?.[1] } +// The rail shows icons and no text, so a row is read by the tooltip naming its relay. See +// spaceNavItem in notifications.spec.ts for why that is a tooltip rather than an accessible name. +const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]") + +const expectRailFirst = (page: Page, name: string) => + expect(railSpaces(page).first().locator("[data-tip]")).toHaveAttribute( + "data-tip", + new RegExp(`^${name}`), + ) + // A drop reorders the list in place and publishes a new room list behind it, so the order on screen // is ahead of the one the app has settled on. Reading the next drag off that optimistic order is // what made this spec fail under a full suite and pass alone: the relay is slower when the box is // busy, and a room list landing back from it after the next drop replaces the order that drop // applied. Waiting for the copy on disk to name the new first space is waiting for the round trip. -const expectReordered = async (page: Page, pubkey: string, url: string) => { - await expect(page.getByRole("listitem").first()).toContainText(url) - await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(url) +const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => { + await expectRailFirst(page, space.name) + await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(space.url) } -test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => { +test("US-009 browse and search spaces, and reorder your own", async ({seed, as}) => { const scenario = await seed(({relay, user}) => { const space = relay("space") const other = relay("other") @@ -50,76 +61,56 @@ test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => { // bootstraps from, so pointing that at bob is what puts his other space in front of alice. const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}}) - await expect(page.getByText("Your spaces")).toBeVisible() - await expect(page.getByText(space.url)).toBeVisible() - await expect(page.getByText(other.url)).toBeVisible() - - // ...and the rest of them in a section of their own + // The page is for spaces she hasn't joined. The ones she has are in the rail, all of them. await expect(page.getByText("Browse Spaces")).toBeVisible() await expect(page.getByText(unsigned.url)).toBeVisible() + await expect(page.getByText(space.url)).toHaveCount(0) + await expect(page.getByText(other.url)).toHaveCount(0) + await expect(railSpaces(page)).toHaveCount(2) const term = page.getByPlaceholder("Search for spaces...") await term.fill("unsigned") await expect(page.getByText(unsigned.url)).toBeVisible() - await expect(page.getByText(space.url)).toHaveCount(0) - await expect(page.getByText(other.url)).toHaveCount(0) + + await term.fill("nothing by that name") + + await expect(page.getByText(unsigned.url)).toHaveCount(0) await term.fill("") - await expect(page.getByText(space.url)).toBeVisible() - - // A space she's joined opens - await page.getByRole("listitem").filter({hasText: space.url}).click() - - await expect(page).toHaveURL(/\/spaces\/space\.test\//) - - // A space she hasn't asks her to join first - await page.goto("/spaces") + // A space she hasn't joined asks her to join first await page.getByRole("button").filter({hasText: unsigned.url}).click() await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible() await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled() + // A space she has joined opens from the rail + await page.goto("/spaces") + await railSpaces(page).first().click() + + await expect(page).toHaveURL(/\/spaces\/space\.test\//) + // Reordering by dragging, which lives in her room list and so outlives the page await page.goto("/spaces") - const joined = page.getByRole("listitem") - - await expect(joined.first()).toContainText(space.url) + await expectRailFirst(page, space.name) // Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag // starts the drag and moves it, but never delivers the drop the reorder is committed in, so the // row would snap back to where it came from. const dataTransfer = await page.evaluateHandle(() => new DataTransfer()) - - // The sidebar rail is the same list and reorders the same way. It shows icons and no text, so - // its rows are named by position and the list on the page is where the result is read. - const rail = page.locator(".primary-nav [draggable=true]") + const rail = railSpaces(page) await rail.nth(0).dispatchEvent("dragstart", {dataTransfer}) await rail.nth(1).dispatchEvent("drop", {dataTransfer}) - await expectReordered(page, users.alice.pubkey, other.url) - - await rail.nth(1).dispatchEvent("dragstart", {dataTransfer}) - await rail.nth(0).dispatchEvent("drop", {dataTransfer}) - - await expectReordered(page, users.alice.pubkey, space.url) - - // Dragging in the page's own list moves the same room list. - const source = joined.filter({hasText: other.url}) - const target = joined.filter({hasText: space.url}) - - await source.dispatchEvent("dragstart", {dataTransfer}) - await target.dispatchEvent("drop", {dataTransfer}) - - await expectReordered(page, users.alice.pubkey, other.url) + await expectReordered(page, users.alice.pubkey, other) await page.reload() - await expect(page.getByRole("listitem").first()).toContainText(other.url) + await expectRailFirst(page, other.name) }) test("US-010 join a space from an invite link", async ({seed, as}) => { @@ -268,7 +259,7 @@ test("US-011 request access when a space turns you away", async ({seed, as}) => await bob.goto("/spaces") - await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible() + await expect(railSpaces(bob)).toHaveCount(0) }) test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => { @@ -296,7 +287,7 @@ test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => { // page that reloads before it reaches disk reads the list he had a moment ago. await bob.getByRole("link", {name: "All Spaces"}).click() - await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible() + await expect(railSpaces(bob)).toHaveCount(0) const alice = await as(users.alice, roomPath(unsigned.url, "general")) @@ -343,10 +334,8 @@ test("US-013 follow a space that has moved", async ({seed, as}) => { // that reloads before it reaches disk reads the old address back. await alice.getByRole("link", {name: "All Spaces"}).click() - const aliceSpaces = alice.getByRole("listitem") - - await expect(aliceSpaces).toHaveCount(1) - await expect(aliceSpaces.first()).toContainText(other.url) + await expect(railSpaces(alice)).toHaveCount(1) + await expectRailFirst(alice, other.name) const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo}) @@ -358,10 +347,8 @@ test("US-013 follow a space that has moved", async ({seed, as}) => { await bob.goto("/spaces") - const bobSpaces = bob.getByRole("listitem") - - await expect(bobSpaces).toHaveCount(1) - await expect(bobSpaces.first()).toContainText(space.url) + await expect(railSpaces(bob)).toHaveCount(1) + await expectRailFirst(bob, space.name) }) test("US-014 leave a space", async ({seed, as}) => { @@ -389,7 +376,7 @@ test("US-014 leave a space", async ({seed, as}) => { // reloads before it reaches disk reads the list he had a moment ago. await page.getByRole("link", {name: "All Spaces"}).click() - await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible() + await expect(railSpaces(page)).toHaveCount(0) // Nothing stops him coming back await page.getByRole("button", {name: "Add Space"}).click() diff --git a/src/app/components/PrimaryNavSpaces.svelte b/src/app/components/PrimaryNavSpaces.svelte index 4daa17e8..ca35c348 100644 --- a/src/app/components/PrimaryNavSpaces.svelte +++ b/src/app/components/PrimaryNavSpaces.svelte @@ -1,47 +1,45 @@ - - -
+
{#each PLATFORM_RELAYS as url (url)} {:else} @@ -49,32 +47,23 @@ - - {#snippet item(url)} - - {/snippet} - - {#snippet allSpaces(title: string)} - - - - {/snippet} - {#if secondarySpaceUrls.length > 0} - - {@render allSpaces("")} - - {:else} - {@render allSpaces("All Spaces")} - {/if} +
+ + {#snippet item(url)} + + {/snippet} + +
+ + + {/each}
diff --git a/src/app/components/PrimaryNavSpacesOverflow.svelte b/src/app/components/PrimaryNavSpacesOverflow.svelte deleted file mode 100644 index c434b866..00000000 --- a/src/app/components/PrimaryNavSpacesOverflow.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
- {#each urls as url (url)} - - {/each} -
diff --git a/src/app/components/SpaceMenuDrawer.svelte b/src/app/components/SpaceMenuDrawer.svelte index 2fd1ef14..518362ca 100644 --- a/src/app/components/SpaceMenuDrawer.svelte +++ b/src/app/components/SpaceMenuDrawer.svelte @@ -28,10 +28,10 @@
- +
diff --git a/src/lib/components/primary-nav.css b/src/lib/components/primary-nav.css index 811669ce..0863200d 100644 --- a/src/lib/components/primary-nav.css +++ b/src/lib/components/primary-nav.css @@ -7,6 +7,41 @@ @apply hidden; } + .primary-nav__spaces { + @apply flex min-h-0 w-full flex-1 flex-col items-center self-start overflow-y-auto; + scrollbar-width: none; + -ms-overflow-style: none; + + /* Fades whichever end of the list has spaces past it */ + mask-image: linear-gradient( + to bottom, + transparent 0, + black var(--fade-top, 0px), + black calc(100% - var(--fade-bottom, 0px)), + transparent 100% + ); + + &::-webkit-scrollbar { + display: none; + } + } + + /* Hover tooltips reach past the rail, and a scroll container clips whatever leaves it. The extra + width is padding, so it holds them without moving the items, and passes its pointer events to + the page underneath. Tooltips are a desktop affair, so the rail in the mobile drawer keeps the + width it draws in. */ + @media (min-width: 768px) { + .primary-nav__spaces { + width: calc(100% + 12rem); + padding-right: 12rem; + pointer-events: none; + + & > * { + pointer-events: auto; + } + } + } + .primary-nav__nav-item { @apply text-content relative my-1 flex h-12 w-12 cursor-pointer items-center justify-center rounded-full transition-all; } diff --git a/src/routes/home/+page.svelte b/src/routes/home/+page.svelte index 01b60221..852d1c85 100644 --- a/src/routes/home/+page.svelte +++ b/src/routes/home/+page.svelte @@ -1,7 +1,9 @@