Fix desktop dialog focus and room layout overflow (#517)

This commit is contained in:
Gaurav Chaudhary 2026-09-12 17:03:24 +00:00 committed by hodlbod
parent a2c4dcec44
commit 0460c94395
50 changed files with 417 additions and 114 deletions

View file

@ -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:\/\//)

View file

@ -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})

View file

@ -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")

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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!")

View file

@ -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")

View file

@ -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",

Binary file not shown.

View file

@ -14,9 +14,15 @@
}
const {children}: Props = $props()
const blockingModal = $derived.by(() => {
const modal = getModal()
return Boolean(modal && !modal.options.drawer)
})
</script>
<div class="flex h-screen flex-col overflow-hidden">
<div class="flex h-screen flex-col overflow-hidden" inert={blockingModal}>
<div class="flex min-h-0 flex-1 overflow-hidden">
{#if $app.user?.pubkey}
<PrimaryNav>

View file

@ -70,7 +70,8 @@
params={{interactive: true, trigger: "manual"}}>
<button
type="button"
class="opacity-0 transition-all"
aria-label="Message actions"
class="opacity-0 transition-all focus:opacity-100"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon={MenuDots} size={4} />

View file

@ -20,6 +20,9 @@
}
</script>
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
<EmojiButton
aria-label="Add a reaction"
{onEmoji}
class="button button-neutral button-xs join-item">
<Icon icon={SmileCircle} size={4} />
</EmojiButton>

View file

@ -22,16 +22,19 @@
<div class="join text-xs">
<ChatMessageEmojiButton {event} {pubkeys} />
{#if replyTo}
<Button class="button button-neutral button-xs join-item" onclick={reply}>
<Button aria-label="Reply" class="button button-neutral button-xs join-item" onclick={reply}>
<Icon size={4} icon={Reply} />
</Button>
{/if}
{#if edit}
<Button class="button button-neutral button-xs join-item" onclick={onEdit}>
<Button aria-label="Edit" class="button button-neutral button-xs join-item" onclick={onEdit}>
<Icon size={4} icon={Pen} />
</Button>
{/if}
<Button class="button button-neutral button-xs join-item" onclick={showInfo}>
<Button
aria-label="Message info"
class="button button-neutral button-xs join-item"
onclick={showInfo}>
<Icon size={4} icon={Code2} />
</Button>
</div>

View file

@ -64,7 +64,7 @@
let showMore = $state(false)
</script>
<Modal>
<Modal label="Message actions">
<ModalBody>
<div class="grid grid-cols-2 gap-2">
<Button class="{tile} button-outline button-primary" onclick={showEmojiPicker}>

View file

@ -69,7 +69,8 @@
hideImage = true
}
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
const expand = () =>
pushModal(ContentLinkDetail, {value, event}, {fullscreen: true, label: "Content preview"})
</script>
{#if isRoomOrRelay}

View file

@ -13,7 +13,8 @@
const url = value.url.toString()
const fileType = getUrlContentType(url, event)
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
const expand = () =>
pushModal(ContentLinkDetail, {value, event}, {fullscreen: true, label: "Content preview"})
</script>
{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}

View file

@ -10,7 +10,7 @@
const {onSelect}: Props = $props()
</script>
<Modal>
<Modal label="Choose an icon">
<ModalBody>
<IconPicker {onSelect} />
</ModalBody>

View file

@ -6,6 +6,7 @@
import Link from "@lib/components/Link.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.svelte"
import SignUp from "@app/components/SignUp.svelte"
@ -26,7 +27,7 @@
<Modal>
<ModalBody>
<div class="py-2">
<h1 class="heading">Welcome to {PLATFORM_NAME}!</h1>
<ModalTitle>Welcome to {PLATFORM_NAME}!</ModalTitle>
<p class="text-center">{PLATFORM_DESCRIPTION}</p>
</div>
<Button aria-label="Log in" onclick={logIn}>

View file

@ -14,6 +14,7 @@
import Link from "@lib/components/Link.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SignUp from "@app/components/SignUp.svelte"
@ -98,7 +99,7 @@
<Modal data-testid="login">
<ModalBody>
<h1 class="heading">Log in with Nostr</h1>
<ModalTitle>Log in with Nostr</ModalTitle>
<p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which

View file

@ -20,7 +20,7 @@
const logout = () => pushModal(LogOut)
</script>
<Modal>
<Modal label="Settings">
<ModalBody>
<div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
<Link href={makeProfilePath($user.pubkey)}>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import type {Component, ComponentProps} from "svelte"
import {mount, unmount, untrack} from "svelte"
import Drawer from "@lib/components/Drawer.svelte"
import Dialog from "@lib/components/Dialog.svelte"
@ -16,6 +17,7 @@
const target = event.target
if (
!event.defaultPrevented &&
event.code === "Escape" &&
target instanceof Element &&
!target.closest("input, textarea, [contenteditable]")
@ -25,16 +27,22 @@
}
let element: HTMLElement
const instances: Record<string, any> = {}
type WrapperProps = ComponentProps<typeof Dialog>
const instances: Record<string, {instance: ReturnType<typeof mount>; props: WrapperProps}> = {}
$effect(() => {
const stack = getModalStack()
untrack(() => {
const ids = stack.map(({id}) => id)
const activeId = ids.at(-1)
for (const [id, {instance, props}] of Object.entries(instances)) {
props.active = id === activeId
for (const [id, instance] of Object.entries(instances)) {
if (!ids.includes(id)) {
props.restoreFocus = !activeId || Boolean(instances[activeId])
unmount(instance, {outro: true})
delete instances[id]
}
@ -47,17 +55,24 @@
const {options, component, props} = item
const wrapper = options.drawer ? Drawer : Dialog
instances[item.id] = mount(wrapper as any, {
target: element,
props: {
onClose: closeModal,
size: options.size,
noEscape: options.noEscape,
fullscreen: options.fullscreen,
children: {component, props},
},
const wrapperProps = $state({
active: item.id === activeId,
onClose: closeModal,
label: options.label,
restoreFocus: true,
size: options.size,
noEscape: options.noEscape,
fullscreen: options.fullscreen,
children: {component, props},
})
instances[item.id] = {
instance: mount(wrapper as Component, {
target: element,
props: wrapperProps,
}),
props: wrapperProps,
}
}
})
})

View file

@ -22,7 +22,7 @@
const viewEvent = () => goToEvent($latest!)
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url}, {label: "Profile spaces"})
onMount(() => {
$network.loadUsingOutbox(pubkey, {limit: 1, kinds: [NOTE, COMMENT, MESSAGE]})

View file

@ -95,7 +95,7 @@
})
</script>
<Modal>
<Modal label="Profile details">
<ModalBody>
<div class="flex flex-col gap-4">
<div class="flex justify-between">

View file

@ -48,7 +48,10 @@
let file: File | undefined = $state()
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<Modal
label={isSignup ? "Create a profile" : "Edit profile"}
tag="form"
onsubmit={preventDefault(submit)}>
<ModalBody>
{#if isSignup}
<div class="grid grid-cols-2">

View file

@ -48,7 +48,7 @@
})
</script>
<Modal>
<Modal label="Share profile">
<ModalBody>
<div class="flex flex-col items-center gap-4 text-center">
<strong>Share Profile</strong>

View file

@ -95,7 +95,7 @@
})
</script>
<Modal>
<Modal label="Add relays">
<ModalBody>
<label class="input input-group flex w-full items-center gap-2">
<Icon icon={Magnifier} />

View file

@ -13,6 +13,7 @@
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import RelayAdd from "@app/components/RelayAdd.svelte"
import RelayItem from "@app/components/RelayItem.svelte"
import {pushModal} from "@app/modal"
@ -56,7 +57,7 @@
<Modal>
<ModalBody>
<h2 class="text-xl">{title}</h2>
<ModalTitle class="text-xl">{title}</ModalTitle>
<p class="text-sm">{subtitle}</p>
{#each $relays.toSorted() as url (url)}
<RelayItem {url}>

View file

@ -74,19 +74,23 @@
}
</script>
<Modal>
<Modal label="Room details">
<ModalBody>
<div class="flex justify-between">
<div class="flex gap-3">
<div class="pt-0.5">
<div class="flex min-w-0 justify-between gap-3">
<div class="flex min-w-0 gap-3">
<div class="shrink-0 pt-0.5">
<RoomImage {url} {h} size={8} />
</div>
<div class="flex min-w-0 flex-col">
<RoomName {url} {h} class="text-2xl" />
<span class="text-primary">{displayRelayUrl(url)}</span>
<RoomName {url} {h} class="block text-2xl" />
<span class="truncate text-primary">{displayRelayUrl(url)}</span>
</div>
</div>
<MenuButton component={RoomDetailMenu} componentProps={{url, h}} />
<MenuButton
class="shrink-0"
aria-label="Room options"
component={RoomDetailMenu}
componentProps={{url, h}} />
</div>
{#if meta?.about()}
<p>{meta.about()}</p>

View file

@ -187,24 +187,26 @@
{/if}
</div>
{#if !isMobile}
<button
class="room__item-actions join absolute right-2 top-0.5 opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<div
class="room__item-actions join absolute right-2 top-0.5 opacity-0 transition-all focus-within:opacity-100 group-hover:opacity-100">
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
{/if}
<RoomItemEmojiButton {url} {event} />
{#if replyTo}
<Button class="button button-xs button-neutral join-item" onclick={reply}>
<Button
aria-label="Reply"
class="button button-xs button-neutral join-item"
onclick={reply}>
<Icon icon={Reply} size={4} />
</Button>
{/if}
{#if edit}
<Button class="button button-xs button-neutral join-item" onclick={edit}>
<Button aria-label="Edit" class="button button-xs button-neutral join-item" onclick={edit}>
<Icon icon={Pen} size={4} />
</Button>
{/if}
<RoomItemMenuButton {url} {event} />
</button>
</div>
{/if}
</TapTarget>

View file

@ -33,6 +33,7 @@
</script>
<EmojiButton
aria-label="Add a reaction"
{onEmoji}
class="button button-xs button-neutral join-item"
tippyParams={{placement: "bottom-end"}}>

View file

@ -31,7 +31,7 @@
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
<Button onclick={open} class="button button-xs button-neutral join-item">
<Button aria-label="More options" onclick={open} class="button button-xs button-neutral join-item">
<Tippy
bind:controller={tippy}
component={RoomItemMenu}

View file

@ -124,7 +124,7 @@
let showMore = $state(false)
</script>
<Modal>
<Modal label="Message actions">
<ModalBody>
<div class="grid gap-2 {ENABLE_ZAPS ? 'grid-cols-3' : 'grid-cols-2'}">
{#if ENABLE_ZAPS}

View file

@ -6,6 +6,6 @@
const {url, event} = $props()
</script>
<ZapButton {url} {event} class="button button-xs button-neutral join-item">
<ZapButton aria-label="Send a zap" {url} {event} class="button button-xs button-neutral join-item">
<Icon icon={Bolt} size={4} />
</ZapButton>

View file

@ -11,6 +11,7 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import LogIn from "@app/components/LogIn.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpEmail from "@app/components/SignUpEmail.svelte"
@ -105,7 +106,7 @@
<Modal>
<ModalBody>
<h1 class="heading">Join {PLATFORM_NAME}</h1>
<ModalTitle>Join {PLATFORM_NAME}</ModalTitle>
<p class="m-auto max-w-sm text-center">
Censorship resistant digital spaces for communities. Meet new people, own your identity.
</p>

View file

@ -22,14 +22,14 @@
</script>
<PageBar {...props}>
<div class="flex">
<div class="flex min-w-0">
{#if back}
<Button onclick={back} aria-label="Go back" class="place-self-start pr-3 md:hidden">
<Icon icon={ArrowLeft} size={7} />
</Button>
{/if}
<div class="flex grow items-center justify-between gap-4">
<div class="flex min-w-0 flex-col">
<div class="flex min-w-0 grow items-center justify-between gap-4">
<div class="flex min-w-0 grow flex-col">
<div class="flex min-w-0 items-start gap-2">
<div class="hidden shrink-0 md:flex md:items-center place-self-center">
{@render leading?.()}
@ -38,7 +38,7 @@
{@render title?.()}
</div>
</div>
<div class="text-xs text-primary md:hidden">
<div class="truncate text-xs text-primary md:hidden">
{displayRelayUrl(url)}
</div>
</div>

View file

@ -59,7 +59,7 @@
})
</script>
<Modal tag="form" onsubmit={preventDefault(join)}>
<Modal label="Join space" tag="form" onsubmit={preventDefault(join)}>
<ModalBody>
<RelaySummary {url} />
<SpaceJoinNotifications bind:notifications />

View file

@ -10,7 +10,7 @@
const {url}: Props = $props()
</script>
<Modal>
<Modal label="Space menu">
<ModalBody>
<div class="flex flex-col gap-2">
<SpaceMenuActionsMobile {url} />

View file

@ -6,6 +6,7 @@ import type {DialogSize} from "@lib/components/Dialog.svelte"
export type ModalOptions = {
drawer?: boolean
label?: string
nested?: boolean
noEscape?: boolean
fullscreen?: boolean

View file

@ -8,7 +8,6 @@ import {PLATFORM_NAME} from "@app/env"
import {decodeRelay} from "@app/relays"
const staticTitles = new Map<string, string>([
["/", "Redirecting"],
["/home", "Home"],
["/spaces", "Spaces"],
["/spaces/create", "Create a Space"],

View file

@ -2,28 +2,38 @@
export type DialogSize = "default" | "large"
</script>
<script lang="ts">
<script lang="ts" generics="ChildrenProps extends Record<string, unknown>">
import type {Component} from "svelte"
import {onMount, setContext} from "svelte"
import cx from "classnames"
import {createFocusTrap} from "focus-trap"
import type {FocusTrap} from "focus-trap"
import {noop} from "@welshman/lib"
import {fade, fly} from "@lib/transition"
import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {DIALOG_CONTEXT} from "@lib/components/dialog"
type Props = {
onClose?: any
onClose?: () => void
active?: boolean
label?: string
restoreFocus?: boolean
noEscape?: boolean
fullscreen?: boolean
size?: DialogSize
children: {
component: Component<any>
props: Record<string, any>
component: Component<ChildrenProps>
props: ChildrenProps
}
}
const {
onClose = noop,
active = true,
label = undefined,
restoreFocus = true,
noEscape = false,
fullscreen = false,
size = "default",
@ -51,29 +61,105 @@
"-top-4 mr-sai": !fullscreen,
}),
)
let titleId = $state<string | undefined>()
let contentLabel = $state<string | undefined>()
let element: HTMLElement
let panel: HTMLElement
let trap: FocusTrap | undefined
setContext(DIALOG_CONTEXT, {
registerLabel: (value: string) => {
contentLabel = value
return () => {
if (contentLabel === value) {
contentLabel = undefined
}
}
},
registerTitle: (id: string) => {
titleId = id
return () => {
if (titleId === id) {
titleId = undefined
}
}
},
})
$effect(() => {
if (trap) {
if (active) {
if (trap.active) {
trap.unpause()
} else {
trap.activate()
}
} else if (trap.active && !trap.paused) {
trap.pause()
}
}
})
onMount(() => {
const autofocus = panel.querySelector<HTMLElement>("[autofocus]")
trap = createFocusTrap(element, {
allowOutsideClick: true,
escapeDeactivates: false,
fallbackFocus: panel,
...(autofocus ? {initialFocus: autofocus} : {}),
isolateSubtrees: false,
returnFocusOnDeactivate: false,
setReturnFocus: previous => (previous.isConnected ? previous : false),
tabbableOptions: {getShadowRoot: true},
})
if (active) {
trap.activate()
}
return () => {
trap?.deactivate({returnFocus: restoreFocus})
}
})
</script>
<div class="dialog flex justify-center items-center fixed inset-0 z-modal">
<div
bind:this={element}
class="dialog dialog-overlay flex justify-center items-center fixed inset-0 z-modal"
inert={!active}>
<button
type="button"
aria-label="Close dialog"
tabindex="-1"
aria-hidden="true"
class="absolute inset-0 cursor-pointer bg-black opacity-50 dark:opacity-75"
transition:fade={{duration: 200}}
onclick={onClose}>
</button>
<div class={wrapperClass}>
<div
bind:this={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-label={titleId ? undefined : contentLabel || label}
tabindex="-1"
class={innerClass}
style={!fullscreen ? "box-shadow: var(--shadow-lg)" : undefined}
transition:fly>
<children.component {...children.props} />
{#if !noEscape}
<Button
aria-label="Close dialog"
class={cx("button button-neutral button-sm button-circle", buttonClass)}
onclick={onClose}>
<Icon icon={Close} size={6} />
</Button>
{/if}
<children.component {...children.props} />
</div>
</div>
<div class="tippy-target dialog-tippy-target"></div>
</div>

View file

@ -4,16 +4,18 @@
type Props = {
onClose?: any
active?: boolean
children: {
component: Component
props: Record<string, any>
}
}
const {onClose, children}: Props = $props()
const {onClose, active = true, children}: Props = $props()
</script>
<div
inert={!active}
class="drawer fixed inset-x-0 top-0 z-modal bottom-[calc(var(--mobile-nav-height)+var(--saib))] md:bottom-0">
<button
aria-label="Close drawer"

View file

@ -1,15 +1,27 @@
<script lang="ts">
import cx from "classnames"
import {getContext} from "svelte"
import type {Snippet} from "svelte"
import type {DialogContext} from "@lib/components/dialog"
import {DIALOG_CONTEXT} from "@lib/components/dialog"
interface Props {
type Props = {
label?: string
tag?: string
class?: string
children?: Snippet
[key: string]: any
}
const {children, tag = "div", ...props}: Props = $props()
const {children, label, tag = "div", ...props}: Props = $props()
const context = getContext<DialogContext | undefined>(DIALOG_CONTEXT)
$effect(() => {
if (label) {
return context?.registerLabel(label)
}
})
</script>
<svelte:element this={tag} {...props} class={cx("flex flex-col overflow-hidden", props.class)}>

View file

@ -1,11 +1,22 @@
<script lang="ts">
import {getContext} from "svelte"
import type {Snippet} from "svelte"
import cx from "classnames"
import {randomId} from "@welshman/lib"
import type {DialogContext} from "@lib/components/dialog"
import {DIALOG_CONTEXT} from "@lib/components/dialog"
interface Props {
type Props = {
class?: string
children: Snippet
}
const {children}: Props = $props()
const {children, ...props}: Props = $props()
const id = randomId()
const context = getContext<DialogContext | undefined>(DIALOG_CONTEXT)
$effect(() => context?.registerTitle(id))
</script>
<h1 class="heading">{@render children()}</h1>
<h1 {id} class={cx("heading", props.class)} tabindex="-1">{@render children()}</h1>

View file

@ -35,6 +35,12 @@
// the props it was first mounted with, showing stale data after the source updates.
const mountedProps = $state({...props})
let element: Element
let popover: Maybe<Instance>
let content: Maybe<Record<string, any>>
let returnFocus: HTMLElement | undefined
let visible = $state(false)
// Building a tippy costs a popper element and a set of listeners, which is wasted on the
// hover menus of a chat row nobody ever opens. Only a real trigger needs the instance up
// front — tippy is the one listening for it. A manual one can wait to be shown.
@ -42,17 +48,27 @@
popover ??= tippy(element, {
content: target,
animation: "shift-away",
appendTo: getTippyTarget(),
appendTo: getTippyTarget(element),
trigger: isMobile ? "click" : "mouseenter focus",
...params,
onShow: (instance: Instance) => {
const focused = document.activeElement
returnFocus =
focused instanceof HTMLElement && element.contains(focused)
? focused
: element.closest<HTMLElement>("button, [href], input, [tabindex]") ||
element.querySelector<HTMLElement>("button, [href], input, [tabindex]") ||
undefined
content ??= mount(component, {target, props: mountedProps})
visible = true
document.addEventListener("keydown", onKeyDown)
return params.onShow?.(instance)
},
onHidden: (instance: Instance) => {
visible = false
document.removeEventListener("keydown", onKeyDown)
return params.onHidden?.(instance)
},
@ -61,10 +77,17 @@
return popover
}
let element: Element
let popover: Maybe<Instance>
let content: Maybe<Record<string, any>>
let visible = $state(false)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && visible) {
event.preventDefault()
event.stopImmediatePropagation()
popover?.hide()
if (returnFocus?.isConnected) {
returnFocus.focus()
}
}
}
controller = {
show: () => create().show(),
@ -95,6 +118,7 @@
}
return () => {
document.removeEventListener("keydown", onKeyDown)
popover?.destroy()
if (content) {

View file

@ -50,7 +50,7 @@
interactive,
animation: "shift-away",
theme: "tooltip",
appendTo: getTippyTarget(),
appendTo: getTippyTarget(element),
trigger: isMobile ? "click" : "mouseenter focus",
})

View file

@ -0,0 +1,6 @@
export type DialogContext = {
registerLabel: (label: string) => () => void
registerTitle: (id: string) => () => void
}
export const DIALOG_CONTEXT = Symbol("dialog")

View file

@ -75,15 +75,19 @@ export const anchorDatepicker = (wrapper: HTMLElement) => {
export const copyToClipboard = (text: string) => {
const {activeElement} = document
const input = document.createElement("textarea")
const target = activeElement?.closest(".dialog-overlay") || document.body
input.innerHTML = text
document.body.appendChild(input)
target.appendChild(input)
input.select()
const result = document.execCommand("copy")
document.body.removeChild(input)
;(activeElement as HTMLElement).focus()
target.removeChild(input)
if (activeElement instanceof HTMLElement) {
activeElement.focus()
}
return result
}
@ -170,7 +174,13 @@ export const isMobile = "ontouchstart" in document.documentElement
// this runs thousands of times against a document that is itself thousands of nodes.
let tippyTarget: Maybe<Element>
export const getTippyTarget = () => {
export const getTippyTarget = (trigger?: Element) => {
const dialogTarget = trigger?.closest(".dialog-overlay")?.querySelector(".dialog-tippy-target")
if (dialogTarget) {
return dialogTarget
}
if (!tippyTarget?.isConnected) {
tippyTarget = document.querySelector(".tippy-target")!
}

View file

@ -49,14 +49,15 @@
// Do this asap to avoid a flash of the wrong font size or theme. The stores these mirror live in
// indexeddb, which doesn't load until well after first paint.
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
const savedTheme = localStorage.getItem("theme")
const initialTheme =
savedTheme === "light" || savedTheme === "dark" ? savedTheme : prefersDark ? "dark" : "light"
// @ts-ignore
document.documentElement.style["font-size"] = `${localStorage.getItem("font-size") || 1.1}rem`
document.documentElement.style.colorScheme = initialTheme
document.body.setAttribute("data-fl-theme", localStorage.getItem("fl-theme") || env.FL_THEME)
document.body.setAttribute(
"data-theme",
localStorage.getItem("theme") || (prefersDark ? "dark" : "light"),
)
document.body.setAttribute("data-theme", initialTheme)
// Add stuff to window for convenience
Object.assign(window, {get, nip19, theme, Logger, ...lib, ...util, ...core})
@ -255,6 +256,7 @@
activeTheme.subscribe($activeTheme => {
localStorage.setItem("theme", $activeTheme)
document.body.setAttribute("data-theme", $activeTheme)
document.documentElement.style.colorScheme = $activeTheme
}),
flTheme.subscribe($flTheme => {
localStorage.setItem("fl-theme", $flTheme)

View file

@ -27,7 +27,7 @@
<RoomImage {url} {h} />
{/snippet}
{#snippet title()}
<RoomName {url} {h} />
<RoomName {url} {h} class="block" />
{/snippet}
{#snippet action()}
<Button
@ -36,7 +36,10 @@
onclick={showRoomSearch}>
<Icon size={4} icon={Magnifier} />
</Button>
<Button class="button button-neutral button-sm button-square" onclick={showRoomDetail}>
<Button
class="button button-neutral button-sm button-square"
aria-label="Room details"
onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
{/snippet}