feat(overlays): add managed overlay package

This commit is contained in:
Maofeng
2026-09-28 23:13:44 +08:00
parent bca874fd30
commit 0ec968c344
34 changed files with 2340 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import { useTranslate } from "@workspace/i18n"
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogFooter,
createAlertDialogHandle,
} from "@workspace/ui/components/alert-dialog"
import { OverlayController, type OverlayRequest } from "./internal/controller"
import { MessageHeader } from "./internal/message-header"
import { useOverlayController } from "./internal/use-controller"
import { overlayMessages } from "./messages"
import { globalOverlayScope, type OverlayScope } from "./scope"
import type { AlertOptions } from "./types"
type AlertRequestOptions = Omit<AlertOptions, "scope">
const alertChannel = Symbol("overlay.alert")
function getAlertController(scope: OverlayScope) {
return scope.getChannel(alertChannel, () => {
const handle =
createAlertDialogHandle<OverlayRequest<AlertRequestOptions>>()
return new OverlayController<AlertRequestOptions, typeof handle>({
handle,
scope,
})
})
}
export function openAlert(options: AlertOptions) {
const { scope = globalOverlayScope, ...alertOptions } = options
return getAlertController(scope).open<void>(alertOptions, undefined)
}
export interface AlertHostProps {
scope?: OverlayScope
}
export function AlertHost({ scope }: AlertHostProps) {
const controller = useOverlayController(scope, getAlertController)
return (
<AlertDialog<OverlayRequest<AlertRequestOptions>>
handle={controller.handle}
onOpenChange={(open, details) => {
if (!open) controller.handleCloseRequest(details)
}}
onOpenChangeComplete={(open) => {
if (!open) controller.handleCloseComplete()
}}
>
{({ payload }) =>
payload ? (
<AlertContent
key={payload.id}
options={payload.options}
onResolve={() => controller.resolve(undefined)}
/>
) : null
}
</AlertDialog>
)
}
function AlertContent({
onResolve,
options,
}: {
onResolve: () => void
options: AlertRequestOptions
}) {
const t = useTranslate()
return (
<AlertDialogContent>
<MessageHeader options={options} />
<AlertDialogFooter>
<AlertDialogAction onClick={onResolve}>
{options.actionLabel ?? t(overlayMessages.okay)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
)
}
export type { AlertOptions } from "./types"
+27
View File
@@ -0,0 +1,27 @@
import type { OverlayScope } from "./scope"
import { AlertHost } from "./alert"
import { ConfirmHost } from "./confirm"
import { DrawerHost } from "./drawer"
import { ModalHost } from "./modal"
import { PromptHost } from "./prompt"
import { SheetHost } from "./sheet"
import { ToastHost } from "./toast"
export interface OverlayHostProps {
scope?: OverlayScope
}
/** Convenience host for applications that use every overlay feature. */
export function OverlayHost({ scope }: OverlayHostProps) {
return (
<>
<AlertHost scope={scope} />
<ConfirmHost scope={scope} />
<PromptHost scope={scope} />
<ModalHost scope={scope} />
<SheetHost scope={scope} />
<DrawerHost scope={scope} />
<ToastHost scope={scope} />
</>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useTranslate } from "@workspace/i18n"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
createAlertDialogHandle,
} from "@workspace/ui/components/alert-dialog"
import { OverlayController, type OverlayRequest } from "./internal/controller"
import { MessageHeader } from "./internal/message-header"
import { useOverlayController } from "./internal/use-controller"
import { overlayMessages } from "./messages"
import { globalOverlayScope, type OverlayScope } from "./scope"
import type { ConfirmOptions } from "./types"
type ConfirmRequestOptions = Omit<ConfirmOptions, "scope">
const confirmChannel = Symbol("overlay.confirm")
function getConfirmController(scope: OverlayScope) {
return scope.getChannel(confirmChannel, () => {
const handle =
createAlertDialogHandle<OverlayRequest<ConfirmRequestOptions>>()
return new OverlayController<ConfirmRequestOptions, typeof handle>({
handle,
scope,
})
})
}
export function openConfirm(options: ConfirmOptions) {
const { scope = globalOverlayScope, ...confirmOptions } = options
return getConfirmController(scope).open<boolean>(confirmOptions, false)
}
export interface ConfirmHostProps {
scope?: OverlayScope
}
export function ConfirmHost({ scope }: ConfirmHostProps) {
const controller = useOverlayController(scope, getConfirmController)
return (
<AlertDialog<OverlayRequest<ConfirmRequestOptions>>
handle={controller.handle}
onOpenChange={(open, details) => {
if (!open) controller.handleCloseRequest(details)
}}
onOpenChangeComplete={(open) => {
if (!open) controller.handleCloseComplete()
}}
>
{({ payload }) =>
payload ? (
<ConfirmContent
key={payload.id}
options={payload.options}
onDismiss={controller.dismiss}
onResolve={(value) => controller.resolve(value)}
/>
) : null
}
</AlertDialog>
)
}
function ConfirmContent({
onDismiss,
onResolve,
options,
}: {
onDismiss: () => void
onResolve: (value: boolean) => void
options: ConfirmRequestOptions
}) {
const t = useTranslate()
return (
<AlertDialogContent>
<MessageHeader options={options} />
<AlertDialogFooter>
<AlertDialogCancel onClick={onDismiss}>
{options.cancelLabel ?? t(overlayMessages.cancel)}
</AlertDialogCancel>
<AlertDialogAction
variant={options.destructive ? "destructive" : "default"}
onClick={() => onResolve(true)}
>
{options.actionLabel ?? t(overlayMessages.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
)
}
export type { ConfirmOptions } from "./types"
+126
View File
@@ -0,0 +1,126 @@
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import {
createDrawerHandle,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@workspace/ui/components/drawer"
import { XIcon } from "@workspace/ui/components/icon"
import { OverlayController, type OverlayRequest } from "./internal/controller"
import { renderOverlayContent } from "./internal/render-content"
import { useOverlayController } from "./internal/use-controller"
import { overlayMessages } from "./messages"
import { globalOverlayScope, type OverlayScope } from "./scope"
import type { DrawerOptions } from "./types"
type DrawerRequestOptions = Omit<DrawerOptions<unknown>, "scope">
const drawerChannel = Symbol("overlay.drawer")
function getDrawerController(scope: OverlayScope) {
return scope.getChannel(drawerChannel, () => {
const handle = createDrawerHandle<OverlayRequest<DrawerRequestOptions>>()
return new OverlayController<DrawerRequestOptions, typeof handle>({
handle,
isDismissible: (options) => options.dismissible !== false,
scope,
})
})
}
export function openDrawer<Result = void>(options: DrawerOptions<Result>) {
const { scope = globalOverlayScope, ...drawerOptions } = options
return getDrawerController(scope).open<Result | undefined>(
drawerOptions as DrawerRequestOptions,
undefined
)
}
export interface DrawerHostProps {
scope?: OverlayScope
}
export function DrawerHost({ scope }: DrawerHostProps) {
const controller = useOverlayController(scope, getDrawerController)
const activeOptions = controller.getActive()?.options
return (
<Drawer<OverlayRequest<DrawerRequestOptions>>
disablePointerDismissal={activeOptions?.dismissible === false}
handle={controller.handle}
modal={activeOptions?.modal}
showSwipeHandle={activeOptions?.showSwipeHandle}
snapPoints={activeOptions?.snapPoints}
swipeDirection={activeOptions?.swipeDirection}
onOpenChange={(open, details) => {
if (!open) controller.handleCloseRequest(details)
}}
onOpenChangeComplete={(open) => {
if (!open) controller.handleCloseComplete()
}}
>
{({ payload }) =>
payload ? (
<DrawerContentView
key={payload.id}
options={payload.options}
onDismiss={controller.dismiss}
onResolve={(value) => controller.resolve(value)}
/>
) : null
}
</Drawer>
)
}
function DrawerContentView({
onDismiss,
onResolve,
options,
}: {
onDismiss: () => void
onResolve: (value: unknown) => void
options: DrawerRequestOptions
}) {
const t = useTranslate()
const dismissible = options.dismissible !== false
return (
<DrawerContent className={options.className}>
<DrawerHeader>
<DrawerTitle>{options.title}</DrawerTitle>
{options.description != null && (
<DrawerDescription>{options.description}</DrawerDescription>
)}
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-auto p-4">
{renderOverlayContent(options.content, {
close: onResolve,
dismiss: onDismiss,
})}
</div>
{dismissible && (
<DrawerClose
aria-label={t(overlayMessages.close)}
onClick={onDismiss}
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute end-4 top-4"
/>
}
>
<XIcon aria-hidden="true" />
</DrawerClose>
)}
</DrawerContent>
)
}
export type { DrawerOptions } from "./types"
+18
View File
@@ -0,0 +1,18 @@
export {
createOverlayScope,
globalOverlayScope,
OverlayScope,
type CreateOverlayScopeOptions,
type OverlayLayer,
} from "./scope"
export {
OverlayScopeProvider,
type OverlayScopeProviderProps,
useOverlayScope,
useOverlayScopes,
} from "./scope-context"
export type {
OverlayContent,
OverlayControls,
ScopedOverlayOptions,
} from "./types"
@@ -0,0 +1,176 @@
import type { OverlayScope } from "../scope"
let nextRequestId = 0
export interface OverlayPrimitiveHandle<Payload> {
close: () => void
openWithPayload: (payload: Payload) => void
}
export interface OverlayRequest<Options> {
dismissedValue: unknown
id: string
options: Options
resolve: (value: unknown) => void
settled: boolean
}
interface CancelableChangeEventDetails {
cancel: () => void
}
export interface CreateOverlayControllerOptions<
Options,
Handle extends OverlayPrimitiveHandle<OverlayRequest<Options>>,
> {
handle: Handle
isDismissible?: (options: Options) => boolean
scope: OverlayScope
}
/** @internal A FIFO request channel around one Base UI handle. */
export class OverlayController<
Options,
Handle extends OverlayPrimitiveHandle<OverlayRequest<Options>>,
> {
readonly handle: Handle
private active?: OverlayRequest<Options>
private readonly isDismissible: (options: Options) => boolean
private readonly listeners = new Set<VoidFunction>()
private readonly queue: Array<OverlayRequest<Options>> = []
private readonly scope: OverlayScope
private hostCount = 0
private hostVersion = 0
private revision = 0
constructor({
handle,
isDismissible = () => true,
scope,
}: CreateOverlayControllerOptions<Options, Handle>) {
this.handle = handle
this.isDismissible = isDismissible
this.scope = scope
}
open<Result>(options: Options, dismissedValue: Result) {
return new Promise<Result>((resolve) => {
this.queue.push({
dismissedValue,
id: `overlay-${nextRequestId++}`,
options,
resolve: (value) => resolve(value as Result),
settled: false,
})
this.activateNext()
this.emit()
})
}
getActive() {
return this.active
}
resolve(value: unknown) {
this.settle(value, true)
}
dismiss = () => {
if (!this.active) return false
this.settle(this.active.dismissedValue, true)
return true
}
handleCloseRequest(eventDetails: CancelableChangeEventDetails) {
if (!this.active || this.active.settled) return
if (!this.isDismissible(this.active.options)) {
eventDetails.cancel()
return
}
this.settle(this.active.dismissedValue, false)
}
handleCloseComplete() {
if (!this.active) return
if (!this.active.settled) {
this.active.settled = true
this.active.resolve(this.active.dismissedValue)
this.scope.unregisterLayer(this.active.id)
}
this.active = undefined
this.activateNext()
this.emit()
}
subscribe = (listener: VoidFunction) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
getSnapshot = () => this.revision
getServerSnapshot = () => this.revision
attachHost() {
this.hostCount += 1
this.hostVersion += 1
return () => {
this.hostCount -= 1
const version = ++this.hostVersion
queueMicrotask(() => {
if (this.hostCount === 0 && this.hostVersion === version) this.reset()
})
}
}
private activateNext() {
if (this.active || this.queue.length === 0) return
const request = this.queue.shift()
if (!request) return
this.active = request
this.scope.registerLayer({
dismiss: this.dismiss,
dismissible: this.isDismissible(request.options),
id: request.id,
})
this.emit()
this.handle.openWithPayload(request)
}
private settle(value: unknown, closePrimitive: boolean) {
if (!this.active || this.active.settled) return
this.active.settled = true
this.active.resolve(value)
this.scope.unregisterLayer(this.active.id)
this.emit()
if (closePrimitive) this.handle.close()
}
private reset() {
for (const request of this.queue.splice(0)) {
request.settled = true
request.resolve(request.dismissedValue)
}
if (this.active) {
if (!this.active.settled) {
this.active.settled = true
this.active.resolve(this.active.dismissedValue)
}
this.scope.unregisterLayer(this.active.id)
this.handle.close()
this.active = undefined
}
this.emit()
}
private emit() {
this.revision += 1
this.listeners.forEach((listener) => listener())
}
}
@@ -0,0 +1,22 @@
import {
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@workspace/ui/components/alert-dialog"
import type { MessageOverlayOptions } from "../types"
export function MessageHeader({
options,
}: {
options: Omit<MessageOverlayOptions, "scope">
}) {
return (
<AlertDialogHeader>
<AlertDialogTitle>{options.title}</AlertDialogTitle>
{options.description != null && (
<AlertDialogDescription>{options.description}</AlertDialogDescription>
)}
</AlertDialogHeader>
)
}
@@ -0,0 +1,8 @@
import type { OverlayContent, OverlayControls } from "../types"
export function renderOverlayContent<Result>(
content: OverlayContent<Result>,
controls: OverlayControls<Result>
) {
return typeof content === "function" ? content(controls) : content
}
@@ -0,0 +1,29 @@
import * as React from "react"
import { useOverlayScope } from "../scope-context"
import type { OverlayScope } from "../scope"
import type {
OverlayController,
OverlayPrimitiveHandle,
OverlayRequest,
} from "./controller"
export function useOverlayController<
Options,
Handle extends OverlayPrimitiveHandle<OverlayRequest<Options>>,
>(
providedScope: OverlayScope | undefined,
getController: (scope: OverlayScope) => OverlayController<Options, Handle>
) {
const contextualScope = useOverlayScope()
const scope = providedScope ?? contextualScope
const controller = getController(scope)
React.useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getServerSnapshot
)
React.useEffect(() => controller.attachHost(), [controller])
return controller
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest"
import { overlayCatalogLocales } from "./catalogs"
import { messages as englishMessages } from "./en-US"
import { messages as simplifiedChineseMessages } from "./zh-Hans"
describe("overlay message catalogs", () => {
it("provide the same messages for every supported locale", () => {
expect(overlayCatalogLocales).toEqual(["en-US", "zh-Hans"])
expect(new Set(Object.keys(simplifiedChineseMessages))).toEqual(
new Set(Object.keys(englishMessages))
)
})
})
+16
View File
@@ -0,0 +1,16 @@
import type { overlayMessages } from "../messages"
export const overlayCatalogLocales = ["en-US", "zh-Hans"] as const
interface MessageDescriptorMap {
readonly [key: string]: {
readonly id: string
}
}
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
export type OverlayCatalogLocale = (typeof overlayCatalogLocales)[number]
export type OverlayMessageCatalog = Readonly<
Record<MessageId<typeof overlayMessages>, string>
>
+11
View File
@@ -0,0 +1,11 @@
import type { OverlayMessageCatalog } from "./catalogs"
export const locale = "en-US"
export const languageTag = "en-US"
export const messages = {
"overlays.actions.cancel": "Cancel",
"overlays.actions.close": "Close",
"overlays.actions.confirm": "Confirm",
"overlays.actions.okay": "OK",
"overlays.prompt.inputLabel": "Value",
} as const satisfies OverlayMessageCatalog
+11
View File
@@ -0,0 +1,11 @@
import type { OverlayMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-Hans"
export const messages = {
"overlays.actions.cancel": "取消",
"overlays.actions.close": "关闭",
"overlays.actions.confirm": "确认",
"overlays.actions.okay": "确定",
"overlays.prompt.inputLabel": "输入值",
} as const satisfies OverlayMessageCatalog
+9
View File
@@ -0,0 +1,9 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const overlayMessages = {
cancel: { id: "overlays.actions.cancel", message: "Cancel" },
close: { id: "overlays.actions.close", message: "Close" },
confirm: { id: "overlays.actions.confirm", message: "Confirm" },
inputLabel: { id: "overlays.prompt.inputLabel", message: "Value" },
okay: { id: "overlays.actions.okay", message: "OK" },
} as const satisfies Record<string, MessageDescriptor>
+121
View File
@@ -0,0 +1,121 @@
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import {
createDialogHandle,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog"
import { XIcon } from "@workspace/ui/components/icon"
import { OverlayController, type OverlayRequest } from "./internal/controller"
import { renderOverlayContent } from "./internal/render-content"
import { useOverlayController } from "./internal/use-controller"
import { overlayMessages } from "./messages"
import { globalOverlayScope, type OverlayScope } from "./scope"
import type { ModalOptions } from "./types"
type ModalRequestOptions = Omit<ModalOptions<unknown>, "scope">
const modalChannel = Symbol("overlay.modal")
function getModalController(scope: OverlayScope) {
return scope.getChannel(modalChannel, () => {
const handle = createDialogHandle<OverlayRequest<ModalRequestOptions>>()
return new OverlayController<ModalRequestOptions, typeof handle>({
handle,
isDismissible: (options) => options.dismissible !== false,
scope,
})
})
}
export function openModal<Result = void>(options: ModalOptions<Result>) {
const { scope = globalOverlayScope, ...modalOptions } = options
return getModalController(scope).open<Result | undefined>(
modalOptions as ModalRequestOptions,
undefined
)
}
export interface ModalHostProps {
scope?: OverlayScope
}
export function ModalHost({ scope }: ModalHostProps) {
const controller = useOverlayController(scope, getModalController)
const activeOptions = controller.getActive()?.options
return (
<Dialog<OverlayRequest<ModalRequestOptions>>
disablePointerDismissal={activeOptions?.dismissible === false}
handle={controller.handle}
modal={activeOptions?.modal}
onOpenChange={(open, details) => {
if (!open) controller.handleCloseRequest(details)
}}
onOpenChangeComplete={(open) => {
if (!open) controller.handleCloseComplete()
}}
>
{({ payload }) =>
payload ? (
<ModalContent
key={payload.id}
options={payload.options}
onDismiss={controller.dismiss}
onResolve={(value) => controller.resolve(value)}
/>
) : null
}
</Dialog>
)
}
function ModalContent({
onDismiss,
onResolve,
options,
}: {
onDismiss: () => void
onResolve: (value: unknown) => void
options: ModalRequestOptions
}) {
const t = useTranslate()
const dismissible = options.dismissible !== false
return (
<DialogContent className={options.className} showCloseButton={false}>
<DialogHeader>
<DialogTitle>{options.title}</DialogTitle>
{options.description != null && (
<DialogDescription>{options.description}</DialogDescription>
)}
</DialogHeader>
{renderOverlayContent(options.content, {
close: onResolve,
dismiss: onDismiss,
})}
{dismissible && (
<DialogClose
aria-label={t(overlayMessages.close)}
onClick={onDismiss}
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute end-4 top-4"
/>
}
>
<XIcon aria-hidden="true" />
</DialogClose>
)}
</DialogContent>
)
}
export type { ModalOptions } from "./types"
+80
View File
@@ -0,0 +1,80 @@
import * as React from "react"
import { useOverlayScope, useOverlayScopes } from "./scope-context"
import type { OverlayScope } from "./scope"
/** A router-owned navigation attempt that is currently blocked. */
export interface OverlayNavigationAttempt {
cancel: () => void
/** Continues this exact attempt without notifying the adapter again. */
proceed: () => void
}
/** Implement this bridge with a router's blocker API. */
export interface OverlayNavigationAdapter {
subscribe: (
listener: (attempt: OverlayNavigationAttempt) => void
) => VoidFunction
}
export interface OverlayNavigationContext {
attempt: OverlayNavigationAttempt
scopes: readonly OverlayScope[]
}
export type OverlayNavigationInterceptor = (
context: OverlayNavigationContext
) => void
export const dismissOverlayNavigationInterceptor: OverlayNavigationInterceptor =
({ attempt, scopes }) => {
let topmostScope: OverlayScope | undefined
let topmostOrder = -1
for (const scope of scopes) {
const order = scope.getTopmostLayer()?.order ?? -1
if (order > topmostOrder) {
topmostScope = scope
topmostOrder = order
}
}
if (topmostScope?.interceptNavigation()) attempt.cancel()
else attempt.proceed()
}
export interface OverlayNavigationGuardProps {
adapter: OverlayNavigationAdapter
interceptor?: OverlayNavigationInterceptor
scope?: OverlayScope
scopes?: readonly OverlayScope[]
}
export function OverlayNavigationGuard({
adapter,
interceptor = dismissOverlayNavigationInterceptor,
scope,
scopes,
}: OverlayNavigationGuardProps) {
const contextualScope = useOverlayScope()
const namedScopes = useOverlayScopes()
const resolvedScopes = React.useMemo(
() =>
Array.from(
new Set(
scopes ?? [scope ?? contextualScope, ...Object.values(namedScopes)]
)
),
[contextualScope, namedScopes, scope, scopes]
)
React.useEffect(
() =>
adapter.subscribe((attempt) => {
interceptor({ attempt, scopes: resolvedScopes })
}),
[adapter, interceptor, resolvedScopes]
)
return null
}
+280
View File
@@ -0,0 +1,280 @@
// @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()
})
})
+119
View File
@@ -0,0 +1,119 @@
import * as React from "react"
import { useTranslate } from "@workspace/i18n"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
createAlertDialogHandle,
} from "@workspace/ui/components/alert-dialog"
import { Input } from "@workspace/ui/components/input"
import { OverlayController, type OverlayRequest } from "./internal/controller"
import { MessageHeader } from "./internal/message-header"
import { useOverlayController } from "./internal/use-controller"
import { overlayMessages } from "./messages"
import { globalOverlayScope, type OverlayScope } from "./scope"
import type { PromptOptions } from "./types"
type PromptRequestOptions = Omit<PromptOptions, "scope">
const promptChannel = Symbol("overlay.prompt")
function getPromptController(scope: OverlayScope) {
return scope.getChannel(promptChannel, () => {
const handle =
createAlertDialogHandle<OverlayRequest<PromptRequestOptions>>()
return new OverlayController<PromptRequestOptions, typeof handle>({
handle,
scope,
})
})
}
export function openPrompt(options: PromptOptions) {
const { scope = globalOverlayScope, ...promptOptions } = options
return getPromptController(scope).open<string | null>(promptOptions, null)
}
export interface PromptHostProps {
scope?: OverlayScope
}
export function PromptHost({ scope }: PromptHostProps) {
const controller = useOverlayController(scope, getPromptController)
return (
<AlertDialog<OverlayRequest<PromptRequestOptions>>
handle={controller.handle}
onOpenChange={(open, details) => {
if (!open) controller.handleCloseRequest(details)
}}
onOpenChangeComplete={(open) => {
if (!open) controller.handleCloseComplete()
}}
>
{({ payload }) =>
payload ? (
<PromptContent
key={payload.id}
options={payload.options}
onDismiss={controller.dismiss}
onResolve={(value) => controller.resolve(value)}
/>
) : null
}
</AlertDialog>
)
}
function PromptContent({
onDismiss,
onResolve,
options,
}: {
onDismiss: () => void
onResolve: (value: string) => void
options: PromptRequestOptions
}) {
const t = useTranslate()
const inputId = React.useId()
const [value, setValue] = React.useState(options.defaultValue ?? "")
return (
<AlertDialogContent>
<form
className="contents"
onSubmit={(event) => {
event.preventDefault()
onResolve(value)
}}
>
<MessageHeader options={options} />
<label className="grid gap-2 text-sm" htmlFor={inputId}>
<span>{options.inputLabel ?? t(overlayMessages.inputLabel)}</span>
<Input
autoFocus
{...options.inputProps}
id={inputId}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
</label>
<AlertDialogFooter>
<AlertDialogCancel onClick={onDismiss}>
{options.cancelLabel ?? t(overlayMessages.cancel)}
</AlertDialogCancel>
<AlertDialogAction
type="submit"
variant={options.destructive ? "destructive" : "default"}
>
{options.actionLabel ?? t(overlayMessages.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</form>
</AlertDialogContent>
)
}
export type { PromptOptions } from "./types"
+70
View File
@@ -0,0 +1,70 @@
import * as React from "react"
import {
createOverlayScope,
globalOverlayScope,
type OverlayScope,
} from "./scope"
interface OverlayScopeContextValue {
scope: OverlayScope
scopes: Readonly<Record<string, OverlayScope>>
}
const OverlayScopeContext = React.createContext<OverlayScopeContextValue>({
scope: globalOverlayScope,
scopes: { global: globalOverlayScope },
})
OverlayScopeContext.displayName = "OverlayScopeContext"
export interface OverlayScopeProviderProps {
children?: React.ReactNode
/** Creates a local scope with this name when `scope` is omitted. */
name?: string
scope?: OverlayScope
scopes?: Readonly<Record<string, OverlayScope>>
}
/** Provides scope lookup only; feature hosts remain explicit and tree-shakeable. */
export function OverlayScopeProvider({
children,
name,
scope,
scopes,
}: OverlayScopeProviderProps) {
const parent = React.useContext(OverlayScopeContext)
const [generatedScope] = React.useState(() => createOverlayScope({ name }))
const currentScope = scope ?? (name ? generatedScope : parent.scope)
const namedScopes = React.useMemo(() => {
const next = { ...parent.scopes, ...scopes }
if (name) next[name] = currentScope
return next
}, [currentScope, name, parent.scopes, scopes])
const value = React.useMemo(
() => ({ scope: currentScope, scopes: namedScopes }),
[currentScope, namedScopes]
)
return (
<OverlayScopeContext.Provider value={value}>
{children}
</OverlayScopeContext.Provider>
)
}
export function useOverlayScope(name?: string) {
const context = React.useContext(OverlayScopeContext)
if (!name) return context.scope
const scope = context.scopes[name]
if (!scope) {
throw new Error(`No overlay scope named "${name}" exists in this subtree`)
}
return scope
}
export function useOverlayScopes() {
return React.useContext(OverlayScopeContext).scopes
}
+71
View File
@@ -0,0 +1,71 @@
let nextScopeId = 0
let nextLayerOrder = 0
export interface CreateOverlayScopeOptions {
name?: string
}
export interface OverlayLayer {
dismiss: () => void
dismissible: boolean
id: string
order: number
}
/** UI-agnostic storage shared by independently imported overlay features. */
export class OverlayScope {
readonly id: string
readonly name?: string
private readonly channels = new Map<symbol, unknown>()
private readonly layers = new Map<string, OverlayLayer>()
constructor({ name }: CreateOverlayScopeOptions = {}) {
this.id = `overlay-scope-${nextScopeId++}`
this.name = name
}
/** @internal Used by atomic overlay feature modules. */
getChannel<Channel>(key: symbol, create: () => Channel): Channel {
const existing = this.channels.get(key)
if (existing) return existing as Channel
const channel = create()
this.channels.set(key, channel)
return channel
}
/** @internal Registers a visible layer for navigation interception. */
registerLayer(layer: Omit<OverlayLayer, "order">) {
const registered = { ...layer, order: nextLayerOrder++ }
this.layers.set(registered.id, registered)
return registered.order
}
/** @internal Removes a layer after it has settled. */
unregisterLayer(id: string) {
this.layers.delete(id)
}
getTopmostLayer() {
let topmost: OverlayLayer | undefined
for (const layer of this.layers.values()) {
if (!topmost || layer.order > topmost.order) topmost = layer
}
return topmost
}
/** Cancels navigation while a layer is open and dismisses it when allowed. */
interceptNavigation() {
const layer = this.getTopmostLayer()
if (!layer) return false
if (layer.dismissible) layer.dismiss()
return true
}
}
export function createOverlayScope(options?: CreateOverlayScopeOptions) {
return new OverlayScope(options)
}
export const globalOverlayScope = createOverlayScope({ name: "global" })
+127
View File
@@ -0,0 +1,127 @@
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import { XIcon } from "@workspace/ui/components/icon"
import {
createSheetHandle,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@workspace/ui/components/sheet"
import { OverlayController, type OverlayRequest } from "./internal/controller"
import { renderOverlayContent } from "./internal/render-content"
import { useOverlayController } from "./internal/use-controller"
import { overlayMessages } from "./messages"
import { globalOverlayScope, type OverlayScope } from "./scope"
import type { SheetOptions } from "./types"
type SheetRequestOptions = Omit<SheetOptions<unknown>, "scope">
const sheetChannel = Symbol("overlay.sheet")
function getSheetController(scope: OverlayScope) {
return scope.getChannel(sheetChannel, () => {
const handle = createSheetHandle<OverlayRequest<SheetRequestOptions>>()
return new OverlayController<SheetRequestOptions, typeof handle>({
handle,
isDismissible: (options) => options.dismissible !== false,
scope,
})
})
}
export function openSheet<Result = void>(options: SheetOptions<Result>) {
const { scope = globalOverlayScope, ...sheetOptions } = options
return getSheetController(scope).open<Result | undefined>(
sheetOptions as SheetRequestOptions,
undefined
)
}
export interface SheetHostProps {
scope?: OverlayScope
}
export function SheetHost({ scope }: SheetHostProps) {
const controller = useOverlayController(scope, getSheetController)
const activeOptions = controller.getActive()?.options
return (
<Sheet<OverlayRequest<SheetRequestOptions>>
disablePointerDismissal={activeOptions?.dismissible === false}
handle={controller.handle}
modal={activeOptions?.modal}
onOpenChange={(open, details) => {
if (!open) controller.handleCloseRequest(details)
}}
onOpenChangeComplete={(open) => {
if (!open) controller.handleCloseComplete()
}}
>
{({ payload }) =>
payload ? (
<SheetContentView
key={payload.id}
options={payload.options}
onDismiss={controller.dismiss}
onResolve={(value) => controller.resolve(value)}
/>
) : null
}
</Sheet>
)
}
function SheetContentView({
onDismiss,
onResolve,
options,
}: {
onDismiss: () => void
onResolve: (value: unknown) => void
options: SheetRequestOptions
}) {
const t = useTranslate()
const dismissible = options.dismissible !== false
return (
<SheetContent
className={options.className}
side={options.side}
showCloseButton={false}
>
<SheetHeader>
<SheetTitle>{options.title}</SheetTitle>
{options.description != null && (
<SheetDescription>{options.description}</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-auto px-4 pb-4">
{renderOverlayContent(options.content, {
close: onResolve,
dismiss: onDismiss,
})}
</div>
{dismissible && (
<SheetClose
aria-label={t(overlayMessages.close)}
onClick={onDismiss}
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute end-4 top-4"
/>
}
>
<XIcon aria-hidden="true" />
</SheetClose>
)}
</SheetContent>
)
}
export type { SheetOptions } from "./types"
+1
View File
@@ -0,0 +1 @@
@source "../";
+64
View File
@@ -0,0 +1,64 @@
import { Toaster, createToastManager } from "@workspace/ui/components/toast"
import { useOverlayScope } from "./scope-context"
import { globalOverlayScope, type OverlayScope } from "./scope"
import type {
ToastOptions,
ToastPromiseOptions,
ToastUpdateOptions,
} from "./types"
const toastChannel = Symbol("overlay.toast")
function getToastManager(scope: OverlayScope) {
return scope.getChannel(toastChannel, createToastManager)
}
export function openToast<Data extends object = Record<string, never>>(
options: ToastOptions<Data>
) {
const { scope = globalOverlayScope, ...toastOptions } = options
return getToastManager(scope).add(toastOptions)
}
export function updateToast<Data extends object = Record<string, never>>(
id: string,
options: ToastUpdateOptions<Data>
) {
const { scope = globalOverlayScope, ...toastOptions } = options
getToastManager(scope).update(id, toastOptions)
}
export function dismissToast({
id,
scope = globalOverlayScope,
}: {
id?: string
scope?: OverlayScope
} = {}) {
getToastManager(scope).close(id)
}
export function toastPromise<
Value,
Data extends object = Record<string, never>,
>(promise: Promise<Value>, options: ToastPromiseOptions<Value, Data>) {
const { scope = globalOverlayScope, ...toastOptions } = options
return getToastManager(scope).promise(promise, toastOptions)
}
export interface ToastHostProps {
scope?: OverlayScope
}
export function ToastHost({ scope }: ToastHostProps) {
const contextualScope = useOverlayScope()
const resolvedScope = scope ?? contextualScope
return <Toaster toastManager={getToastManager(resolvedScope)} />
}
export type {
ToastOptions,
ToastPromiseOptions,
ToastUpdateOptions,
} from "./types"
+87
View File
@@ -0,0 +1,87 @@
import type * as React from "react"
import type {
ToastManagerAddOptions,
ToastManagerPromiseOptions,
ToastManagerUpdateOptions,
} from "@base-ui/react/toast"
import type { OverlayScope } from "./scope"
export interface ScopedOverlayOptions {
/** Uses the global scope when omitted. */
scope?: OverlayScope
}
export interface OverlayControls<Result> {
close: (result: Result) => void
dismiss: () => void
}
export type OverlayContent<Result = void> =
| React.ReactNode
| ((controls: OverlayControls<Result>) => React.ReactNode)
export interface MessageOverlayOptions extends ScopedOverlayOptions {
actionLabel?: React.ReactNode
description?: React.ReactNode
title: React.ReactNode
}
export type AlertOptions = MessageOverlayOptions
export interface ConfirmOptions extends MessageOverlayOptions {
cancelLabel?: React.ReactNode
destructive?: boolean
}
export interface PromptOptions extends MessageOverlayOptions {
cancelLabel?: React.ReactNode
defaultValue?: string
destructive?: boolean
inputLabel?: React.ReactNode
inputProps?: Omit<
React.ComponentPropsWithoutRef<"input">,
"defaultValue" | "onChange" | "value"
>
}
export interface ContentOverlayOptions<Result> extends ScopedOverlayOptions {
className?: string
content: OverlayContent<Result>
description?: React.ReactNode
dismissible?: boolean
title: React.ReactNode
}
export interface ModalOptions<
Result = void,
> extends ContentOverlayOptions<Result> {
modal?: boolean | "trap-focus"
}
export interface SheetOptions<
Result = void,
> extends ContentOverlayOptions<Result> {
modal?: boolean | "trap-focus"
side?: "bottom" | "left" | "right" | "top"
}
export interface DrawerOptions<
Result = void,
> extends ContentOverlayOptions<Result> {
modal?: boolean | "trap-focus"
showSwipeHandle?: boolean
snapPoints?: Array<number | string>
swipeDirection?: "down" | "left" | "right" | "up"
}
export type ToastOptions<Data extends object = Record<string, never>> =
ToastManagerAddOptions<Data> & ScopedOverlayOptions
export type ToastUpdateOptions<Data extends object = Record<string, never>> =
ToastManagerUpdateOptions<Data> & ScopedOverlayOptions
export type ToastPromiseOptions<
Value,
Data extends object = Record<string, never>,
> = ToastManagerPromiseOptions<Value, Data> & ScopedOverlayOptions