281 lines
8.4 KiB
TypeScript
281 lines
8.4 KiB
TypeScript
|
|
// @vitest-environment jsdom
|
||
|
|
|
||
|
|
import * as React from "react"
|
||
|
|
import {
|
||
|
|
act,
|
||
|
|
cleanup,
|
||
|
|
fireEvent,
|
||
|
|
render,
|
||
|
|
screen,
|
||
|
|
waitFor,
|
||
|
|
} from "@testing-library/react"
|
||
|
|
import { I18nProvider } from "@workspace/i18n"
|
||
|
|
import {
|
||
|
|
createOverlayScope,
|
||
|
|
globalOverlayScope,
|
||
|
|
OverlayScopeProvider,
|
||
|
|
useOverlayScope,
|
||
|
|
type OverlayScope,
|
||
|
|
} from "@workspace/overlays"
|
||
|
|
import { AlertHost, openAlert } from "@workspace/overlays/alert"
|
||
|
|
import { OverlayHost } from "@workspace/overlays/all"
|
||
|
|
import { openConfirm } from "@workspace/overlays/confirm"
|
||
|
|
import { openDrawer } from "@workspace/overlays/drawer"
|
||
|
|
import { openModal } from "@workspace/overlays/modal"
|
||
|
|
import {
|
||
|
|
OverlayNavigationGuard,
|
||
|
|
type OverlayNavigationAdapter,
|
||
|
|
type OverlayNavigationAttempt,
|
||
|
|
} from "@workspace/overlays/navigation"
|
||
|
|
import { openPrompt } from "@workspace/overlays/prompt"
|
||
|
|
import { openSheet } from "@workspace/overlays/sheet"
|
||
|
|
import { openToast } from "@workspace/overlays/toast"
|
||
|
|
import { Button } from "@workspace/ui/components/button"
|
||
|
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||
|
|
|
||
|
|
import { messages as englishMessages } from "./locales/en-US"
|
||
|
|
|
||
|
|
class ResizeObserverStub {
|
||
|
|
disconnect() {}
|
||
|
|
observe() {}
|
||
|
|
unobserve() {}
|
||
|
|
}
|
||
|
|
|
||
|
|
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
|
||
|
|
Object.defineProperty(Element.prototype, "getAnimations", {
|
||
|
|
configurable: true,
|
||
|
|
value: () => [],
|
||
|
|
})
|
||
|
|
|
||
|
|
afterEach(cleanup)
|
||
|
|
|
||
|
|
function renderHosts(scope = createOverlayScope()) {
|
||
|
|
const result = render(
|
||
|
|
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||
|
|
<OverlayScopeProvider scope={scope}>
|
||
|
|
<OverlayHost />
|
||
|
|
</OverlayScopeProvider>
|
||
|
|
</I18nProvider>
|
||
|
|
)
|
||
|
|
return { scope, ...result }
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("atomic overlays", () => {
|
||
|
|
it("uses the global scope when scope is omitted", async () => {
|
||
|
|
render(
|
||
|
|
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||
|
|
<AlertHost />
|
||
|
|
</I18nProvider>
|
||
|
|
)
|
||
|
|
|
||
|
|
let result: Promise<void> | undefined
|
||
|
|
act(() => {
|
||
|
|
result = openAlert({ title: "Global alert" })
|
||
|
|
})
|
||
|
|
expect(globalOverlayScope.getTopmostLayer()).toBeTruthy()
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "OK" }))
|
||
|
|
await expect(result).resolves.toBeUndefined()
|
||
|
|
})
|
||
|
|
|
||
|
|
it("resolves alert, confirm, and prompt results", async () => {
|
||
|
|
const { scope } = renderHosts()
|
||
|
|
|
||
|
|
let alertResult: Promise<void> | undefined
|
||
|
|
act(() => {
|
||
|
|
alertResult = openAlert({ scope, title: "Export complete" })
|
||
|
|
})
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "OK" }))
|
||
|
|
await expect(alertResult).resolves.toBeUndefined()
|
||
|
|
|
||
|
|
let confirmation: Promise<boolean> | undefined
|
||
|
|
act(() => {
|
||
|
|
confirmation = openConfirm({ scope, title: "Publish changes?" })
|
||
|
|
})
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "Confirm" }))
|
||
|
|
await expect(confirmation).resolves.toBe(true)
|
||
|
|
|
||
|
|
await waitFor(() =>
|
||
|
|
expect(screen.queryByText("Publish changes?")).toBeNull()
|
||
|
|
)
|
||
|
|
let promptResult: Promise<string | null> | undefined
|
||
|
|
act(() => {
|
||
|
|
promptResult = openPrompt({ scope, title: "Document name" })
|
||
|
|
})
|
||
|
|
fireEvent.change(screen.getByLabelText("Value"), {
|
||
|
|
target: { value: "Release notes" },
|
||
|
|
})
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "Confirm" }))
|
||
|
|
await expect(promptResult).resolves.toBe("Release notes")
|
||
|
|
})
|
||
|
|
|
||
|
|
it("returns typed custom content results", async () => {
|
||
|
|
const { scope } = renderHosts()
|
||
|
|
let modalResult: Promise<number | undefined> | undefined
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
modalResult = openModal<number>({
|
||
|
|
content: ({ close }) => (
|
||
|
|
<Button onClick={() => close(42)}>Choose 42</Button>
|
||
|
|
),
|
||
|
|
scope,
|
||
|
|
title: "Choose a value",
|
||
|
|
})
|
||
|
|
})
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "Choose 42" }))
|
||
|
|
await expect(modalResult).resolves.toBe(42)
|
||
|
|
|
||
|
|
await waitFor(() => expect(screen.queryByText("Choose a value")).toBeNull())
|
||
|
|
act(() => {
|
||
|
|
void openSheet({
|
||
|
|
content: <p>Account details</p>,
|
||
|
|
scope,
|
||
|
|
title: "Account",
|
||
|
|
})
|
||
|
|
void openDrawer({
|
||
|
|
content: <p>Filters</p>,
|
||
|
|
scope,
|
||
|
|
title: "Filter products",
|
||
|
|
})
|
||
|
|
})
|
||
|
|
expect(screen.getByText("Account details")).toBeTruthy()
|
||
|
|
expect(screen.getByText("Filter products")).toBeTruthy()
|
||
|
|
})
|
||
|
|
|
||
|
|
it("renders scoped toasts", async () => {
|
||
|
|
const { scope } = renderHosts()
|
||
|
|
act(() => {
|
||
|
|
openToast({
|
||
|
|
description: "Your changes were saved.",
|
||
|
|
scope,
|
||
|
|
title: "Saved",
|
||
|
|
type: "success",
|
||
|
|
})
|
||
|
|
})
|
||
|
|
expect(await screen.findByText("Your changes were saved.")).toBeTruthy()
|
||
|
|
})
|
||
|
|
|
||
|
|
it("provides current and named local scopes", () => {
|
||
|
|
const editor = createOverlayScope({ name: "editor" })
|
||
|
|
const picker = createOverlayScope({ name: "picker" })
|
||
|
|
let current: OverlayScope | undefined
|
||
|
|
let named: OverlayScope | undefined
|
||
|
|
|
||
|
|
function CaptureScopes() {
|
||
|
|
current = useOverlayScope()
|
||
|
|
named = useOverlayScope("picker")
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
render(
|
||
|
|
<OverlayScopeProvider scope={editor} scopes={{ picker }}>
|
||
|
|
<CaptureScopes />
|
||
|
|
</OverlayScopeProvider>
|
||
|
|
)
|
||
|
|
expect(current).toBe(editor)
|
||
|
|
expect(named).toBe(picker)
|
||
|
|
})
|
||
|
|
|
||
|
|
it("queues requests for the same atomic host", async () => {
|
||
|
|
const { scope } = renderHosts()
|
||
|
|
let first: Promise<boolean> | undefined
|
||
|
|
let second: Promise<boolean> | undefined
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
first = openConfirm({ scope, title: "First confirmation" })
|
||
|
|
second = openConfirm({ scope, title: "Second confirmation" })
|
||
|
|
})
|
||
|
|
expect(screen.getByText("First confirmation")).toBeTruthy()
|
||
|
|
expect(screen.queryByText("Second confirmation")).toBeNull()
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "Confirm" }))
|
||
|
|
await expect(first).resolves.toBe(true)
|
||
|
|
|
||
|
|
expect(await screen.findByText("Second confirmation")).toBeTruthy()
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }))
|
||
|
|
await expect(second).resolves.toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it("settles pending requests when their host unmounts", async () => {
|
||
|
|
const { scope, unmount } = renderHosts()
|
||
|
|
let result: Promise<boolean> | undefined
|
||
|
|
act(() => {
|
||
|
|
result = openConfirm({ scope, title: "Pending action" })
|
||
|
|
})
|
||
|
|
unmount()
|
||
|
|
await expect(result).resolves.toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it("intercepts navigation through a router-neutral adapter", async () => {
|
||
|
|
const scope = createOverlayScope()
|
||
|
|
let listener: ((attempt: OverlayNavigationAttempt) => void) | undefined
|
||
|
|
const adapter: OverlayNavigationAdapter = {
|
||
|
|
subscribe(nextListener) {
|
||
|
|
listener = nextListener
|
||
|
|
return () => {
|
||
|
|
listener = undefined
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}
|
||
|
|
const cancel = vi.fn()
|
||
|
|
const proceed = vi.fn()
|
||
|
|
|
||
|
|
render(
|
||
|
|
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||
|
|
<OverlayScopeProvider scope={scope}>
|
||
|
|
<OverlayHost />
|
||
|
|
<OverlayNavigationGuard adapter={adapter} />
|
||
|
|
</OverlayScopeProvider>
|
||
|
|
</I18nProvider>
|
||
|
|
)
|
||
|
|
let result: Promise<boolean> | undefined
|
||
|
|
act(() => {
|
||
|
|
result = openConfirm({ scope, title: "Discard changes?" })
|
||
|
|
})
|
||
|
|
act(() => listener?.({ cancel, proceed }))
|
||
|
|
|
||
|
|
expect(cancel).toHaveBeenCalledOnce()
|
||
|
|
expect(proceed).not.toHaveBeenCalled()
|
||
|
|
await expect(result).resolves.toBe(false)
|
||
|
|
|
||
|
|
await waitFor(() => expect(scope.getTopmostLayer()).toBeUndefined())
|
||
|
|
act(() => listener?.({ cancel, proceed }))
|
||
|
|
expect(proceed).toHaveBeenCalledOnce()
|
||
|
|
})
|
||
|
|
|
||
|
|
it("keeps non-dismissible content open while blocking navigation", () => {
|
||
|
|
const scope = createOverlayScope()
|
||
|
|
let listener: ((attempt: OverlayNavigationAttempt) => void) | undefined
|
||
|
|
const adapter: OverlayNavigationAdapter = {
|
||
|
|
subscribe(nextListener) {
|
||
|
|
listener = nextListener
|
||
|
|
return () => {
|
||
|
|
listener = undefined
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}
|
||
|
|
const cancel = vi.fn()
|
||
|
|
|
||
|
|
render(
|
||
|
|
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||
|
|
<OverlayScopeProvider scope={scope}>
|
||
|
|
<OverlayHost />
|
||
|
|
<OverlayNavigationGuard adapter={adapter} />
|
||
|
|
</OverlayScopeProvider>
|
||
|
|
</I18nProvider>
|
||
|
|
)
|
||
|
|
act(() => {
|
||
|
|
void openModal({
|
||
|
|
content: ({ close }) => (
|
||
|
|
<Button onClick={() => close()}>Finish required step</Button>
|
||
|
|
),
|
||
|
|
dismissible: false,
|
||
|
|
scope,
|
||
|
|
title: "Required step",
|
||
|
|
})
|
||
|
|
})
|
||
|
|
act(() => listener?.({ cancel, proceed: vi.fn() }))
|
||
|
|
|
||
|
|
expect(cancel).toHaveBeenCalledOnce()
|
||
|
|
expect(screen.getByText("Required step")).toBeTruthy()
|
||
|
|
})
|
||
|
|
})
|