diff --git a/packages/overlays/docs/en-US/advanced/lifecycle.mdx b/packages/overlays/docs/en-US/advanced/lifecycle.mdx new file mode 100644 index 0000000..b6855c6 --- /dev/null +++ b/packages/overlays/docs/en-US/advanced/lifecycle.mdx @@ -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()`. + +```tsx +import { openModal } from "@workspace/overlays/modal" + +const productId = await openModal({ + title: "Choose a product", + content: ({ close }) => ( + 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, + }) + }) + }, +} + + +``` + +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. diff --git a/packages/overlays/docs/en-US/examples/custom-overlays.mdx b/packages/overlays/docs/en-US/examples/custom-overlays.mdx new file mode 100644 index 0000000..f7885d0 --- /dev/null +++ b/packages/overlays/docs/en-US/examples/custom-overlays.mdx @@ -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 }) => ( +
+ + + +
+ ), + }) + + if (plan) updatePlan(plan) + } + + return +} +``` + +## Sheet {#sheet} + +```tsx +import { openSheet } from "@workspace/overlays/sheet" + +await openSheet({ + title: "Filters", + side: "right", + content: , +}) +``` + +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: , + showSwipeHandle: true, + snapPoints: [0.5, 1], + swipeDirection: "down", +}) +``` + +Use a drawer for touch-oriented interaction with configurable swipe direction and Base UI snap points. diff --git a/packages/overlays/docs/en-US/guide/setup.mdx b/packages/overlays/docs/en-US/guide/setup.mdx new file mode 100644 index 0000000..5537fe0 --- /dev/null +++ b/packages/overlays/docs/en-US/guide/setup.mdx @@ -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} + + + + + ) +} +``` + +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 ( + + {children} + + + + ) +} + +function OpenPickerButton() { + const scope = useOverlayScope("picker") + return ( + + ) +} +``` + +`` 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`, `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`. diff --git a/packages/overlays/docs/en-US/overview.mdx b/packages/overlays/docs/en-US/overview.mdx new file mode 100644 index 0000000..9f0e9da --- /dev/null +++ b/packages/overlays/docs/en-US/overview.mdx @@ -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. diff --git a/packages/overlays/docs/zh-Hans/advanced/lifecycle.mdx b/packages/overlays/docs/zh-Hans/advanced/lifecycle.mdx new file mode 100644 index 0000000..2dd013c --- /dev/null +++ b/packages/overlays/docs/zh-Hans/advanced/lifecycle.mdx @@ -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()` 的泛型检查结果。 + +```tsx +import { openModal } from "@workspace/overlays/modal" + +const productId = await openModal({ + title: "选择产品", + content: ({ close }) => ( + 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, + }) + }) + }, +} + + +``` + +默认 Guard 检查当前和命名局部 Scope,找到最后打开的浮层。如果允许 dismiss,则关闭浮层并取消跳转;`dismissible: false` 会保留浮层并继续阻止跳转;没有浮层时才调用 `proceed()`。 + +适配器的 `proceed()` 必须继续已经被阻塞的同一次跳转,不能重新发起一个会再次触发 blocker 的导航。需要不同策略时,可以传入自定义 `interceptor`。 diff --git a/packages/overlays/docs/zh-Hans/examples/custom-overlays.mdx b/packages/overlays/docs/zh-Hans/examples/custom-overlays.mdx new file mode 100644 index 0000000..3e872e0 --- /dev/null +++ b/packages/overlays/docs/zh-Hans/examples/custom-overlays.mdx @@ -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 }) => ( +
+ + + +
+ ), + }) + + if (plan) updatePlan(plan) + } + + return +} +``` + +## Sheet {#sheet} + +```tsx +import { openSheet } from "@workspace/overlays/sheet" + +await openSheet({ + title: "筛选条件", + side: "right", + content: , +}) +``` + +Sheet 适合固定边缘面板。`side` 支持 `top`、`right`、`bottom` 和 `left`。 + +## Drawer {#drawer} + +```tsx +import { openDrawer } from "@workspace/overlays/drawer" + +await openDrawer({ + title: "选择配送时间", + content: , + showSwipeHandle: true, + snapPoints: [0.5, 1], + swipeDirection: "down", +}) +``` + +Drawer 适合触摸交互,可以配置滑动方向和 Base UI snap points。 diff --git a/packages/overlays/docs/zh-Hans/guide/setup.mdx b/packages/overlays/docs/zh-Hans/guide/setup.mdx new file mode 100644 index 0000000..c77112f --- /dev/null +++ b/packages/overlays/docs/zh-Hans/guide/setup.mdx @@ -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} + + + + + ) +} +``` + +确定会使用全部能力的应用也可以从 `@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 ( + + {children} + + + + ) +} + +function OpenPickerButton() { + const scope = useOverlayScope("picker") + return ( + + ) +} +``` + +`` 可以自动创建一个局部 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`;`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` 渲染。 diff --git a/packages/overlays/docs/zh-Hans/overview.mdx b/packages/overlays/docs/zh-Hans/overview.mdx new file mode 100644 index 0000000..4bd4a5e --- /dev/null +++ b/packages/overlays/docs/zh-Hans/overview.mdx @@ -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 边界。 diff --git a/packages/overlays/package.json b/packages/overlays/package.json new file mode 100644 index 0000000..8310693 --- /dev/null +++ b/packages/overlays/package.json @@ -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" + } +} \ No newline at end of file diff --git a/packages/overlays/src/alert.tsx b/packages/overlays/src/alert.tsx new file mode 100644 index 0000000..57b7fe6 --- /dev/null +++ b/packages/overlays/src/alert.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 +const alertChannel = Symbol("overlay.alert") + +function getAlertController(scope: OverlayScope) { + return scope.getChannel(alertChannel, () => { + const handle = + createAlertDialogHandle>() + return new OverlayController({ + handle, + scope, + }) + }) +} + +export function openAlert(options: AlertOptions) { + const { scope = globalOverlayScope, ...alertOptions } = options + return getAlertController(scope).open(alertOptions, undefined) +} + +export interface AlertHostProps { + scope?: OverlayScope +} + +export function AlertHost({ scope }: AlertHostProps) { + const controller = useOverlayController(scope, getAlertController) + + return ( + > + handle={controller.handle} + onOpenChange={(open, details) => { + if (!open) controller.handleCloseRequest(details) + }} + onOpenChangeComplete={(open) => { + if (!open) controller.handleCloseComplete() + }} + > + {({ payload }) => + payload ? ( + controller.resolve(undefined)} + /> + ) : null + } + + ) +} + +function AlertContent({ + onResolve, + options, +}: { + onResolve: () => void + options: AlertRequestOptions +}) { + const t = useTranslate() + return ( + + + + + {options.actionLabel ?? t(overlayMessages.okay)} + + + + ) +} + +export type { AlertOptions } from "./types" diff --git a/packages/overlays/src/all.tsx b/packages/overlays/src/all.tsx new file mode 100644 index 0000000..14ea9e5 --- /dev/null +++ b/packages/overlays/src/all.tsx @@ -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 ( + <> + + + + + + + + + ) +} diff --git a/packages/overlays/src/confirm.tsx b/packages/overlays/src/confirm.tsx new file mode 100644 index 0000000..6e0ee2c --- /dev/null +++ b/packages/overlays/src/confirm.tsx @@ -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 +const confirmChannel = Symbol("overlay.confirm") + +function getConfirmController(scope: OverlayScope) { + return scope.getChannel(confirmChannel, () => { + const handle = + createAlertDialogHandle>() + return new OverlayController({ + handle, + scope, + }) + }) +} + +export function openConfirm(options: ConfirmOptions) { + const { scope = globalOverlayScope, ...confirmOptions } = options + return getConfirmController(scope).open(confirmOptions, false) +} + +export interface ConfirmHostProps { + scope?: OverlayScope +} + +export function ConfirmHost({ scope }: ConfirmHostProps) { + const controller = useOverlayController(scope, getConfirmController) + + return ( + > + handle={controller.handle} + onOpenChange={(open, details) => { + if (!open) controller.handleCloseRequest(details) + }} + onOpenChangeComplete={(open) => { + if (!open) controller.handleCloseComplete() + }} + > + {({ payload }) => + payload ? ( + controller.resolve(value)} + /> + ) : null + } + + ) +} + +function ConfirmContent({ + onDismiss, + onResolve, + options, +}: { + onDismiss: () => void + onResolve: (value: boolean) => void + options: ConfirmRequestOptions +}) { + const t = useTranslate() + return ( + + + + + {options.cancelLabel ?? t(overlayMessages.cancel)} + + onResolve(true)} + > + {options.actionLabel ?? t(overlayMessages.confirm)} + + + + ) +} + +export type { ConfirmOptions } from "./types" diff --git a/packages/overlays/src/drawer.tsx b/packages/overlays/src/drawer.tsx new file mode 100644 index 0000000..c1ee479 --- /dev/null +++ b/packages/overlays/src/drawer.tsx @@ -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, "scope"> +const drawerChannel = Symbol("overlay.drawer") + +function getDrawerController(scope: OverlayScope) { + return scope.getChannel(drawerChannel, () => { + const handle = createDrawerHandle>() + return new OverlayController({ + handle, + isDismissible: (options) => options.dismissible !== false, + scope, + }) + }) +} + +export function openDrawer(options: DrawerOptions) { + const { scope = globalOverlayScope, ...drawerOptions } = options + return getDrawerController(scope).open( + 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 ( + > + 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 ? ( + controller.resolve(value)} + /> + ) : null + } + + ) +} + +function DrawerContentView({ + onDismiss, + onResolve, + options, +}: { + onDismiss: () => void + onResolve: (value: unknown) => void + options: DrawerRequestOptions +}) { + const t = useTranslate() + const dismissible = options.dismissible !== false + + return ( + + + {options.title} + {options.description != null && ( + {options.description} + )} + +
+ {renderOverlayContent(options.content, { + close: onResolve, + dismiss: onDismiss, + })} +
+ {dismissible && ( + + } + > + + )} +
+ ) +} + +export type { DrawerOptions } from "./types" diff --git a/packages/overlays/src/index.ts b/packages/overlays/src/index.ts new file mode 100644 index 0000000..a0b2c9a --- /dev/null +++ b/packages/overlays/src/index.ts @@ -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" diff --git a/packages/overlays/src/internal/controller.ts b/packages/overlays/src/internal/controller.ts new file mode 100644 index 0000000..27974c5 --- /dev/null +++ b/packages/overlays/src/internal/controller.ts @@ -0,0 +1,176 @@ +import type { OverlayScope } from "../scope" + +let nextRequestId = 0 + +export interface OverlayPrimitiveHandle { + close: () => void + openWithPayload: (payload: Payload) => void +} + +export interface OverlayRequest { + dismissedValue: unknown + id: string + options: Options + resolve: (value: unknown) => void + settled: boolean +} + +interface CancelableChangeEventDetails { + cancel: () => void +} + +export interface CreateOverlayControllerOptions< + Options, + Handle extends OverlayPrimitiveHandle>, +> { + 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>, +> { + readonly handle: Handle + + private active?: OverlayRequest + private readonly isDismissible: (options: Options) => boolean + private readonly listeners = new Set() + private readonly queue: Array> = [] + private readonly scope: OverlayScope + private hostCount = 0 + private hostVersion = 0 + private revision = 0 + + constructor({ + handle, + isDismissible = () => true, + scope, + }: CreateOverlayControllerOptions) { + this.handle = handle + this.isDismissible = isDismissible + this.scope = scope + } + + open(options: Options, dismissedValue: Result) { + return new Promise((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()) + } +} diff --git a/packages/overlays/src/internal/message-header.tsx b/packages/overlays/src/internal/message-header.tsx new file mode 100644 index 0000000..afba0f3 --- /dev/null +++ b/packages/overlays/src/internal/message-header.tsx @@ -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 +}) { + return ( + + {options.title} + {options.description != null && ( + {options.description} + )} + + ) +} diff --git a/packages/overlays/src/internal/render-content.ts b/packages/overlays/src/internal/render-content.ts new file mode 100644 index 0000000..112ba7e --- /dev/null +++ b/packages/overlays/src/internal/render-content.ts @@ -0,0 +1,8 @@ +import type { OverlayContent, OverlayControls } from "../types" + +export function renderOverlayContent( + content: OverlayContent, + controls: OverlayControls +) { + return typeof content === "function" ? content(controls) : content +} diff --git a/packages/overlays/src/internal/use-controller.ts b/packages/overlays/src/internal/use-controller.ts new file mode 100644 index 0000000..1ff6594 --- /dev/null +++ b/packages/overlays/src/internal/use-controller.ts @@ -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>, +>( + providedScope: OverlayScope | undefined, + getController: (scope: OverlayScope) => OverlayController +) { + 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 +} diff --git a/packages/overlays/src/locales/catalogs.test.ts b/packages/overlays/src/locales/catalogs.test.ts new file mode 100644 index 0000000..6315c36 --- /dev/null +++ b/packages/overlays/src/locales/catalogs.test.ts @@ -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)) + ) + }) +}) diff --git a/packages/overlays/src/locales/catalogs.ts b/packages/overlays/src/locales/catalogs.ts new file mode 100644 index 0000000..fd70084 --- /dev/null +++ b/packages/overlays/src/locales/catalogs.ts @@ -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[keyof T]["id"] + +export type OverlayCatalogLocale = (typeof overlayCatalogLocales)[number] +export type OverlayMessageCatalog = Readonly< + Record, string> +> diff --git a/packages/overlays/src/locales/en-US.ts b/packages/overlays/src/locales/en-US.ts new file mode 100644 index 0000000..712f194 --- /dev/null +++ b/packages/overlays/src/locales/en-US.ts @@ -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 diff --git a/packages/overlays/src/locales/zh-Hans.ts b/packages/overlays/src/locales/zh-Hans.ts new file mode 100644 index 0000000..5892f1e --- /dev/null +++ b/packages/overlays/src/locales/zh-Hans.ts @@ -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 diff --git a/packages/overlays/src/messages.ts b/packages/overlays/src/messages.ts new file mode 100644 index 0000000..cab11d9 --- /dev/null +++ b/packages/overlays/src/messages.ts @@ -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 diff --git a/packages/overlays/src/modal.tsx b/packages/overlays/src/modal.tsx new file mode 100644 index 0000000..6b0efa4 --- /dev/null +++ b/packages/overlays/src/modal.tsx @@ -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, "scope"> +const modalChannel = Symbol("overlay.modal") + +function getModalController(scope: OverlayScope) { + return scope.getChannel(modalChannel, () => { + const handle = createDialogHandle>() + return new OverlayController({ + handle, + isDismissible: (options) => options.dismissible !== false, + scope, + }) + }) +} + +export function openModal(options: ModalOptions) { + const { scope = globalOverlayScope, ...modalOptions } = options + return getModalController(scope).open( + 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 ( + > + 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 ? ( + controller.resolve(value)} + /> + ) : null + } + + ) +} + +function ModalContent({ + onDismiss, + onResolve, + options, +}: { + onDismiss: () => void + onResolve: (value: unknown) => void + options: ModalRequestOptions +}) { + const t = useTranslate() + const dismissible = options.dismissible !== false + + return ( + + + {options.title} + {options.description != null && ( + {options.description} + )} + + {renderOverlayContent(options.content, { + close: onResolve, + dismiss: onDismiss, + })} + {dismissible && ( + + } + > + + )} + + ) +} + +export type { ModalOptions } from "./types" diff --git a/packages/overlays/src/navigation.tsx b/packages/overlays/src/navigation.tsx new file mode 100644 index 0000000..1dcede3 --- /dev/null +++ b/packages/overlays/src/navigation.tsx @@ -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 +} diff --git a/packages/overlays/src/overlays.test.tsx b/packages/overlays/src/overlays.test.tsx new file mode 100644 index 0000000..3680798 --- /dev/null +++ b/packages/overlays/src/overlays.test.tsx @@ -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( + + + + + + ) + return { scope, ...result } +} + +describe("atomic overlays", () => { + it("uses the global scope when scope is omitted", async () => { + render( + + + + ) + + let result: Promise | 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 | undefined + act(() => { + alertResult = openAlert({ scope, title: "Export complete" }) + }) + fireEvent.click(screen.getByRole("button", { name: "OK" })) + await expect(alertResult).resolves.toBeUndefined() + + let confirmation: Promise | 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 | 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 | undefined + + act(() => { + modalResult = openModal({ + content: ({ close }) => ( + + ), + 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:

Account details

, + scope, + title: "Account", + }) + void openDrawer({ + content:

Filters

, + 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( + + + + ) + expect(current).toBe(editor) + expect(named).toBe(picker) + }) + + it("queues requests for the same atomic host", async () => { + const { scope } = renderHosts() + let first: Promise | undefined + let second: Promise | 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 | 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( + + + + + + + ) + let result: Promise | 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( + + + + + + + ) + act(() => { + void openModal({ + content: ({ close }) => ( + + ), + dismissible: false, + scope, + title: "Required step", + }) + }) + act(() => listener?.({ cancel, proceed: vi.fn() })) + + expect(cancel).toHaveBeenCalledOnce() + expect(screen.getByText("Required step")).toBeTruthy() + }) +}) diff --git a/packages/overlays/src/prompt.tsx b/packages/overlays/src/prompt.tsx new file mode 100644 index 0000000..ebba3dc --- /dev/null +++ b/packages/overlays/src/prompt.tsx @@ -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 +const promptChannel = Symbol("overlay.prompt") + +function getPromptController(scope: OverlayScope) { + return scope.getChannel(promptChannel, () => { + const handle = + createAlertDialogHandle>() + return new OverlayController({ + handle, + scope, + }) + }) +} + +export function openPrompt(options: PromptOptions) { + const { scope = globalOverlayScope, ...promptOptions } = options + return getPromptController(scope).open(promptOptions, null) +} + +export interface PromptHostProps { + scope?: OverlayScope +} + +export function PromptHost({ scope }: PromptHostProps) { + const controller = useOverlayController(scope, getPromptController) + + return ( + > + handle={controller.handle} + onOpenChange={(open, details) => { + if (!open) controller.handleCloseRequest(details) + }} + onOpenChangeComplete={(open) => { + if (!open) controller.handleCloseComplete() + }} + > + {({ payload }) => + payload ? ( + controller.resolve(value)} + /> + ) : null + } + + ) +} + +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 ( + +
{ + event.preventDefault() + onResolve(value) + }} + > + + + + + {options.cancelLabel ?? t(overlayMessages.cancel)} + + + {options.actionLabel ?? t(overlayMessages.confirm)} + + + +
+ ) +} + +export type { PromptOptions } from "./types" diff --git a/packages/overlays/src/scope-context.tsx b/packages/overlays/src/scope-context.tsx new file mode 100644 index 0000000..68b3270 --- /dev/null +++ b/packages/overlays/src/scope-context.tsx @@ -0,0 +1,70 @@ +import * as React from "react" + +import { + createOverlayScope, + globalOverlayScope, + type OverlayScope, +} from "./scope" + +interface OverlayScopeContextValue { + scope: OverlayScope + scopes: Readonly> +} + +const OverlayScopeContext = React.createContext({ + 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> +} + +/** 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 ( + + {children} + + ) +} + +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 +} diff --git a/packages/overlays/src/scope.ts b/packages/overlays/src/scope.ts new file mode 100644 index 0000000..f8810ce --- /dev/null +++ b/packages/overlays/src/scope.ts @@ -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() + private readonly layers = new Map() + + constructor({ name }: CreateOverlayScopeOptions = {}) { + this.id = `overlay-scope-${nextScopeId++}` + this.name = name + } + + /** @internal Used by atomic overlay feature modules. */ + getChannel(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) { + 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" }) diff --git a/packages/overlays/src/sheet.tsx b/packages/overlays/src/sheet.tsx new file mode 100644 index 0000000..d407257 --- /dev/null +++ b/packages/overlays/src/sheet.tsx @@ -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, "scope"> +const sheetChannel = Symbol("overlay.sheet") + +function getSheetController(scope: OverlayScope) { + return scope.getChannel(sheetChannel, () => { + const handle = createSheetHandle>() + return new OverlayController({ + handle, + isDismissible: (options) => options.dismissible !== false, + scope, + }) + }) +} + +export function openSheet(options: SheetOptions) { + const { scope = globalOverlayScope, ...sheetOptions } = options + return getSheetController(scope).open( + 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 ( + > + 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 ? ( + controller.resolve(value)} + /> + ) : null + } + + ) +} + +function SheetContentView({ + onDismiss, + onResolve, + options, +}: { + onDismiss: () => void + onResolve: (value: unknown) => void + options: SheetRequestOptions +}) { + const t = useTranslate() + const dismissible = options.dismissible !== false + + return ( + + + {options.title} + {options.description != null && ( + {options.description} + )} + +
+ {renderOverlayContent(options.content, { + close: onResolve, + dismiss: onDismiss, + })} +
+ {dismissible && ( + + } + > + + )} +
+ ) +} + +export type { SheetOptions } from "./types" diff --git a/packages/overlays/src/styles/globals.css b/packages/overlays/src/styles/globals.css new file mode 100644 index 0000000..f3275b1 --- /dev/null +++ b/packages/overlays/src/styles/globals.css @@ -0,0 +1 @@ +@source "../"; diff --git a/packages/overlays/src/toast.tsx b/packages/overlays/src/toast.tsx new file mode 100644 index 0000000..7222f56 --- /dev/null +++ b/packages/overlays/src/toast.tsx @@ -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>( + options: ToastOptions +) { + const { scope = globalOverlayScope, ...toastOptions } = options + return getToastManager(scope).add(toastOptions) +} + +export function updateToast>( + id: string, + options: ToastUpdateOptions +) { + 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, +>(promise: Promise, options: ToastPromiseOptions) { + 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 +} + +export type { + ToastOptions, + ToastPromiseOptions, + ToastUpdateOptions, +} from "./types" diff --git a/packages/overlays/src/types.ts b/packages/overlays/src/types.ts new file mode 100644 index 0000000..ebbbf86 --- /dev/null +++ b/packages/overlays/src/types.ts @@ -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 { + close: (result: Result) => void + dismiss: () => void +} + +export type OverlayContent = + | React.ReactNode + | ((controls: OverlayControls) => 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 extends ScopedOverlayOptions { + className?: string + content: OverlayContent + description?: React.ReactNode + dismissible?: boolean + title: React.ReactNode +} + +export interface ModalOptions< + Result = void, +> extends ContentOverlayOptions { + modal?: boolean | "trap-focus" +} + +export interface SheetOptions< + Result = void, +> extends ContentOverlayOptions { + modal?: boolean | "trap-focus" + side?: "bottom" | "left" | "right" | "top" +} + +export interface DrawerOptions< + Result = void, +> extends ContentOverlayOptions { + modal?: boolean | "trap-focus" + showSwipeHandle?: boolean + snapPoints?: Array + swipeDirection?: "down" | "left" | "right" | "up" +} + +export type ToastOptions> = + ToastManagerAddOptions & ScopedOverlayOptions + +export type ToastUpdateOptions> = + ToastManagerUpdateOptions & ScopedOverlayOptions + +export type ToastPromiseOptions< + Value, + Data extends object = Record, +> = ToastManagerPromiseOptions & ScopedOverlayOptions diff --git a/packages/overlays/tsconfig.json b/packages/overlays/tsconfig.json new file mode 100644 index 0000000..b3d48f3 --- /dev/null +++ b/packages/overlays/tsconfig.json @@ -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"] +} \ No newline at end of file