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
@@ -0,0 +1,64 @@
---
title: Lifecycle and route interception
description: Understand results, queues, Host lifetime, and router adapters.
order: 20
toc:
- id: result-contracts
title: Result contracts
- id: queue
title: Queues and Hosts
- id: dismissal
title: Dismissal
- id: navigation
title: Route interception
---
## Result contracts {#result-contracts}
`openConfirm` uses `false` for cancellation, `openPrompt` uses `null`, and custom Modals, Sheets, and Drawers use `undefined` when no result is supplied. `close(value)` is checked against the generic passed to `openModal<Result>()`.
```tsx
import { openModal } from "@workspace/overlays/modal"
const productId = await openModal<string>({
title: "Choose a product",
content: ({ close }) => (
<ProductPicker onSelect={(product) => close(product.id)} />
),
})
```
## Queues and Hosts {#queue}
Within one Scope, each feature has a lazily created channel and Base UI handle. Calls of the same kind enter a FIFO queue and the next request appears after the previous closing animation completes. Different features can remain visible together, so a Modal may open a Confirm.
Unmounting a Host settles its pending requests with their cancellation values. React Strict Mode's simulated unmount does not incorrectly cancel requests.
## Dismissal {#dismissal}
Custom content can call `dismiss()`. The close button, Escape key, and backdrop press enter the same cancellation path. Setting `dismissible: false` blocks external dismissal, while the content may still explicitly call `close(result)` or `dismiss()`.
## Route interception {#navigation}
`@workspace/overlays/navigation` depends only on Scope, not on a concrete overlay feature or router:
```tsx
import { OverlayNavigationGuard } from "@workspace/overlays/navigation"
const adapter: OverlayNavigationAdapter = {
subscribe(listener) {
return router.block((transition) => {
listener({
cancel: transition.reset,
proceed: transition.retry,
})
})
},
}
<OverlayNavigationGuard adapter={adapter} />
```
The default Guard examines the current and named local Scopes and finds the last-opened layer. It dismisses an allowed layer and cancels navigation. A layer with `dismissible: false` remains open and keeps blocking. It calls `proceed()` only when no layer is open.
The adapter's `proceed()` must continue the exact blocked attempt instead of starting a new navigation that triggers the blocker again. Pass a custom `interceptor` to replace the policy.
@@ -0,0 +1,73 @@
---
title: Custom overlay examples
description: Build result-producing modals, sheets, and drawers with UI components.
order: 30
toc:
- id: typed-modal
title: Typed modal
- id: sheet
title: Sheet
- id: drawer
title: Drawer
---
## Typed modal {#typed-modal}
The render callback receives `close` and `dismiss`. `close` returns an application result while `dismiss` follows the cancellation path.
```tsx
import { openModal } from "@workspace/overlays/modal"
import { Button } from "@workspace/ui/components/button"
function PlanButton() {
async function choosePlan() {
const plan = await openModal<"free" | "pro">({
title: "Choose a plan",
description: "You can change this later.",
content: ({ close, dismiss }) => (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={dismiss}>
Cancel
</Button>
<Button onClick={() => close("free")}>Free</Button>
<Button onClick={() => close("pro")}>Pro</Button>
</div>
),
})
if (plan) updatePlan(plan)
}
return <Button onClick={choosePlan}>Change plan</Button>
}
```
## Sheet {#sheet}
```tsx
import { openSheet } from "@workspace/overlays/sheet"
await openSheet({
title: "Filters",
side: "right",
content: <ProductFilters />,
})
```
Use a sheet for a fixed edge panel. `side` accepts `top`, `right`, `bottom`, or `left`.
## Drawer {#drawer}
```tsx
import { openDrawer } from "@workspace/overlays/drawer"
await openDrawer({
title: "Choose a delivery time",
content: <DeliverySlots />,
showSwipeHandle: true,
snapPoints: [0.5, 1],
swipeDirection: "down",
})
```
Use a drawer for touch-oriented interaction with configurable swipe direction and Base UI snap points.
@@ -0,0 +1,136 @@
---
title: Setup and basic interactions
description: Mount atomic Hosts on demand and open overlays in global or local scopes.
order: 10
toc:
- id: atomic-hosts
title: Atomic Hosts
- id: global-scope
title: Default global scope
- id: local-scopes
title: Local and named scopes
- id: message-overlays
title: Message overlays
- id: toast
title: Toast
---
## Atomic Hosts {#atomic-hosts}
Import the Tailwind source from the application stylesheet:
```css
@import "@workspace/overlays/globals.css";
```
Mount only the Hosts used by the application. This application does not import Sheet, Drawer, or Toast:
```tsx
import { AlertHost } from "@workspace/overlays/alert"
import { ConfirmHost } from "@workspace/overlays/confirm"
import { ModalHost } from "@workspace/overlays/modal"
export function Root({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<AlertHost />
<ConfirmHost />
<ModalHost />
</>
)
}
```
Applications that intentionally use every feature may import the convenience `OverlayHost` from `@workspace/overlays/all`. It eagerly imports every feature and is therefore not the default recommendation.
## Default global scope {#global-scope}
`openXxx()` functions and Hosts without a `scope` prop use `globalOverlayScope`. The caller does not need React context:
```tsx
import { openAlert } from "@workspace/overlays/alert"
await openAlert({
title: "Export complete",
description: "The file is ready to download.",
})
```
The application must still mount the matching `AlertHost`. A request may be opened before its Host mounts; the Base UI handle renders it when the Host appears.
## Local and named scopes {#local-scopes}
Scopes are real objects rather than names in a global registry:
```tsx
import {
createOverlayScope,
OverlayScopeProvider,
useOverlayScope,
} from "@workspace/overlays"
import { ModalHost, openModal } from "@workspace/overlays/modal"
const editorScope = createOverlayScope({ name: "editor" })
const pickerScope = createOverlayScope({ name: "picker" })
function EditorRoot({ children }: { children: React.ReactNode }) {
return (
<OverlayScopeProvider scope={editorScope} scopes={{ picker: pickerScope }}>
{children}
<ModalHost />
<ModalHost scope={pickerScope} />
</OverlayScopeProvider>
)
}
function OpenPickerButton() {
const scope = useOverlayScope("picker")
return (
<button
onClick={() =>
openModal({ scope, title: "Choose an asset", content: <AssetPicker /> })
}
>
Choose an asset
</button>
)
}
```
`<OverlayScopeProvider name="editor">` can create a local scope automatically. Non-React modules should retain and explicitly pass the object returned by `createOverlayScope()`.
## Message overlays {#message-overlays}
```tsx
import { openConfirm } from "@workspace/overlays/confirm"
async function removeProject() {
const accepted = await openConfirm({
title: "Delete project?",
description: "This action cannot be undone.",
destructive: true,
})
if (accepted) await deleteProject()
}
```
`openAlert()` returns `Promise<void>`, `openConfirm()` returns a boolean, and `openPrompt()` returns a string or `null` after cancellation.
## Toast {#toast}
```tsx
import { dismissToast, openToast, updateToast } from "@workspace/overlays/toast"
const id = openToast({
title: "Saved",
description: "Your changes are synchronized.",
type: "success",
})
updateToast(id, { description: "All devices are synchronized." })
dismissToast({ id })
```
Toast is also isolated by Scope and rendered by the matching `ToastHost`.
+32
View File
@@ -0,0 +1,32 @@
---
title: Overview
description: Coordinate temporary application overlays through one API.
order: 1
toc:
- id: package-role
title: Package role
- id: supported-overlays
title: Supported overlays
- id: design-boundary
title: Design boundary
---
## Package role {#package-role}
`@workspace/overlays` is an asynchronous overlay orchestration layer built on Base UI handles and `@workspace/ui`. Application code starts interactions through atomic functions such as `openAlert()` and `openModal()` instead of owning separate `open` state.
Every feature has its own subpath and Host. For example, `@workspace/overlays/modal` contains only the Modal implementation. An `OverlayScope` creates feature channels lazily instead of constructing every Base UI handle up front. Requests of the same kind are queued and never replace a Promise that is still pending.
## Supported overlays {#supported-overlays}
- `alert` waits until a message is acknowledged.
- `confirm` returns `true` or `false`.
- `prompt` returns the entered string, or `null` after cancellation.
- `modal` displays centered custom content.
- `sheet` displays a panel from a page edge.
- `drawer` adds dragging, direction, and snap-point behavior.
- `toast` reuses the Base UI toast manager for notifications, updates, and Promise states.
## Design boundary {#design-boundary}
The package owns scopes, request queues, Promise settlement, and standard structure without duplicating the underlying UI primitives. The root entry exports only scope capabilities; import concrete UI from subpaths such as `/alert`, `/modal`, and `/sheet` to preserve explicit tree-shaking boundaries.
@@ -0,0 +1,64 @@
---
title: 生命周期与路由拦截
description: 理解结果、队列、Host 生命周期以及路由库适配。
order: 20
toc:
- id: result-contracts
title: 结果约定
- id: queue
title: 队列与 Host
- id: dismissal
title: 关闭行为
- id: navigation
title: 路由拦截
---
## 结果约定 {#result-contracts}
`openConfirm` 使用 `false` 表示取消,`openPrompt` 使用 `null`,自定义 Modal、Sheet 和 Drawer 使用 `undefined` 表示没有结果。`close(value)` 会根据 `openModal<Result>()` 的泛型检查结果。
```tsx
import { openModal } from "@workspace/overlays/modal"
const productId = await openModal<string>({
title: "选择产品",
content: ({ close }) => (
<ProductPicker onSelect={(product) => close(product.id)} />
),
})
```
## 队列与 Host {#queue}
同一个 Scope 中,每种 feature 都有一个按需创建的 channel 和 Base UI handle。同类调用进入 FIFO 队列,前一个关闭动画结束后才显示下一个。不同 feature 可以同时显示,例如 Modal 内可以继续打开 Confirm。
Host 卸载时会用取消值结算它仍在等待的请求。React Strict Mode 的模拟卸载不会错误取消请求。
## 关闭行为 {#dismissal}
自定义内容可以调用 `dismiss()`。关闭按钮、Escape 和遮罩点击也进入取消路径。设置 `dismissible: false` 会阻止外部关闭,但内容仍可以显式调用 `close(result)` 或 `dismiss()`。
## 路由拦截 {#navigation}
`@workspace/overlays/navigation` 只依赖 Scope,不依赖具体 overlay feature 或路由库:
```tsx
import { OverlayNavigationGuard } from "@workspace/overlays/navigation"
const adapter: OverlayNavigationAdapter = {
subscribe(listener) {
return router.block((transition) => {
listener({
cancel: transition.reset,
proceed: transition.retry,
})
})
},
}
<OverlayNavigationGuard adapter={adapter} />
```
默认 Guard 检查当前和命名局部 Scope,找到最后打开的浮层。如果允许 dismiss,则关闭浮层并取消跳转;`dismissible: false` 会保留浮层并继续阻止跳转;没有浮层时才调用 `proceed()`。
适配器的 `proceed()` 必须继续已经被阻塞的同一次跳转,不能重新发起一个会再次触发 blocker 的导航。需要不同策略时,可以传入自定义 `interceptor`。
@@ -0,0 +1,73 @@
---
title: 自定义浮层示例
description: 使用 UI 组件构建有返回值的 modal、sheet 和 drawer。
order: 30
toc:
- id: typed-modal
title: 类型化 Modal
- id: sheet
title: Sheet
- id: drawer
title: Drawer
---
## 类型化 Modal {#typed-modal}
render callback 接收 `close` 和 `dismiss`。`close` 返回业务结果,`dismiss` 进入取消路径。
```tsx
import { openModal } from "@workspace/overlays/modal"
import { Button } from "@workspace/ui/components/button"
function PlanButton() {
async function choosePlan() {
const plan = await openModal<"free" | "pro">({
title: "选择方案",
description: "以后仍然可以修改。",
content: ({ close, dismiss }) => (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={dismiss}>
取消
</Button>
<Button onClick={() => close("free")}>免费版</Button>
<Button onClick={() => close("pro")}>专业版</Button>
</div>
),
})
if (plan) updatePlan(plan)
}
return <Button onClick={choosePlan}>修改方案</Button>
}
```
## Sheet {#sheet}
```tsx
import { openSheet } from "@workspace/overlays/sheet"
await openSheet({
title: "筛选条件",
side: "right",
content: <ProductFilters />,
})
```
Sheet 适合固定边缘面板。`side` 支持 `top`、`right`、`bottom` 和 `left`。
## Drawer {#drawer}
```tsx
import { openDrawer } from "@workspace/overlays/drawer"
await openDrawer({
title: "选择配送时间",
content: <DeliverySlots />,
showSwipeHandle: true,
snapPoints: [0.5, 1],
swipeDirection: "down",
})
```
Drawer 适合触摸交互,可以配置滑动方向和 Base UI snap points。
@@ -0,0 +1,136 @@
---
title: 安装与基础交互
description: 按需挂载原子 Host,并使用全局或局部 Scope 打开浮层。
order: 10
toc:
- id: atomic-hosts
title: 原子 Host
- id: global-scope
title: 默认全局 Scope
- id: local-scopes
title: 局部和命名局部
- id: message-overlays
title: 消息类浮层
- id: toast
title: Toast
---
## 原子 Host {#atomic-hosts}
在应用样式入口导入 Tailwind source:
```css
@import "@workspace/overlays/globals.css";
```
只挂载应用实际使用的 Host。下面的应用不会引入 Sheet、Drawer 或 Toast:
```tsx
import { AlertHost } from "@workspace/overlays/alert"
import { ConfirmHost } from "@workspace/overlays/confirm"
import { ModalHost } from "@workspace/overlays/modal"
export function Root({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<AlertHost />
<ConfirmHost />
<ModalHost />
</>
)
}
```
确定会使用全部能力的应用也可以从 `@workspace/overlays/all` 导入便捷的 `OverlayHost`。它会主动引入所有 feature,因此不是默认推荐方式。
## 默认全局 Scope {#global-scope}
`openXxx()` 和没有 `scope` prop 的 Host 默认使用 `globalOverlayScope`。调用方不需要 React Context:
```tsx
import { openAlert } from "@workspace/overlays/alert"
await openAlert({
title: "导出完成",
description: "文件已经可以下载。",
})
```
应用仍需挂载对应的 `AlertHost`。打开请求可以发生在 Host 挂载之前,Base UI handle 会在 Host 出现后渲染它。
## 局部和命名局部 {#local-scopes}
Scope 是实际对象,不使用全局字符串注册表:
```tsx
import {
createOverlayScope,
OverlayScopeProvider,
useOverlayScope,
} from "@workspace/overlays"
import { ModalHost, openModal } from "@workspace/overlays/modal"
const editorScope = createOverlayScope({ name: "editor" })
const pickerScope = createOverlayScope({ name: "picker" })
function EditorRoot({ children }: { children: React.ReactNode }) {
return (
<OverlayScopeProvider scope={editorScope} scopes={{ picker: pickerScope }}>
{children}
<ModalHost />
<ModalHost scope={pickerScope} />
</OverlayScopeProvider>
)
}
function OpenPickerButton() {
const scope = useOverlayScope("picker")
return (
<button
onClick={() =>
openModal({ scope, title: "选择资源", content: <AssetPicker /> })
}
>
选择资源
</button>
)
}
```
`<OverlayScopeProvider name="editor">` 可以自动创建一个局部 Scope。非 React 模块应保存并显式传递 `createOverlayScope()` 返回的对象。
## 消息类浮层 {#message-overlays}
```tsx
import { openConfirm } from "@workspace/overlays/confirm"
async function removeProject() {
const accepted = await openConfirm({
title: "删除项目?",
description: "此操作无法撤销。",
destructive: true,
})
if (accepted) await deleteProject()
}
```
`openAlert()` 返回 `Promise<void>`;`openConfirm()` 返回布尔值;`openPrompt()` 返回字符串,并在取消时返回 `null`。
## Toast {#toast}
```tsx
import { dismissToast, openToast, updateToast } from "@workspace/overlays/toast"
const id = openToast({
title: "保存成功",
description: "修改已经同步。",
type: "success",
})
updateToast(id, { description: "所有设备均已同步。" })
dismissToast({ id })
```
Toast 同样按 Scope 隔离,并由对应的 `ToastHost` 渲染。
@@ -0,0 +1,32 @@
---
title: 概览
description: 使用统一 API 编排应用中的临时浮层。
order: 1
toc:
- id: package-role
title: 包的职责
- id: supported-overlays
title: 支持的浮层
- id: design-boundary
title: 设计边界
---
## 包的职责 {#package-role}
`@workspace/overlays` 是基于 Base UI handle 和 `@workspace/ui` 的异步浮层编排层。业务代码通过 `openAlert()`、`openModal()` 等原子函数发起交互,不需要分别维护 `open` 状态。
每种能力都使用独立子路径和 Host,例如 `@workspace/overlays/modal` 只包含 Modal 所需实现。`OverlayScope` 按需创建已经使用的 feature channel,不会预先构造所有 Base UI handle。同类请求按顺序显示,不会覆盖仍在等待的 Promise。
## 支持的浮层 {#supported-overlays}
- `alert`:等待用户确认消息。
- `confirm`:返回 `true` 或 `false`。
- `prompt`:返回输入字符串,取消时返回 `null`。
- `modal`:居中的自定义内容浮层。
- `sheet`:从页面边缘进入的面板。
- `drawer`:支持拖动、方向和吸附点的面板。
- `toast`:复用 Base UI toast manager 的通知、更新和 Promise 状态。
## 设计边界 {#design-boundary}
这个包负责 Scope、请求队列、Promise 结算和标准结构,不复制底层 UI primitives。根入口只导出 Scope 能力;具体 UI 从 `/alert`、`/modal`、`/sheet` 等子路径导入,以保留明确的 tree-shaking 边界。
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@workspace/overlays",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@workspace/i18n": "workspace:*",
"@workspace/ui": "workspace:*",
"react": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
".": "./src/index.ts",
"./alert": "./src/alert.tsx",
"./all": "./src/all.tsx",
"./confirm": "./src/confirm.tsx",
"./drawer": "./src/drawer.tsx",
"./globals.css": "./src/styles/globals.css",
"./locales": "./src/locales/catalogs.ts",
"./locales/*": "./src/locales/*.ts",
"./modal": "./src/modal.tsx",
"./navigation": "./src/navigation.tsx",
"./prompt": "./src/prompt.tsx",
"./sheet": "./src/sheet.tsx",
"./toast": "./src/toast.tsx"
}
}
+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
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/overlays": ["./src/index.ts"],
"@workspace/overlays/*": ["./src/*"],
"@workspace/i18n": ["../i18n/src/index.ts"],
"@workspace/ui/*": ["../ui/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}