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.