Fix desktop dialog focus and room layout overflow (#517)
This commit is contained in:
parent
a2c4dcec44
commit
0460c94395
50 changed files with 417 additions and 114 deletions
|
|
@ -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:\/\//)
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
const {onSelect}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<Modal label="Choose an icon">
|
||||
<ModalBody>
|
||||
<IconPicker {onSelect} />
|
||||
</ModalBody>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]})
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<Modal label="Profile details">
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-between">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
</script>
|
||||
|
||||
<EmojiButton
|
||||
aria-label="Add a reaction"
|
||||
{onEmoji}
|
||||
class="button button-xs button-neutral join-item"
|
||||
tippyParams={{placement: "bottom-end"}}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
const {url}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<Modal label="Space menu">
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-2">
|
||||
<SpaceMenuActionsMobile {url} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
interactive,
|
||||
animation: "shift-away",
|
||||
theme: "tooltip",
|
||||
appendTo: getTippyTarget(),
|
||||
appendTo: getTippyTarget(element),
|
||||
trigger: isMobile ? "click" : "mouseenter focus",
|
||||
})
|
||||
|
||||
|
|
|
|||
6
src/lib/components/dialog.ts
Normal file
6
src/lib/components/dialog.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type DialogContext = {
|
||||
registerLabel: (label: string) => () => void
|
||||
registerTitle: (id: string) => () => void
|
||||
}
|
||||
|
||||
export const DIALOG_CONTEXT = Symbol("dialog")
|
||||
|
|
@ -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")!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue