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))
|
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:\/\//)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)}>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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]})
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"}}>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
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) => {
|
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")!
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue