fix(desktop): dismiss share popover after actions
This commit is contained in:
parent
3d65478fb6
commit
52f587dacb
|
|
@ -9,7 +9,7 @@ import { createAtomHooks, jotaiStore } from "~/lib/jotai"
|
|||
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
|
||||
/** Custom z-index for popover */
|
||||
zIndex?: number
|
||||
/** Whether the popover should close when clicked outside */
|
||||
/** Whether the popover should use modal focus and pointer behavior */
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +33,11 @@ export const showPopover = (
|
|||
element: ReactNode,
|
||||
props?: PopoverProps,
|
||||
) => {
|
||||
const currentPopover = jotaiStore.get(popoverAtom)
|
||||
if (currentPopover.open) {
|
||||
currentPopover.abortController.abort()
|
||||
}
|
||||
|
||||
jotaiStore.set(popoverAtom, {
|
||||
open: true,
|
||||
position: mouseXY,
|
||||
|
|
@ -41,3 +46,11 @@ export const showPopover = (
|
|||
abortController: new AbortController(),
|
||||
})
|
||||
}
|
||||
|
||||
export const dismissPopover = () => {
|
||||
const currentPopover = jotaiStore.get(popoverAtom)
|
||||
if (!currentPopover.open) return
|
||||
|
||||
currentPopover.abortController.abort()
|
||||
jotaiStore.set(popoverAtom, { open: false })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import * as React from "react"
|
||||
import { act } from "react"
|
||||
import type { Root } from "react-dom/client"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"
|
||||
|
||||
import { SharePanel } from "./SharePanel"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
dismissPopover: vi.fn(),
|
||||
getEntry: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@follow/store/entry/getter", () => ({
|
||||
getEntry: mocks.getEntry,
|
||||
}))
|
||||
|
||||
vi.mock("~/atoms/popover", () => ({
|
||||
dismissPopover: mocks.dismissPopover,
|
||||
}))
|
||||
|
||||
vi.mock("~/lib/client", () => ({
|
||||
ipcServices: undefined,
|
||||
}))
|
||||
|
||||
vi.mock("~/lib/clipboard", () => ({
|
||||
copyToClipboard: mocks.copyToClipboard,
|
||||
}))
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}))
|
||||
|
||||
const waitForShareAction = async () => {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
const renderSharePanel = async () => {
|
||||
const container = document.createElement("div")
|
||||
document.body.append(container)
|
||||
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<SharePanel entryId="entry-1" />)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
const clickAction = async (container: HTMLElement, label: string) => {
|
||||
const button = Array.from(container.querySelectorAll("button")).find((element) =>
|
||||
element.textContent?.includes(label),
|
||||
)
|
||||
expect(button).not.toBeUndefined()
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
|
||||
await waitForShareAction()
|
||||
})
|
||||
}
|
||||
|
||||
describe("SharePanel", () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
beforeAll(() => {
|
||||
;(globalThis as typeof globalThis & { React: typeof React }).React = React
|
||||
;(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getEntry.mockReturnValue({
|
||||
description: "Example description",
|
||||
id: "entry-1",
|
||||
title: "Example entry",
|
||||
url: "https://example.com/article",
|
||||
})
|
||||
mocks.copyToClipboard.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
document.body.innerHTML = ""
|
||||
root = null
|
||||
container = null
|
||||
Reflect.deleteProperty(navigator, "share")
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test("dismisses after copying the link", async () => {
|
||||
;({ container, root } = await renderSharePanel())
|
||||
|
||||
await clickAction(container, "share.copy_link")
|
||||
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith("https://example.com/article")
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("share.link_copied")
|
||||
expect(mocks.dismissPopover).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test("keeps the panel open when copying the link fails", async () => {
|
||||
mocks.copyToClipboard.mockRejectedValueOnce(new Error("Clipboard unavailable"))
|
||||
;({ container, root } = await renderSharePanel())
|
||||
|
||||
await clickAction(container, "share.copy_link")
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith("share.copy_failed")
|
||||
expect(mocks.dismissPopover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("dismisses after system sharing completes", async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, "share", {
|
||||
configurable: true,
|
||||
value: share,
|
||||
})
|
||||
;({ container, root } = await renderSharePanel())
|
||||
|
||||
await clickAction(container, "share.system_share")
|
||||
|
||||
expect(share).toHaveBeenCalledWith({
|
||||
text: "Example description | share.discover_more",
|
||||
title: "Example entry - Folo",
|
||||
url: "https://example.com/article",
|
||||
})
|
||||
expect(mocks.dismissPopover).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import { useCallback } from "react"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { dismissPopover } from "~/atoms/popover"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
|
|
@ -140,11 +141,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
}
|
||||
dismissPopover()
|
||||
} catch {
|
||||
// If sharing fails, copy link as fallback
|
||||
try {
|
||||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
dismissPopover()
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
}
|
||||
|
|
@ -156,6 +159,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
try {
|
||||
await copyToClipboard(shareUrl)
|
||||
toast.success(t("share.link_copied"))
|
||||
dismissPopover()
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
}
|
||||
|
|
@ -178,6 +182,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
.replace("{text}", shareText)
|
||||
|
||||
window.open(finalUrl, "_blank", "width=600,height=400")
|
||||
dismissPopover()
|
||||
},
|
||||
[entryId, generateShareContent],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { GlobalFocusableProvider } from "@follow/components/common/Focusable/GlobalFocusableProvider.js"
|
||||
import { Provider } from "jotai"
|
||||
import * as React from "react"
|
||||
import { act } from "react"
|
||||
import type { Root } from "react-dom/client"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"
|
||||
|
||||
import { dismissPopover, popoverAtom, showPopover } from "~/atoms/popover"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
|
||||
import { PopoverProvider } from "./popover-provider"
|
||||
|
||||
const waitForPopoverEffects = async () => {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
const renderProvider = async () => {
|
||||
const container = document.createElement("div")
|
||||
document.body.append(container)
|
||||
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={jotaiStore}>
|
||||
<GlobalFocusableProvider>
|
||||
<PopoverProvider>
|
||||
<div>App content</div>
|
||||
</PopoverProvider>
|
||||
</GlobalFocusableProvider>
|
||||
</Provider>,
|
||||
)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
describe("PopoverProvider", () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
beforeAll(() => {
|
||||
;(globalThis as typeof globalThis & { React: typeof React }).React = React
|
||||
;(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.spyOn(console, "info").mockImplementation(() => {})
|
||||
|
||||
Object.assign(window, {
|
||||
addEventListener: document.defaultView?.addEventListener.bind(document.defaultView),
|
||||
clearTimeout,
|
||||
Element: document.defaultView?.Element ?? Element,
|
||||
getComputedStyle:
|
||||
document.defaultView?.getComputedStyle.bind(document.defaultView) ?? getComputedStyle,
|
||||
HTMLElement: document.defaultView?.HTMLElement ?? HTMLElement,
|
||||
innerHeight: 768,
|
||||
innerWidth: 1024,
|
||||
Node: document.defaultView?.Node ?? Node,
|
||||
removeEventListener: document.defaultView?.removeEventListener.bind(document.defaultView),
|
||||
setTimeout,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
jotaiStore.set(popoverAtom, { open: false })
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
document.body.innerHTML = ""
|
||||
root = null
|
||||
container = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test("closes when clicking outside", async () => {
|
||||
;({ container, root } = await renderProvider())
|
||||
|
||||
await act(async () => {
|
||||
showPopover({ x: 120, y: 80 }, <div>Share content</div>)
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain("Share content")
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
|
||||
const appContent = Array.from(container.querySelectorAll("div")).find(
|
||||
(element) => element.textContent === "App content",
|
||||
)
|
||||
expect(appContent).not.toBeUndefined()
|
||||
|
||||
await act(async () => {
|
||||
for (const eventType of ["pointerdown", "pointerup", "click"]) {
|
||||
appContent?.dispatchEvent(
|
||||
new PointerEvent(eventType, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(jotaiStore.get(popoverAtom).open).toBe(false)
|
||||
expect(document.body.textContent).not.toContain("Share content")
|
||||
})
|
||||
|
||||
test("can reopen after a programmatic dismissal", async () => {
|
||||
;({ container, root } = await renderProvider())
|
||||
|
||||
await act(async () => {
|
||||
showPopover({ x: 120, y: 80 }, <div>First popover</div>)
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain("First popover")
|
||||
|
||||
await act(async () => {
|
||||
dismissPopover()
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(document.body.textContent).not.toContain("First popover")
|
||||
|
||||
await act(async () => {
|
||||
showPopover({ x: 140, y: 100 }, <div>Second popover</div>)
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(jotaiStore.get(popoverAtom).open).toBe(true)
|
||||
expect(document.body.textContent).toContain("Second popover")
|
||||
})
|
||||
})
|
||||
|
|
@ -8,9 +8,9 @@ import {
|
|||
PopoverTrigger,
|
||||
} from "@follow/components/ui/popover/index.jsx"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo, useEffect, useRef } from "react"
|
||||
import { memo, useEffect } from "react"
|
||||
|
||||
import { usePopoverState } from "~/atoms/popover"
|
||||
import { dismissPopover, usePopoverValue } from "~/atoms/popover"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
|
||||
export const PopoverProvider: Component = ({ children }) => (
|
||||
|
|
@ -21,36 +21,31 @@ export const PopoverProvider: Component = ({ children }) => (
|
|||
)
|
||||
|
||||
const Handler = memo(() => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
const [popoverState, setPopoverState] = usePopoverState()
|
||||
const popoverState = usePopoverValue()
|
||||
const setGlobalFocusableScope = useSetGlobalFocusableScope()
|
||||
|
||||
useEffect(() => {
|
||||
if (!popoverState.open) return
|
||||
const triggerElement = ref.current
|
||||
if (!triggerElement) return
|
||||
|
||||
triggerElement.dispatchEvent(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}, [popoverState])
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
||||
return () => {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
||||
}
|
||||
}, [popoverState.open, setGlobalFocusableScope])
|
||||
|
||||
const { modal, zIndex, ...contentProps } = popoverState.open ? (popoverState.props ?? {}) : {}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popoverState.open}
|
||||
modal={modal}
|
||||
onOpenChange={(state) => {
|
||||
if (state) {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
||||
} else {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
||||
setPopoverState({ open: false })
|
||||
if (!state) {
|
||||
dismissPopover()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
ref={ref}
|
||||
className="pointer-events-none"
|
||||
style={
|
||||
popoverState.open
|
||||
|
|
@ -58,9 +53,14 @@ const Handler = memo(() => {
|
|||
: {}
|
||||
}
|
||||
/>
|
||||
<PopoverContent asChild forceMount>
|
||||
<AnimatePresence>
|
||||
{popoverState.open && (
|
||||
<AnimatePresence>
|
||||
{popoverState.open && (
|
||||
<PopoverContent
|
||||
{...contentProps}
|
||||
asChild
|
||||
forceMount
|
||||
style={{ ...contentProps.style, zIndex }}
|
||||
>
|
||||
<m.div
|
||||
className="mr-2 rounded-xl border bg-material-ultra-thick p-2 shadow-2xl backdrop-blur-background"
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
|
|
@ -71,9 +71,9 @@ const Handler = memo(() => {
|
|||
<PopoverArrow className="fill-border" />
|
||||
{popoverState.content}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</PopoverContent>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue