feat(overlays): add managed overlay package
This commit is contained in:
@@ -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`.
|
||||
@@ -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 边界。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user