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.