Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/portal-shared-backdrop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vlkoss/cue": minor
---

Portal the overlay outlet, follow close delay on the shared backdrop, and let overlay definitions opt out of or replace that dim.
29 changes: 29 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Cue

An overlay environment for a React app. Several callers, or code outside a component, open the same overlay. The environment is not a dialog.

## Language

**Cue environment**:
An isolated set of overlay definitions and overlay instances that share one backdrop.
_Avoid_: overlay manager, dialog host, global overlay

**Overlay definition**:
An overlay created from a Cue environment and opened through its handle. It may replace the shared backdrop, or opt out of providing one so a lower overlay instance can keep it.
_Avoid_: modal, dialog

**Overlay instance**:
One opening of an overlay definition. The overlay author supplies its UI.
_Avoid_: dialog instance

**Overlay handle**:
What callers use to open, await, and close overlay instances of one overlay definition.
_Avoid_: overlay controller, modal ref

**Shared backdrop**:
The single dim that belongs to a Cue environment while at least one overlay instance is mounted. It is not a dialog. It comes from the top open overlay instance that did not opt out: that definition's replacement, otherwise the environment's. If every open overlay instance opted out, there is no dim.
_Avoid_: DialogOverlay, scrim, overlay

**Close delay**:
The duration an overlay instance remains mounted after it has started to close. The shared backdrop uses the same window when that instance is the last.
_Avoid_: unmount delay, animation timeout
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ confirmDialog.closeAll();

## Using shadcn

If your app uses shadcn, the [Using Cue with shadcn guide](https://cue.vlkstudio.com/docs/shadcn) explains the registry setup and the native Dialog backdrop adapter.
If your app uses shadcn, the [Using Cue with shadcn guide](https://cue.vlkstudio.com/docs/shadcn) explains the registry setup and the shared backdrop adapter.

## Customization

Cue has no backdrop or shared components by default. Add them with `createCue({ backdrop, components })` when your application needs them. See the [Customization guide](https://cue.vlkstudio.com/docs/customization).
Cue has no backdrop or shared components by default. Add them with `createCue({ backdrop, delay, components })` when your application needs them. See the [Customization guide](https://cue.vlkstudio.com/docs/customization).

## Next.js

Expand Down
39 changes: 28 additions & 11 deletions apps/docs/content/docs/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ description: createCue, createOverlay, context, and the provider.
```tsx
const cue = createCue({
backdrop: OverlayBackdrop,
delay: 300,
components: {
wrapper: OverlayWrapper,
content: OverlayContent,
Expand All @@ -20,9 +21,11 @@ const cue = createCue({

`components` is an application-defined map. Cue does not reserve keys such as `header` or `footer`. The exact map is inferred in every overlay callback.

`backdrop` is different. Cue renders it once for the visible stack. Individual overlays do not render it.
`backdrop` is the environment's shared backdrop. Cue renders it once for the stack. Individual overlays do not render it.

The backdrop receives a `close` callback. Call it with `{ strategy: "last" }` to close the top open overlay or `{ strategy: "all" }` to close every open overlay.
`delay` is the environment close delay in milliseconds. Default `300`. `close({ delay })` overrides it for one overlay instance.

The backdrop receives `open` and `close`. `open` stays `true` while any overlay instance that did not opt out is open, then `false` during the last instance's close delay. Call `close` with `{ strategy: "last" }` to close the top open overlay instance or `{ strategy: "all" }` to close every open overlay instance.

For the shadcn Dialog adapter, see [Using Cue with shadcn](/docs/shadcn). For custom backdrops and shared components, see [Customization](/docs/customization).

Expand All @@ -32,9 +35,13 @@ For the shadcn Dialog adapter, see [Using Cue with shadcn](/docs/shadcn). For cu
<cue.OverlayProvider>{children}</cue.OverlayProvider>
```

The provider renders children, every visible overlay, and one shared backdrop while at least one instance is open. An instance remains visible while its close delay runs, but the backdrop disappears as soon as the final instance starts closing.
Mount it once, near the root. The provider portals the shared backdrop and every overlay instance to `document.body`. Pass `container` to portal somewhere else.

A second `OverlayProvider` for the same Cue environment renders children only and warns in development. Unmounting the outlet provider dismisses pending `openAsync()` calls as `undefined` and clears the stack.

An overlay instance remains mounted while its close delay runs. The shared backdrop stays mounted for that window when the instance is the last.

## `cue.createOverlay<Props, Result>(render)`
## `cue.createOverlay<Props, Result>(render, options?)`

```tsx
const dialog = cue.createOverlay<{ message: string }, boolean>((props, ctx) => {
Expand All @@ -61,15 +68,23 @@ type OverlayContext<Components, Result> = {

`props` contains only the object passed to `open()` or `openAsync()`. Do not expect `open`, `close`, or `components` there.

Pass `{ backdrop }` as the second argument to replace the environment dim, or `{ backdrop: false }` to opt out of providing one so a lower overlay instance can keep it:

```tsx
const popover = cue.createOverlay(() => <Popover />, { backdrop: false });
```

`open()` and `openAsync()` still take application props only.

### Handle methods

| Method | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `open(props?)` | Opens one instance and returns that instance's `close` function. Props are required when `Props` has a required key. |
| `openAsync(props?)` | Opens one instance and returns `Promise<Result \| undefined>`. Dismissal resolves `undefined`. |
| `closeAll(options?)` | Closes every live instance from this overlay definition. A `result` settles every pending `openAsync()` from that definition. |
| Method | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `open(props?)` | Opens one instance and returns that instance's `close` function. The function accepts the same `{ result, delay }` as `ctx.close`. Props are required when `Props` has a required key. |
| `openAsync(props?)` | Opens one instance and returns `Promise<Result \| undefined>`. Dismissal resolves `undefined`. |
| `closeAll(options?)` | Closes every live instance from this overlay definition. A `result` settles every pending `openAsync()` from that definition. |

Closed instances always unmount after `delay`, which defaults to `300` milliseconds. There is no `unmount: false` option.
Closed instances always unmount after `delay`. There is no `unmount: false` option.

### Optional props

Expand All @@ -94,4 +109,6 @@ confirm.open({ message: "Continue?" });

## Shared backdrop behavior

The provider renders no backdrop with no open overlays, one backdrop with one overlay, and still one backdrop for a stack. Closing the top overlay leaves the backdrop while the lower overlay remains. Closing the final instance removes it immediately, while the instance itself remains mounted until its delay expires. `closeAll()` follows the same rule.
Cue renders no dim when every open overlay instance opted out, one dim for one overlay instance, and still one dim for a stack. Closing the top overlay instance leaves the dim while a lower instance remains. Closing the last instance sets `open={false}` on the dim and removes it when that instance's close delay ends. `closeAll()` follows the same rule.

The dim comes from the top open overlay instance that did not opt out: that overlay definition's replacement, otherwise the environment's.
37 changes: 22 additions & 15 deletions apps/docs/content/docs/customization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,42 @@ title: Customization
description: Configure a custom backdrop and reusable components for a Cue environment.
---

`createCue()` needs no options. Add a backdrop or shared components when your overlays need them.
`createCue()` needs no options. Add a backdrop, a close delay, or shared components when your overlays need them.

## Custom backdrops

Pass a component through `backdrop`:
Pass a component through `backdrop`. It is not a dialog. Cue portals it with the overlay instances.

```tsx
function OverlayBackdrop({ close }: { close: (options: { strategy: "last" | "all" }) => void }) {
return (
<div
aria-hidden="true"
className="fixed inset-0 bg-black/50"
onClick={() => close({ strategy: "last" })}
/>
);
}
import { createCue, type CueBackdrop } from "@vlkoss/cue";

const OverlayBackdrop: CueBackdrop = ({ open, close }) => (
<div
aria-hidden="true"
data-open={open ? "" : undefined}
className="fixed inset-0 bg-black/50"
style={{ pointerEvents: open ? "auto" : "none" }}
onClick={() => close({ strategy: "last" })}
/>
);

export const cue = createCue({
backdrop: OverlayBackdrop,
delay: 300,
});
```

The backdrop belongs to the provider, not to an individual overlay. The provider renders no backdrop when the stack is empty, one backdrop for one or many visible overlays, and removes it immediately when the final instance starts closing. An overlay can remain mounted during its exit delay without keeping the backdrop visible.
`open` is `true` while the stack should show a dim, then `false` during the last overlay instance's close delay so the dim can fade out. Use `{ strategy: "last" }` to close the top open overlay instance or `{ strategy: "all" }` to close every open overlay instance.

This matters when overlays stack. Rendering a backdrop inside each dialog creates one element per dialog and makes the stack darker as it grows. A provider-level backdrop keeps the stack predictable.
An overlay definition can replace that dim, or opt out so a lower overlay instance keeps it:

Cue passes a `close` callback to the backdrop. The backdrop chooses whether to close the top open overlay with `{ strategy: "last" }` or every open overlay with `{ strategy: "all" }`.
```tsx
const popover = cue.createOverlay(() => <Popover />, { backdrop: false });
```

Opened alone, that overlay instance has no dim. Opened over a dialog that uses the environment backdrop, the dialog's dim stays.

If a UI library's native backdrop reads a root context, wrap that backdrop in the library's root and portal. The [shadcn integration](/docs/shadcn) uses this pattern for both its Radix and Base UI Dialog wrappers.
This matters when overlays stack. Rendering a backdrop inside each dialog creates one element per dialog and makes the stack darker as it grows. A provider-level backdrop keeps the stack predictable.

## Reusable components

Expand Down
30 changes: 14 additions & 16 deletions apps/docs/content/docs/shadcn.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Using Cue with shadcn
description: Install Cue with shadcn and configure its native Dialog backdrop.
description: Install Cue with shadcn and let Cue own the shared backdrop.
---

The shadcn registry can install the Dialog component and create a local Cue module for your app:
Expand Down Expand Up @@ -30,7 +30,7 @@ npx shadcn@latest add VLK-STUDIO/cue/cue
</Tab>
</Tabs>

The registry installs shadcn's `dialog` item first, then creates `components/cue/index.tsx`. That file owns the Cue instance, the native backdrop adapter, and the shared Dialog component map. You can edit it like any other application file.
The registry installs shadcn's `dialog` item first, then creates `components/cue/index.tsx`. That file owns the Cue instance, the shared backdrop, and the Dialog component map. You can edit it like any other application file.

## Allow Cue to own the backdrop

Expand All @@ -56,23 +56,21 @@ Keep the rest of shadcn's generated component unchanged. With the Radix-based Di

## The generated Cue module

The generated file adapts the local shadcn components to Cue. `wrapper` is the Dialog root. `content` disables the local backdrop. The remaining keys are ordinary shared components that you can use or replace.
The generated file adapts the local shadcn components to Cue. `wrapper` is the Dialog root. `content` disables the local backdrop. The shared backdrop is a dim Cue portals with the overlay instances, not a second Dialog root. The remaining keys are ordinary shared components that you can use or replace.

```tsx
// components/cue/index.tsx
"use client";

import type { ComponentProps } from "react";
import { createCue } from "@vlkoss/cue";
import { createCue, type CueBackdrop } from "@vlkoss/cue";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
Expand All @@ -83,15 +81,15 @@ function CueContent(props: CueContentProps) {
return <DialogContent {...props} showBackdrop={false} />;
}

function CueBackdrop({ close }: { close: (options: { strategy: "last" | "all" }) => void }) {
return (
<Dialog open>
<DialogPortal>
<DialogOverlay onClick={() => close({ strategy: "last" })} />
</DialogPortal>
</Dialog>
);
}
const CueBackdrop: CueBackdrop = ({ open, close }) => (
<div
aria-hidden="true"
data-open={open ? "" : undefined}
className="fixed inset-0 isolate z-50 bg-black/10 opacity-0 transition-opacity duration-100 supports-backdrop-filter:backdrop-blur-xs starting:opacity-0 data-open:opacity-100"
style={{ pointerEvents: open ? "auto" : "none" }}
onClick={() => close({ strategy: "last" })}
/>
);

export const cue = createCue({
backdrop: CueBackdrop,
Expand All @@ -108,7 +106,7 @@ export const cue = createCue({
});
```

The backdrop creates a separate shadcn Dialog root and portal. That gives the native `DialogOverlay` the context it needs while Cue decides when the backdrop mounts. Cue passes the backdrop a `close` callback, and this adapter chooses to close the top open overlay. The overlay itself does not render a backdrop.
Do not copy `DialogOverlay`'s `animate-in` / `animate-out` classes onto this div. Cue keeps the backdrop mounted until close delay ends, and those keyframes use fill-mode `none`, so the dim fades out then pops back in until unmount. An opacity transition stays faded. Cue passes `open` for that delay, and this adapter closes the top open overlay instance on click.

## Use the configured components

Expand Down
22 changes: 10 additions & 12 deletions apps/docs/src/components/cue.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import type { ComponentProps } from "react";
import { createCue } from "@vlkoss/cue";
import { createCue, type CueBackdrop } from "@vlkoss/cue";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
Expand All @@ -19,15 +17,15 @@ function CueContent(props: CueContentProps) {
return <DialogContent {...props} showBackdrop={false} />;
}

function CueBackdrop({ close }: { close: (options: { strategy: "last" | "all" }) => void }) {
return (
<Dialog open>
<DialogPortal>
<DialogOverlay onClick={() => close({ strategy: "last" })} />
</DialogPortal>
</Dialog>
);
}
const CueBackdrop: CueBackdrop = ({ open, close }) => (
<div
aria-hidden="true"
data-open={open ? "" : undefined}
className="fixed inset-0 isolate z-50 bg-black/10 opacity-0 transition-opacity duration-100 supports-backdrop-filter:backdrop-blur-xs starting:opacity-0 data-open:opacity-100"
style={{ pointerEvents: open ? "auto" : "none" }}
onClick={() => close({ strategy: "last" })}
/>
);

export const cue = createCue({
backdrop: CueBackdrop,
Expand Down
13 changes: 13 additions & 0 deletions docs/adr/0001-cue-environment-mount-and-shared-backdrop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Cue environment mount and shared backdrop

Cue is an overlay environment, not a dialog host. OverlayProvider portals the shared backdrop and every overlay instance to `document.body`, or to a `container` you pass. The shared backdrop is not a dialog. It follows close delay. It is chosen from the top open overlay instance that did not opt out: that overlay definition's replacement, otherwise the environment's. `backdrop: false` skips that overlay instance so a lower one can keep the dim. Unmounting the outlet provider dismisses pending `openAsync()` calls as `undefined` and clears the stack.

## Considered options

- Render in-flow at the provider. Headless overlay instances clip inside `overflow: hidden`.
- Portal only the dim. A headless overlay instance stays in the app tree and sits under the dim.
- Make Cue a Dialog host. Every overlay would have to be a dialog.
- `backdrop: false` kills the dim for the whole stack. Popovers and alerts opened over a dialog would undim that dialog.
- Keep overlay instances in the store after the provider unmounts. `openAsync()` hangs, and a remount resurrects the stack.

We rejected those. The Cue environment owns the stack, the close delay, and one dim. Overlay authors still supply the UI.
8 changes: 4 additions & 4 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,17 @@ const cue = createCue({
});
```

Cue exposes the configured components through `ctx.components` and leaves composition to each overlay. It renders the configured backdrop once for the visible stack. A UI library backdrop adapter must provide any root or portal context the library requires.
Cue exposes the configured components through `ctx.components` and leaves composition to each overlay. The OverlayProvider portals the shared backdrop and overlay instances to `document.body`, or to `container`. The backdrop receives `open` and `close`. Choose `{ strategy: "last" }` or `{ strategy: "all" }`.

The backdrop receives a `close` callback. Choose `{ strategy: "last" }` to close the top open overlay or `{ strategy: "all" }` to close every open overlay.
An overlay definition may pass `{ backdrop: false }` to opt out of the dim, or pass a replacement. `createCue({ delay })` sets the close delay. Default `300`. `open()` returns a `close` that accepts `{ result, delay }` like `ctx.close`.

Closing instances remain visible during their `delay`, but the backdrop disappears as soon as the final open instance starts closing. Instances always unmount after that delay. There is no `unmount: false` option.
Unmounting the provider dismisses pending `openAsync()` calls as `undefined` and clears the stack. There is no `unmount: false` option.

Each call to `createCue()` creates an isolated store, provider, definitions, and lifecycle state. The package does not export global `createOverlay`, `OverlayProvider`, or `OverlayManager` values.

## Types

`OverlayContext<C, R>` contains `open`, `onOpenChange`, `close`, and the exact component map inferred from `createCue({ components })`. `CloseOptions<R>` accepts `result` and `delay`.
`OverlayContext<C, R>` contains `open`, `onOpenChange`, `close`, and the exact component map inferred from `createCue({ components })`. `CloseOptions<R>` accepts `result` and `delay`. The package also exports `OverlayHandle`, `CueOptions`, `CueBackdrop`, `CueBackdropCloseOptions`, `OverlayDefinitionOptions`, and `OverlayProviderProps`.

## License

Expand Down
3 changes: 3 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"jsdom": "^30.0.1",
"oxfmt": "^0.66.0",
"oxlint": "^1.81.0",
"typescript": "7.0.2",
Expand Down
Loading
Loading