feat(overlays): add managed overlay package
This commit is contained in:
@@ -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"
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" })
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
@source "../";
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user