diff --git a/e2e/desktop/smoke.spec.ts b/e2e/desktop/smoke.spec.ts index cd657feb..c8500593 100644 --- a/e2e/desktop/smoke.spec.ts +++ b/e2e/desktop/smoke.spec.ts @@ -29,7 +29,13 @@ test("the desktop baseline renders, navigates, and keeps external pages outside" page.on("pageerror", error => errors.push(error.message)) await page.reload() - await expect(page.getByRole("heading")).toBeVisible() + const heading = page.getByRole("heading") + + await expect(heading).toBeVisible() + await expect(page).toHaveTitle((await heading.textContent())!.replace(/^Welcome to |!$/g, "")) + expect( + await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme), + ).toBe(await page.locator("body").getAttribute("data-theme")) const origin = await page.evaluate(() => location.origin) expect(origin).toMatch(/^capacitor-electron:\/\//) diff --git a/e2e/harness/ui.ts b/e2e/harness/ui.ts index ed3e1444..3d275d73 100644 --- a/e2e/harness/ui.ts +++ b/e2e/harness/ui.ts @@ -7,17 +7,12 @@ import type {Locator, Page} from "@playwright/test" * locator is shaped the way it is has one copy to keep true. */ -// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the -// panel inside it, so the last match is the panel. export const dialog = (page: Page, title: string) => - page - .locator(".dialog") - .filter({has: page.getByRole("heading", {name: title, exact: true})}) - .last() + page.getByRole("dialog", {name: title, exact: true}) // The modal on top, for one with no heading of its own or one pushed over another rather than // alongside it. -export const topDialog = (page: Page) => page.locator(".dialog").last() +export const topDialog = (page: Page) => page.getByRole("dialog").last() // A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit // are both in the dom at once. Anything said about the form is scoped to the modal's own to say @@ -55,8 +50,8 @@ export const pickEmoji = async (page: Page, opener: Locator, annotation: string) export const pageBar = (page: Page) => page.locator('[data-component="PageBar"]') -// The room's page bar carries a search button and the detail button, in that order. -export const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click() +export const openRoomDetail = (page: Page) => + pageBar(page).getByRole("button", {name: "Room details"}).click() export const roomLink = (page: Page, name: string) => page.locator(".space-menu__scroll").getByRole("link", {name}) @@ -94,13 +89,11 @@ export const messages = (page: Page) => page.locator(".room__item") export const message = (page: Page, text: string) => messages(page).filter({hasText: text}) -// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is -// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu. export const messageActions = (page: Page, text: string) => message(page, text).locator(".room__item-actions button") export const openMessageMenu = (page: Page, text: string) => - messageActions(page, text).last().click() + message(page, text).getByRole("button", {name: "More options"}).click() export const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text}) diff --git a/e2e/specs/content-rendering.spec.ts b/e2e/specs/content-rendering.spec.ts index 249ed869..e6308bb6 100644 --- a/e2e/specs/content-rendering.spec.ts +++ b/e2e/specs/content-rendering.spec.ts @@ -138,8 +138,10 @@ test("US-062 see images and video inline", async ({seed, as}) => { await inlineImage.click() // The lightbox is a fullscreen modal holding nothing but the image. - const lightbox = page.locator(".dialog img") + const preview = page.getByRole("dialog", {name: "Content preview"}) + const lightbox = preview.locator("img") + await expect(preview).toBeVisible() await expect(lightbox).toBeVisible() await page.keyboard.press("Escape") diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts index c31012a4..424f7b71 100644 --- a/e2e/specs/dms.spec.ts +++ b/e2e/specs/dms.spec.ts @@ -511,6 +511,17 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as}) await expect(message(alice, his.id)).toBeVisible() await expect(message(alice, hers.id)).toBeVisible() + const desktopActions = message(bob, his.id).getByRole("button", {name: "Message actions"}) + + await desktopActions.focus() + await expect(desktopActions).toHaveCSS("opacity", "1") + await desktopActions.click() + await expect(bob.getByRole("button", {name: "Add a reaction"})).toBeVisible() + await expect(bob.getByRole("button", {name: "Reply"})).toBeVisible() + await expect(bob.getByRole("button", {name: "Message info"})).toBeVisible() + await bob.keyboard.press("Escape") + await expect(desktopActions).toBeFocused() + // Reply: the preview appears above the composer, and closing it sends nothing await openMessageMenu(alice, his.id) await alice.getByRole("button", {name: "Reply"}).click() diff --git a/e2e/specs/onboarding.spec.ts b/e2e/specs/onboarding.spec.ts index 9b791f89..0174f0d9 100644 --- a/e2e/specs/onboarding.spec.ts +++ b/e2e/specs/onboarding.spec.ts @@ -43,6 +43,12 @@ test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { const page = await visit(roomPath(url, "general")) await expect(gate(page)).toBeVisible() + const signInGate = page.getByRole("dialog", {name: "Welcome to Flotilla!"}) + const logIn = signInGate.getByRole("button", {name: "Log in"}) + + await expect(signInGate).toBeVisible() + await expect(logIn).toBeFocused() + await expect(signInGate.getByRole("button", {name: "Close dialog"})).toHaveCount(0) await expect(page.getByRole("button", {name: "Log in"})).toBeVisible() await expect(page.getByRole("button", {name: "Create an account"})).toBeVisible() await expect(page.getByRole("link", {name: "Terms of Service"})).toHaveAttribute( @@ -58,8 +64,12 @@ test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { await expect(gate(page)).toBeVisible() - // The backdrop, clicked in its top left corner so the dialog itself isn't what gets hit. - await page.getByRole("button", {name: "Close dialog"}).click({position: {x: 4, y: 4}}) + for (const key of ["Tab", "Tab", "Shift+Tab", "Shift+Tab"]) { + await page.keyboard.press(key) + expect(await signInGate.evaluate(dialog => dialog.contains(document.activeElement))).toBe(true) + } + + await page.locator(".dialog-overlay > button").click({position: {x: 4, y: 4}}) await expect(gate(page)).toBeVisible() diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts index f84c798c..ac6ede0e 100644 --- a/e2e/specs/people.spec.ts +++ b/e2e/specs/people.spec.ts @@ -608,17 +608,25 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => { await preview.click() - await expect(topDialog(page).getByText("Bob Barnacle")).toBeVisible() - await expect(topDialog(page).locator(`img[src="${avatar}"]`)).toBeVisible() - await expect(topDialog(page).getByText("Deckhand, dockside cook")).toBeVisible() - await expect(topDialog(page).getByText("Scrubbing the decks")).toBeVisible() - await expect(topDialog(page).getByText(/Last active/)).toBeVisible() + const profile = page.getByRole("dialog", {name: "Profile details"}) + + await expect(profile.getByText("Bob Barnacle")).toBeVisible() + await expect(profile.locator(`img[src="${avatar}"]`)).toBeVisible() + await expect(profile.getByText("Deckhand, dockside cook")).toBeVisible() + await expect(profile.getByText("Scrubbing the decks")).toBeVisible() + await expect(profile.getByText(/Last active/)).toBeVisible() + await expect(profile.getByRole("button", {name: "Close dialog"})).toBeVisible() + + for (const key of ["Tab", "Shift+Tab"]) { + await page.keyboard.press(key) + expect(await profile.evaluate(dialog => dialog.contains(document.activeElement))).toBe(true) + } await page.keyboard.press("Escape") await expect(page.locator(".dialog")).toHaveCount(0) await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`)) - await expect(preview).toBeVisible() + await expect(preview).toBeFocused() await preview.click() diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index 16be753b..5884427e 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -35,13 +35,10 @@ const jumpToNewest = (page: Page) => page.getByRole("button", {name: "Jump to ne // left corner of the viewport is outside it. const dismissMenu = (page: Page) => page.mouse.move(0, 0) -// RoomDetail has no title of its own — the room's name stands in for one — so it is named by the -// permissions card only it renders. Scoped to the modal's body rather than the dialog, so that the -// dialog's own close button isn't the first button in it. -const roomDetail = (page: Page) => - page.locator(".scroll-container").filter({hasText: "Room Permissions"}) +const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details"}) -const openRoomDetailMenu = (page: Page) => roomDetail(page).getByRole("button").first().click() +const openRoomDetailMenu = (page: Page) => + roomDetail(page).getByRole("button", {name: "Room options"}).click() // The space menu's sections are flat siblings — a header, then the rooms under it — so which // section a room is in is a question about document order rather than nesting. @@ -225,6 +222,33 @@ test("US-019 join and leave a room", async ({seed, as}) => { await expect.poll(() => roomSection(bob, "General")).toBe("Rooms") }) +test("US-122 long room names keep header and dialog actions available", async ({seed, as}) => { + const name = "A very long room name for coordinating every ship arriving in the harbor today" + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("harbor", {name}) + space.join(user.alice, "harbor") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "harbor"), { + context: {viewport: {width: 800, height: 600}}, + }) + const header = pageBar(page) + + await expect(header.getByRole("button", {name: "Search"})).toBeVisible() + await expect(header.getByRole("button", {name: "Room details"})).toBeVisible() + expect(await header.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + + await openRoomDetail(page) + + const detail = roomDetail(page) + + await expect(detail.getByRole("button", {name: "Room options"})).toBeVisible() + expect(await detail.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) +}) + test("US-020 create, edit, and delete a room", async ({seed, as}) => { const scenario = await seed(({relay, user}) => { const space = relay("space") @@ -564,6 +588,25 @@ test("US-024 edit or delete a message you sent", async ({seed, as}) => { // Her own message offers zap, emoji, reply, edit and a menu; his offers no edit... await expect(messageActions(alice, "we sail at dwan")).toHaveCount(5) await expect(messageActions(alice, "spelling?")).toHaveCount(4) + await expect(message(alice, "we sail at dwan").locator("button button")).toHaveCount(0) + + const moreOptions = message(alice, "we sail at dwan").getByRole("button", { + name: "More options", + }) + + await moreOptions.focus() + await expect(message(alice, "we sail at dwan").locator(".room__item-actions")).toHaveCSS( + "opacity", + "1", + ) + await moreOptions.press("Enter") + + const messageDetails = alice.getByRole("button", {name: "Message Details"}) + + await messageDetails.focus() + await expect(messageDetails).toBeFocused() + await alice.keyboard.press("Escape") + await expect(moreOptions).toBeFocused() // ...and no delete either await openMessageMenu(alice, "spelling?") @@ -630,8 +673,22 @@ test("US-025 react to a message", async ({seed, as}) => { await expect(message(carol, "we made port")).toBeVisible() await expect(message(bob, "we made port")).toBeVisible() - // The quick reaction button is the second of RoomItem's hover actions - await react(carol, messageActions(carol, "we made port").nth(1)) + const reaction = message(carol, "we made port").getByRole("button", {name: "Add a reaction"}) + + await reaction.focus() + await reaction.press("Enter") + + const picker = carol.locator("emoji-picker").filter({visible: true}) + const search = picker.locator("input.search") + + await expect(picker).toBeVisible() + await search.focus() + await expect(search).toBeFocused() + await carol.keyboard.press("Escape") + await expect(picker).toHaveCount(0) + await expect(reaction).toBeFocused() + + await react(carol, reaction) await expect(reactionPill(carol, "we made port")).toBeVisible() await expect(reactionPill(bob, "we made port")).toBeVisible() @@ -743,6 +800,14 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => { const term = page.getByPlaceholder("Search this room...") const search = dialog(page, "Search") + await expect(term).toBeFocused() + + for (const key of ["Tab", "Tab", "Shift+Tab"]) { + await page.keyboard.press(key) + expect(await search.evaluate(dialog => dialog.contains(document.activeElement))).toBe(true) + } + + await term.focus() await term.fill("harbor") await expect(search.getByText("Last 24 Hours")).toBeVisible() @@ -979,6 +1044,7 @@ test("US-115 connect a wallet without losing the zap you were composing", async const connect = dialog(page, "Connect a Wallet") + await expect(page.locator(".dialog-overlay[inert]")).toHaveCount(1) await connect.getByRole("button", {name: "Connect with WebLN"}).click() await expect(page.getByRole("alert")).toContainText("Wallet successfully connected!") diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts index 279b232a..9fb833c5 100644 --- a/e2e/specs/settings.spec.ts +++ b/e2e/specs/settings.spec.ts @@ -262,21 +262,27 @@ test("US-090 change the app's appearance", async ({seed, as}) => { const page = await as(users.alice, "/settings/theme", {context: {colorScheme: "light"}}) const body = page.locator("body") + const colorScheme = () => + page.evaluate(() => getComputedStyle(document.documentElement).colorScheme) await expect(body).toHaveAttribute("data-theme", "light") + await expect.poll(colorScheme).toBe("light") await page.getByRole("button", {name: "Dark", exact: true}).click() await expect(body).toHaveAttribute("data-theme", "dark") + await expect.poll(colorScheme).toBe("dark") await page.getByRole("button", {name: "System", exact: true}).click() await expect(body).toHaveAttribute("data-theme", "light") + await expect.poll(colorScheme).toBe("light") // System means the device's, so changing the device's changes the app's. await page.emulateMedia({colorScheme: "dark"}) await expect(body).toHaveAttribute("data-theme", "dark") + await expect.poll(colorScheme).toBe("dark") await page.getByLabel("Style").selectOption("navy") diff --git a/package.json b/package.json index 8c0c0139..75013324 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "dotenv": "^16.6.1", "emoji-picker-element": "^1.28.1", "emoji-picker-element-data": "^1.8.0", + "focus-trap": "8.2.2", "fuse.js": "^7.1.0", "hono": "^4.12.23", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da4369ca..95426fa7 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/src/app/components/AppContainer.svelte b/src/app/components/AppContainer.svelte index a90a8ae1..c4521585 100644 --- a/src/app/components/AppContainer.svelte +++ b/src/app/components/AppContainer.svelte @@ -14,9 +14,15 @@ } const {children}: Props = $props() + + const blockingModal = $derived.by(() => { + const modal = getModal() + + return Boolean(modal && !modal.options.drawer) + }) -
+
{#if $app.user?.pubkey} diff --git a/src/app/components/ChatMessage.svelte b/src/app/components/ChatMessage.svelte index 7e5e481b..f1f4174c 100644 --- a/src/app/components/ChatMessage.svelte +++ b/src/app/components/ChatMessage.svelte @@ -70,7 +70,8 @@ params={{interactive: true, trigger: "manual"}}> {/if} {#if edit} - {/if} -
diff --git a/src/app/components/ChatMessageMenuMobile.svelte b/src/app/components/ChatMessageMenuMobile.svelte index 38e31877..1dc3a7b9 100644 --- a/src/app/components/ChatMessageMenuMobile.svelte +++ b/src/app/components/ChatMessageMenuMobile.svelte @@ -64,7 +64,7 @@ let showMore = $state(false) - +
, which diff --git a/src/app/components/MenuSettings.svelte b/src/app/components/MenuSettings.svelte index 7ab03c5b..36e75e35 100644 --- a/src/app/components/MenuSettings.svelte +++ b/src/app/components/MenuSettings.svelte @@ -20,7 +20,7 @@ const logout = () => pushModal(LogOut) - +
diff --git a/src/app/components/ModalContainer.svelte b/src/app/components/ModalContainer.svelte index e6110af2..b16a150f 100644 --- a/src/app/components/ModalContainer.svelte +++ b/src/app/components/ModalContainer.svelte @@ -1,4 +1,5 @@ - +
diff --git a/src/app/components/ProfileEditForm.svelte b/src/app/components/ProfileEditForm.svelte index 09117a42..3d49db38 100644 --- a/src/app/components/ProfileEditForm.svelte +++ b/src/app/components/ProfileEditForm.svelte @@ -48,7 +48,10 @@ let file: File | undefined = $state() - + {#if isSignup}
diff --git a/src/app/components/ProfileQrCode.svelte b/src/app/components/ProfileQrCode.svelte index 63a504af..2b65fba7 100644 --- a/src/app/components/ProfileQrCode.svelte +++ b/src/app/components/ProfileQrCode.svelte @@ -48,7 +48,7 @@ }) - +
Share Profile diff --git a/src/app/components/RelayAdd.svelte b/src/app/components/RelayAdd.svelte index 4a13d9de..b2cfb6f8 100644 --- a/src/app/components/RelayAdd.svelte +++ b/src/app/components/RelayAdd.svelte @@ -95,7 +95,7 @@ }) - +