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)) page.on("pageerror", error => errors.push(error.message))
await page.reload() 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) const origin = await page.evaluate(() => location.origin)
expect(origin).toMatch(/^capacitor-electron:\/\//) 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. * 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) => export const dialog = (page: Page, title: string) =>
page page.getByRole("dialog", {name: title, exact: true})
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
.last()
// The modal on top, for one with no heading of its own or one pushed over another rather than // The modal on top, for one with no heading of its own or one pushed over another rather than
// alongside it. // 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 // 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 // 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"]') 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) =>
export const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click() pageBar(page).getByRole("button", {name: "Room details"}).click()
export const roomLink = (page: Page, name: string) => export const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name}) 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}) 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) => export const messageActions = (page: Page, text: string) =>
message(page, text).locator(".room__item-actions button") message(page, text).locator(".room__item-actions button")
export const openMessageMenu = (page: Page, text: string) => 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) => export const bubble = (page: Page, text: string) =>
page.locator(".chat-bubble").filter({hasText: text}) 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() await inlineImage.click()
// The lightbox is a fullscreen modal holding nothing but the image. // 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 expect(lightbox).toBeVisible()
await page.keyboard.press("Escape") 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, his.id)).toBeVisible()
await expect(message(alice, hers.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 // Reply: the preview appears above the composer, and closing it sends nothing
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Reply"}).click() 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")) const page = await visit(roomPath(url, "general"))
await expect(gate(page)).toBeVisible() 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: "Log in"})).toBeVisible()
await expect(page.getByRole("button", {name: "Create an account"})).toBeVisible() await expect(page.getByRole("button", {name: "Create an account"})).toBeVisible()
await expect(page.getByRole("link", {name: "Terms of Service"})).toHaveAttribute( 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() await expect(gate(page)).toBeVisible()
// The backdrop, clicked in its top left corner so the dialog itself isn't what gets hit. for (const key of ["Tab", "Tab", "Shift+Tab", "Shift+Tab"]) {
await page.getByRole("button", {name: "Close dialog"}).click({position: {x: 4, y: 4}}) 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() 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 preview.click()
await expect(topDialog(page).getByText("Bob Barnacle")).toBeVisible() const profile = page.getByRole("dialog", {name: "Profile details"})
await expect(topDialog(page).locator(`img[src="${avatar}"]`)).toBeVisible()
await expect(topDialog(page).getByText("Deckhand, dockside cook")).toBeVisible() await expect(profile.getByText("Bob Barnacle")).toBeVisible()
await expect(topDialog(page).getByText("Scrubbing the decks")).toBeVisible() await expect(profile.locator(`img[src="${avatar}"]`)).toBeVisible()
await expect(topDialog(page).getByText(/Last active/)).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 page.keyboard.press("Escape")
await expect(page.locator(".dialog")).toHaveCount(0) await expect(page.locator(".dialog")).toHaveCount(0)
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`)) await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
await expect(preview).toBeVisible() await expect(preview).toBeFocused()
await preview.click() 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. // left corner of the viewport is outside it.
const dismissMenu = (page: Page) => page.mouse.move(0, 0) 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 const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details"})
// 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 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 // 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. // 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") 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}) => { test("US-020 create, edit, and delete a room", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => { const scenario = await seed(({relay, user}) => {
const space = relay("space") 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... // 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, "we sail at dwan")).toHaveCount(5)
await expect(messageActions(alice, "spelling?")).toHaveCount(4) 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 // ...and no delete either
await openMessageMenu(alice, "spelling?") 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(carol, "we made port")).toBeVisible()
await expect(message(bob, "we made port")).toBeVisible() await expect(message(bob, "we made port")).toBeVisible()
// The quick reaction button is the second of RoomItem's hover actions const reaction = message(carol, "we made port").getByRole("button", {name: "Add a reaction"})
await react(carol, messageActions(carol, "we made port").nth(1))
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(carol, "we made port")).toBeVisible()
await expect(reactionPill(bob, "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 term = page.getByPlaceholder("Search this room...")
const search = dialog(page, "Search") 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 term.fill("harbor")
await expect(search.getByText("Last 24 Hours")).toBeVisible() 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") 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 connect.getByRole("button", {name: "Connect with WebLN"}).click()
await expect(page.getByRole("alert")).toContainText("Wallet successfully connected!") 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 page = await as(users.alice, "/settings/theme", {context: {colorScheme: "light"}})
const body = page.locator("body") const body = page.locator("body")
const colorScheme = () =>
page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)
await expect(body).toHaveAttribute("data-theme", "light") await expect(body).toHaveAttribute("data-theme", "light")
await expect.poll(colorScheme).toBe("light")
await page.getByRole("button", {name: "Dark", exact: true}).click() await page.getByRole("button", {name: "Dark", exact: true}).click()
await expect(body).toHaveAttribute("data-theme", "dark") await expect(body).toHaveAttribute("data-theme", "dark")
await expect.poll(colorScheme).toBe("dark")
await page.getByRole("button", {name: "System", exact: true}).click() await page.getByRole("button", {name: "System", exact: true}).click()
await expect(body).toHaveAttribute("data-theme", "light") 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. // System means the device's, so changing the device's changes the app's.
await page.emulateMedia({colorScheme: "dark"}) await page.emulateMedia({colorScheme: "dark"})
await expect(body).toHaveAttribute("data-theme", "dark") await expect(body).toHaveAttribute("data-theme", "dark")
await expect.poll(colorScheme).toBe("dark")
await page.getByLabel("Style").selectOption("navy") await page.getByLabel("Style").selectOption("navy")

View file

@ -100,6 +100,7 @@
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0", "emoji-picker-element-data": "^1.8.0",
"focus-trap": "8.2.2",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"hono": "^4.12.23", "hono": "^4.12.23",
"husky": "^9.1.7", "husky": "^9.1.7",

Binary file not shown.

View file

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

View file

@ -70,7 +70,8 @@
params={{interactive: true, trigger: "manual"}}> params={{interactive: true, trigger: "manual"}}>
<button <button
type="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} class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}> onclick={togglePopover}>
<Icon icon={MenuDots} size={4} /> <Icon icon={MenuDots} size={4} />

View file

@ -20,6 +20,9 @@
} }
</script> </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} /> <Icon icon={SmileCircle} size={4} />
</EmojiButton> </EmojiButton>

View file

@ -22,16 +22,19 @@
<div class="join text-xs"> <div class="join text-xs">
<ChatMessageEmojiButton {event} {pubkeys} /> <ChatMessageEmojiButton {event} {pubkeys} />
{#if replyTo} {#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} /> <Icon size={4} icon={Reply} />
</Button> </Button>
{/if} {/if}
{#if edit} {#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} /> <Icon size={4} icon={Pen} />
</Button> </Button>
{/if} {/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} /> <Icon size={4} icon={Code2} />
</Button> </Button>
</div> </div>

View file

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

View file

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

View file

@ -13,7 +13,8 @@
const url = value.url.toString() const url = value.url.toString()
const fileType = getUrlContentType(url, event) 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> </script>
{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} {#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} /> <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 <Tippy
bind:controller={tippy} bind:controller={tippy}
component={RoomItemMenu} component={RoomItemMenu}

View file

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

View file

@ -6,6 +6,6 @@
const {url, event} = $props() const {url, event} = $props()
</script> </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} /> <Icon icon={Bolt} size={4} />
</ZapButton> </ZapButton>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,28 +2,38 @@
export type DialogSize = "default" | "large" export type DialogSize = "default" | "large"
</script> </script>
<script lang="ts"> <script lang="ts" generics="ChildrenProps extends Record<string, unknown>">
import type {Component} from "svelte" import type {Component} from "svelte"
import {onMount, setContext} from "svelte"
import cx from "classnames" import cx from "classnames"
import {createFocusTrap} from "focus-trap"
import type {FocusTrap} from "focus-trap"
import {noop} from "@welshman/lib" import {noop} from "@welshman/lib"
import {fade, fly} from "@lib/transition" import {fade, fly} from "@lib/transition"
import Close from "@assets/icons/close.svg?dataurl" import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {DIALOG_CONTEXT} from "@lib/components/dialog"
type Props = { type Props = {
onClose?: any onClose?: () => void
active?: boolean
label?: string
restoreFocus?: boolean
noEscape?: boolean noEscape?: boolean
fullscreen?: boolean fullscreen?: boolean
size?: DialogSize size?: DialogSize
children: { children: {
component: Component<any> component: Component<ChildrenProps>
props: Record<string, any> props: ChildrenProps
} }
} }
const { const {
onClose = noop, onClose = noop,
active = true,
label = undefined,
restoreFocus = true,
noEscape = false, noEscape = false,
fullscreen = false, fullscreen = false,
size = "default", size = "default",
@ -51,29 +61,105 @@
"-top-4 mr-sai": !fullscreen, "-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> </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 <button
type="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" class="absolute inset-0 cursor-pointer bg-black opacity-50 dark:opacity-75"
transition:fade={{duration: 200}} transition:fade={{duration: 200}}
onclick={onClose}> onclick={onClose}>
</button> </button>
<div class={wrapperClass}> <div class={wrapperClass}>
<div <div
bind:this={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-label={titleId ? undefined : contentLabel || label}
tabindex="-1"
class={innerClass} class={innerClass}
style={!fullscreen ? "box-shadow: var(--shadow-lg)" : undefined} style={!fullscreen ? "box-shadow: var(--shadow-lg)" : undefined}
transition:fly> transition:fly>
<children.component {...children.props} />
{#if !noEscape} {#if !noEscape}
<Button <Button
aria-label="Close dialog"
class={cx("button button-neutral button-sm button-circle", buttonClass)} class={cx("button button-neutral button-sm button-circle", buttonClass)}
onclick={onClose}> onclick={onClose}>
<Icon icon={Close} size={6} /> <Icon icon={Close} size={6} />
</Button> </Button>
{/if} {/if}
<children.component {...children.props} />
</div> </div>
</div> </div>
<div class="tippy-target dialog-tippy-target"></div>
</div> </div>

View file

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

View file

@ -1,15 +1,27 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {getContext} from "svelte"
import type {Snippet} 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 tag?: string
class?: string class?: string
children?: Snippet children?: Snippet
[key: string]: any [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> </script>
<svelte:element this={tag} {...props} class={cx("flex flex-col overflow-hidden", props.class)}> <svelte:element this={tag} {...props} class={cx("flex flex-col overflow-hidden", props.class)}>

View file

@ -1,11 +1,22 @@
<script lang="ts"> <script lang="ts">
import {getContext} from "svelte"
import type {Snippet} 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 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> </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. // the props it was first mounted with, showing stale data after the source updates.
const mountedProps = $state({...props}) 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 // 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 // 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. // front — tippy is the one listening for it. A manual one can wait to be shown.
@ -42,17 +48,27 @@
popover ??= tippy(element, { popover ??= tippy(element, {
content: target, content: target,
animation: "shift-away", animation: "shift-away",
appendTo: getTippyTarget(), appendTo: getTippyTarget(element),
trigger: isMobile ? "click" : "mouseenter focus", trigger: isMobile ? "click" : "mouseenter focus",
...params, ...params,
onShow: (instance: Instance) => { 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}) content ??= mount(component, {target, props: mountedProps})
visible = true visible = true
document.addEventListener("keydown", onKeyDown)
return params.onShow?.(instance) return params.onShow?.(instance)
}, },
onHidden: (instance: Instance) => { onHidden: (instance: Instance) => {
visible = false visible = false
document.removeEventListener("keydown", onKeyDown)
return params.onHidden?.(instance) return params.onHidden?.(instance)
}, },
@ -61,10 +77,17 @@
return popover return popover
} }
let element: Element const onKeyDown = (event: KeyboardEvent) => {
let popover: Maybe<Instance> if (event.key === "Escape" && visible) {
let content: Maybe<Record<string, any>> event.preventDefault()
let visible = $state(false) event.stopImmediatePropagation()
popover?.hide()
if (returnFocus?.isConnected) {
returnFocus.focus()
}
}
}
controller = { controller = {
show: () => create().show(), show: () => create().show(),
@ -95,6 +118,7 @@
} }
return () => { return () => {
document.removeEventListener("keydown", onKeyDown)
popover?.destroy() popover?.destroy()
if (content) { if (content) {

View file

@ -50,7 +50,7 @@
interactive, interactive,
animation: "shift-away", animation: "shift-away",
theme: "tooltip", theme: "tooltip",
appendTo: getTippyTarget(), appendTo: getTippyTarget(element),
trigger: isMobile ? "click" : "mouseenter focus", 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) => { export const copyToClipboard = (text: string) => {
const {activeElement} = document const {activeElement} = document
const input = document.createElement("textarea") const input = document.createElement("textarea")
const target = activeElement?.closest(".dialog-overlay") || document.body
input.innerHTML = text input.innerHTML = text
document.body.appendChild(input) target.appendChild(input)
input.select() input.select()
const result = document.execCommand("copy") const result = document.execCommand("copy")
document.body.removeChild(input) target.removeChild(input)
;(activeElement as HTMLElement).focus()
if (activeElement instanceof HTMLElement) {
activeElement.focus()
}
return result 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. // this runs thousands of times against a document that is itself thousands of nodes.
let tippyTarget: Maybe<Element> 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) { if (!tippyTarget?.isConnected) {
tippyTarget = document.querySelector(".tippy-target")! 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 // 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. // indexeddb, which doesn't load until well after first paint.
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches 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 // @ts-ignore
document.documentElement.style["font-size"] = `${localStorage.getItem("font-size") || 1.1}rem` 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-fl-theme", localStorage.getItem("fl-theme") || env.FL_THEME)
document.body.setAttribute( document.body.setAttribute("data-theme", initialTheme)
"data-theme",
localStorage.getItem("theme") || (prefersDark ? "dark" : "light"),
)
// Add stuff to window for convenience // Add stuff to window for convenience
Object.assign(window, {get, nip19, theme, Logger, ...lib, ...util, ...core}) Object.assign(window, {get, nip19, theme, Logger, ...lib, ...util, ...core})
@ -255,6 +256,7 @@
activeTheme.subscribe($activeTheme => { activeTheme.subscribe($activeTheme => {
localStorage.setItem("theme", $activeTheme) localStorage.setItem("theme", $activeTheme)
document.body.setAttribute("data-theme", $activeTheme) document.body.setAttribute("data-theme", $activeTheme)
document.documentElement.style.colorScheme = $activeTheme
}), }),
flTheme.subscribe($flTheme => { flTheme.subscribe($flTheme => {
localStorage.setItem("fl-theme", $flTheme) localStorage.setItem("fl-theme", $flTheme)

View file

@ -27,7 +27,7 @@
<RoomImage {url} {h} /> <RoomImage {url} {h} />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<RoomName {url} {h} /> <RoomName {url} {h} class="block" />
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<Button <Button
@ -36,7 +36,10 @@
onclick={showRoomSearch}> onclick={showRoomSearch}>
<Icon size={4} icon={Magnifier} /> <Icon size={4} icon={Magnifier} />
</Button> </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} /> <Icon size={4} icon={InfoCircle} />
</Button> </Button>
{/snippet} {/snippet}