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"}}>