# Dialog

A modal overlay for focused tasks and multi-step flows.

## Installation

```tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogCloseGroup,
  DialogToolbar,
  DialogPanes,
  DialogAside,
  DialogMain,
  DialogHeader,
  DialogProgression,
  DialogHeading,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogActions,
  DialogInfo,
  DialogInfoIcon,
  DialogClose,
} from "@makeplane/propel/components/dialog";
```
## Usage

Compose `DialogCloseGroup` (corner dismiss), optional `DialogToolbar` (top chrome with a bottom
border), `DialogMain` (progression / heading / optional body slot), and `DialogActions` (optional
`DialogInfo` + buttons) inside `DialogContent`. Put `DialogDescription` in `DialogHeading` under
the title — not in `DialogBody`. When `DialogBody` content can overflow, pass `tabIndex={0}` so the
scroll region is keyboard-reachable.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogInfo,
  DialogInfoIcon,
  DialogMain,
  DialogProgression,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { ChevronLeftOutline, InfoOutline, CloseOutline } from "@makeplane/propel/icons";

/**
 * Default composition: full Modal anatomy — progression, heading, body slot, info helper, and
 * action row.
 */
export default function BasicDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Create cycle"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogProgression>
              <IconButton
                variant="ghost"
                size="xs"
                aria-label="Back"
                icon={<Icon icon={ChevronLeftOutline} />}
              />
              Step 1 of 3
            </DialogProgression>
            <DialogHeading>
              <DialogTitle>Create cycle</DialogTitle>
              <DialogDescription>
                Name the cycle and set its date range. You can add work items in the next step.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
          <DialogBody>
            <div className="min-h-32 rounded-lg bg-surface-2" />
          </DialogBody>
        </DialogMain>
        <DialogActions>
          <DialogInfo>
            <DialogInfoIcon>
              <Icon icon={InfoOutline} />
            </DialogInfoIcon>
            Drafts save automatically
          </DialogInfo>
          <Button
            stretch="auto"
            variant="ghost"
            size="md"
            render={<DialogClose />}
            label="Cancel"
          />
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            render={<DialogClose />}
            label="Save draft"
          />
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={<DialogClose />}
            label="Continue"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}
```
## Anatomy

| Part                                | Role                                                           |
| ----------------------------------- | -------------------------------------------------------------- |
| `Dialog`                            | Root context (open state, modality)                            |
| `DialogTrigger` / `DialogClose`     | Behavior grafts onto `Button` / `IconButton` via `render`      |
| `DialogContent`                     | Portal + backdrop + viewport + popup (`size` defaults to `md`) |
| `DialogCloseGroup`                  | Absolute top-end slot for the dismiss `IconButton`             |
| `DialogToolbar`                     | Optional top chrome; bottom border flush to the popup          |
| `DialogPanes` / `DialogAside`       | Optional two-pane row: 280 px start rail beside `DialogMain`   |
| `DialogMain`                        | Padded top section; shrinks so the footer stays visible        |
| `DialogProgression`                 | Optional back + step label row                                 |
| `DialogHeader` / `DialogHeading`    | Progression + **title / description** stack (6 px gap)         |
| `DialogTitle` / `DialogDescription` | Accessible name + supporting copy — keep both in Heading       |
| `DialogBody`                        | Scrollable **slot** only (forms, lists) — not description      |
| `DialogActions`                     | Footer with top border, pinned while the body scrolls          |
| `DialogInfo` / `DialogInfoIcon`     | Optional footer helper                                         |

## Examples

### Sizes

`DialogContent`'s `size` sets the dialog width: `xs` 480 px, `sm` 576 px, `md` 672 px (default),
`lg` 800 px, `xl` 1000 px, or `full`, which fills the viewport minus an 80 px margin on every edge
and pins the height. Every dialog hangs 80 px from the top of the viewport, so a short one opens
where a tall one does, and grows with its content until it is 80 px from the bottom; past that the
body scrolls. The gutter of `DialogMain`, `DialogActions`, and `DialogAside` follows the size
(16 px, 48 px at `full`). `DialogToolbar` matches on the start edge; its end edge is 40 px so
content clears the corner dismiss (48 px at `full`). `density="compact"` keeps 16 px at every size,
including `full`. `height="fixed"` pins 700 px so split chrome does not jump when the active pane
changes.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  type DialogPopupSize,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

const SIZES: { size: DialogPopupSize; label: string; width: string }[] = [
  { size: "xs", label: "Extra small", width: "480 px — a short confirmation with one field." },
  { size: "sm", label: "Small", width: "576 px — a single-column form." },
  { size: "md", label: "Medium", width: "672 px — the default; most create and edit forms." },
  { size: "lg", label: "Large", width: "800 px — two-column forms and previews." },
  { size: "xl", label: "Extra large", width: "1000 px — dense tables and editors." },
  { size: "full", label: "Full", width: "Viewport minus 80 px on every edge; height pinned." },
];

export default function SizesDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      {SIZES.map(({ size, label, width }) => (
        <Dialog key={size}>
          <Button
            stretch="auto"
            variant="secondary"
            size="lg"
            render={<DialogTrigger />}
            label={label}
          />
          <DialogContent size={size}>
            <DialogCloseGroup>
              <IconButton
                variant="ghost"
                size="xs"
                aria-label="Close"
                render={<DialogClose />}
                icon={<Icon icon={CloseOutline} />}
              />
            </DialogCloseGroup>
            <DialogMain>
              <DialogHeader>
                <DialogHeading>
                  <DialogTitle>{label} dialog</DialogTitle>
                  <DialogDescription>{width}</DialogDescription>
                </DialogHeading>
              </DialogHeader>
              <DialogBody>
                <div className="min-h-24 rounded-lg bg-surface-2" />
              </DialogBody>
            </DialogMain>
            <DialogActions>
              <Button
                stretch="auto"
                variant="primary"
                size="md"
                render={<DialogClose />}
                label="Done"
              />
            </DialogActions>
          </DialogContent>
        </Dialog>
      ))}
    </div>
  );
}
```

### Scrollable body

Tall content scrolls inside `DialogBody` so the header and footer stay pinned. Pass `tabIndex={0}`
on the body when it can overflow.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

const RELEASE_NOTES = Array.from({ length: 12 }, (_, index) => ({
  version: `2.${12 - index}.0`,
  notes:
    "Improved cycle burndown accuracy, faster work-item search indexing, and new keyboard shortcuts for triaging intake items.",
}));

export default function ScrollableBodyDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="What's new"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>What&apos;s new</DialogTitle>
              <DialogDescription>
                Everything that shipped in the last twelve releases.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
          {/* A scrollable region must be keyboard-reachable (axe scrollable-region-focusable). */}
          <DialogBody tabIndex={0}>
            <div className="flex flex-col gap-4">
              {RELEASE_NOTES.map((release) => (
                <div key={release.version} className="flex flex-col gap-1">
                  <h3 className="text-body-sm-medium text-primary">Version {release.version}</h3>
                  <p className="text-body-sm-regular text-secondary">{release.notes}</p>
                </div>
              ))}
            </div>
          </DialogBody>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={<DialogClose />}
            label="Got it"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}
```

### Fullscreen wizard

`size="full"` plus `DialogPanes`: a `DialogAside` step rail on the start side, the content pane in
`DialogMain`, and a `DialogInfo` note in the footer beside Back / Next. The panes work at any size;
the step list is your own content.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogAside,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogInfo,
  DialogInfoIcon,
  DialogMain,
  DialogPanes,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { InfoOutline, CloseOutline } from "@makeplane/propel/icons";
import * as React from "react";

const STEPS = ["States", "Work item types", "Workflows", "Labels", "Templates"];

export default function FullscreenWizardDemo() {
  const [step, setStep] = React.useState(0);
  const last = step === STEPS.length - 1;
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Review migration"
      />
      <DialogContent size="full">
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogPanes>
          <DialogAside>
            <nav aria-label="Migration steps" className="flex flex-col items-start gap-3">
              {STEPS.map((label, index) => (
                <button
                  key={label}
                  type="button"
                  aria-current={index === step ? "step" : undefined}
                  onClick={() => setStep(index)}
                  className="text-start text-body-sm-medium text-tertiary hover:text-primary aria-[current]:text-primary"
                >
                  {label}
                </button>
              ))}
            </nav>
          </DialogAside>
          <DialogMain>
            <DialogHeader>
              <DialogHeading>
                <DialogTitle>{STEPS[step]}</DialogTitle>
                <DialogDescription>
                  Everything below moves from the project level to the workspace level.
                </DialogDescription>
              </DialogHeading>
            </DialogHeader>
            <DialogBody tabIndex={0}>
              <div className="min-h-96 rounded-lg bg-surface-2" />
            </DialogBody>
          </DialogMain>
        </DialogPanes>
        <DialogActions>
          <DialogInfo>
            <DialogInfoIcon>
              <Icon icon={InfoOutline} />
            </DialogInfoIcon>
            This is review only. No changes are made during migration.
          </DialogInfo>
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            disabled={step === 0}
            onClick={() => setStep((current) => current - 1)}
            label="Back"
          />
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={last ? <DialogClose /> : undefined}
            onClick={last ? undefined : () => setStep((current) => current + 1)}
            label={last ? "Review and enable" : "Next"}
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}
```

### Discard guard

Pass `discard` on the root with `when` true while the form has unsaved edits. Escape, an outside
press, and the corner close then raise an alert asking to discard; "Keep editing" returns to the
draft, "Discard" closes and calls `onDiscard`. Closing from your own handler (Post, Save) is never
intercepted.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { TextAreaField } from "@makeplane/propel/components/text-area-field";
import { CloseOutline } from "@makeplane/propel/icons";
import * as React from "react";

export default function DiscardGuardDemo() {
  const [open, setOpen] = React.useState(false);
  const [comment, setComment] = React.useState("");
  return (
    <Dialog
      open={open}
      onOpenChange={setOpen}
      discard={{
        when: comment.trim() !== "",
        onDiscard: () => setComment(""),
        title: "Discard comment?",
        description: "Your comment hasn't been posted yet. Discarding it can't be undone.",
      }}
    >
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Add comment"
      />
      <DialogContent size="md">
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Add comment</DialogTitle>
            </DialogHeading>
          </DialogHeader>
          <DialogBody>
            <TextAreaField
              size="lg"
              resize="none"
              label="Comment"
              placeholder="Leave a comment…"
              value={comment}
              onChange={(event) => setComment(event.target.value)}
            />
          </DialogBody>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            onClick={() => {
              // Closing from a handler is not a dismissal, so the guard does not intercept it.
              setOpen(false);
              setComment("");
            }}
            label="Post comment"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}
```

### Confirmation

Title, description, and cancel / confirm actions — a short task without a body slot.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

export default function ConfirmationDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Delete project"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Delete project</DialogTitle>
              <DialogDescription>
                This permanently removes the project and all of its work items. This action
                can&apos;t be undone.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            render={<DialogClose />}
            label="Cancel"
          />
          <Button
            stretch="auto"
            variant="danger"
            size="md"
            render={<DialogClose />}
            label="Delete"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}
```

### Non-dismissable

With `disablePointerDismissal` on the root, a backdrop click no longer closes the dialog — it
dismisses only via an explicit `DialogClose` or Escape.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

export default function NonDismissableDemo() {
  return (
    <Dialog disablePointerDismissal>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Open locked dialog"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Unsaved changes</DialogTitle>
              <DialogDescription>
                Choose an action — clicking outside won&apos;t dismiss.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
        </DialogMain>
        <DialogActions>
          <Button
            stretch="auto"
            variant="secondary"
            size="md"
            render={<DialogClose />}
            label="Discard"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}
```

### Nested dialogs

A `DialogTrigger` inside a dialog stacks a second dialog above it. The child paints its own
backdrop over the parent. Escape unwinds one level at a time.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

export default function NestedDialogsDemo() {
  return (
    <Dialog>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger />}
        label="Invite teammates"
      />
      <DialogContent>
        <DialogCloseGroup>
          <IconButton
            variant="ghost"
            size="xs"
            aria-label="Close"
            render={<DialogClose />}
            icon={<Icon icon={CloseOutline} />}
          />
        </DialogCloseGroup>
        <DialogMain>
          <DialogHeader>
            <DialogHeading>
              <DialogTitle>Invite teammates</DialogTitle>
              <DialogDescription>
                Invited members join with the Member role. Need something more specific? Create a
                custom role first.
              </DialogDescription>
            </DialogHeading>
          </DialogHeader>
        </DialogMain>
        <DialogActions>
          <Dialog>
            <Button
              stretch="auto"
              variant="secondary"
              size="md"
              render={<DialogTrigger />}
              label="Create custom role"
            />
            <DialogContent>
              <DialogCloseGroup>
                <IconButton
                  variant="ghost"
                  size="xs"
                  aria-label="Close"
                  render={<DialogClose />}
                  icon={<Icon icon={CloseOutline} />}
                />
              </DialogCloseGroup>
              <DialogMain>
                <DialogHeader>
                  <DialogHeading>
                    <DialogTitle>Create custom role</DialogTitle>
                    <DialogDescription>
                      Custom roles scope what invited members can see and edit.
                    </DialogDescription>
                  </DialogHeading>
                </DialogHeader>
              </DialogMain>
              <DialogActions>
                <Button
                  stretch="auto"
                  variant="secondary"
                  size="md"
                  render={<DialogClose />}
                  label="Back"
                />
              </DialogActions>
            </DialogContent>
          </Dialog>
          <Button
            stretch="auto"
            variant="primary"
            size="md"
            render={<DialogClose />}
            label="Send invites"
          />
        </DialogActions>
      </DialogContent>
    </Dialog>
  );
}
```

### Open from a menu

Choosing a menu item closes the menu, so the dialog lives outside it and is opened imperatively via
controlled `open` / `onOpenChange`.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
} from "@makeplane/propel/components/menu";
import { LinkOutline, EditOutline, DeleteOutline, CloseOutline } from "@makeplane/propel/icons";
import * as React from "react";

export default function OpenFromMenuDemo() {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <Menu>
        <Button
          stretch="auto"
          variant="secondary"
          size="lg"
          render={<MenuTrigger />}
          label="Project options"
        />
        <MenuContent>
          <MenuItem icon={<Icon icon={EditOutline} tint="secondary" />} label="Edit" />
          <MenuItem icon={<Icon icon={LinkOutline} tint="secondary" />} label="Copy link" />
          <MenuSeparator />
          <MenuItem
            variant="danger"
            icon={<Icon icon={DeleteOutline} />}
            onClick={() => setOpen(true)}
            label="Delete project…"
          />
        </MenuContent>
      </Menu>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogCloseGroup>
            <IconButton
              variant="ghost"
              size="xs"
              aria-label="Close"
              render={<DialogClose />}
              icon={<Icon icon={CloseOutline} />}
            />
          </DialogCloseGroup>
          <DialogMain>
            <DialogHeader>
              <DialogHeading>
                <DialogTitle>Delete project</DialogTitle>
                <DialogDescription>
                  This permanently removes the project and all of its work items. This action
                  can&apos;t be undone.
                </DialogDescription>
              </DialogHeading>
            </DialogHeader>
          </DialogMain>
          <DialogActions>
            <Button
              stretch="auto"
              variant="secondary"
              size="md"
              render={<DialogClose />}
              label="Cancel"
            />
            <Button
              stretch="auto"
              variant="danger"
              size="md"
              render={<DialogClose />}
              label="Delete"
            />
          </DialogActions>
        </DialogContent>
      </Dialog>
    </>
  );
}
```

### Detached triggers

`createDialogHandle()` links triggers to a `Dialog` defined elsewhere in the tree, so several launch
points share one dialog without lifting state.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  createDialogHandle,
  Dialog,
  DialogActions,
  DialogClose,
  DialogCloseGroup,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogHeading,
  DialogMain,
  DialogTitle,
  DialogTrigger,
} from "@makeplane/propel/components/dialog";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { CloseOutline } from "@makeplane/propel/icons";

// A handle links triggers that live far from the dialog they open, so several
// launch points can share one dialog without hoisting controlled state.
const shortcutsDialog = createDialogHandle();

export default function DetachedTriggersDemo() {
  return (
    <div className="flex items-center gap-2">
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<DialogTrigger handle={shortcutsDialog} />}
        label="Keyboard shortcuts"
      />
      <Button
        stretch="auto"
        variant="ghost"
        size="lg"
        render={<DialogTrigger handle={shortcutsDialog} />}
        label="Help"
      />
      <Dialog handle={shortcutsDialog}>
        <DialogContent>
          <DialogCloseGroup>
            <IconButton
              variant="ghost"
              size="xs"
              aria-label="Close"
              render={<DialogClose />}
              icon={<Icon icon={CloseOutline} />}
            />
          </DialogCloseGroup>
          <DialogMain>
            <DialogHeader>
              <DialogHeading>
                <DialogTitle>Keyboard shortcuts</DialogTitle>
                <DialogDescription>
                  Press <kbd>C</kbd> to create a work item, <kbd>/</kbd> to search, and <kbd>?</kbd>{" "}
                  to reopen this list from anywhere.
                </DialogDescription>
              </DialogHeading>
            </DialogHeader>
          </DialogMain>
          <DialogActions>
            <Button
              stretch="auto"
              variant="primary"
              size="md"
              render={<DialogClose />}
              label="Done"
            />
          </DialogActions>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```
## API Reference

### Dialog

The dialog Root — Base UI's context/state provider (renders no element of its own). Modal by
default; pass `disablePointerDismissal` to keep it open on outside clicks. A behavior-only role,
so it lives in `components` (rules 1a, 2); the styled parts live in `elements/dialog` and are
grafted onto Base UI behavior here.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode \| PayloadChildRenderFunction<unknown>` | — | The content of the dialog. This can be a regular React node or a render function that receives the `payload` of the active trigger. |
| `discard` | `DialogDiscard` | — | Ask before a dismissal while `when` is true: Escape, an outside press, and any `DialogClose` raise an `AlertDialog` instead of closing. "Discard" then closes the dialog and calls `onDiscard`; "Keep editing" returns to it. Closing from your own handler (Save, Post) is not a dismissal and is never intercepted: set controlled `open` to `false`, or call `actionsRef.current.close()`. Either of those while the alert is showing closes both. |

### DialogTrigger

The behavior that opens the dialog. Use as the `render` target of a `Button` so the styled
primitive's look wins via render-composition:

```tsx
<Button variant="secondary" size="lg" label="Open dialog" render={<DialogTrigger />} />;
```

Base UI manages `aria-haspopup`/`aria-expanded` and focus restoration when the dialog closes.
Maps 1:1 to `Dialog.Trigger`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DialogTriggerState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogContent

Convenience that composes the dialog overlay boilerplate — portal, shared `Backdrop`, the
top-anchored `DialogViewport`, and the `DialogPopup` — so a consumer only writes the trigger and
the popup body. Each Base UI behavior part grafts onto its styled element via `render`.

Nested dialogs keep their own dim: Base UI omits a nested Backdrop unless `forceRender` is set,
which would leave a child modal sitting on the parent with no extra overlay. Closed
force-rendered backdrops set `pointer-events: none` so they cannot steal clicks from the parent
while unmounting. `DialogPanesContext` is reset here so a nested dialog does not inherit pane
fill from a parent `DialogPanes` through the portal.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| "full"` | `md` | Dialog width: `xs` 480 px, `sm` 576 px, `md` 672 px, `lg` 800 px, `xl` 1000 px, or `full`, which fills the viewport minus an 80 px margin on every edge and pins the height as well. The ready-made `DialogMain`, `DialogToolbar`, `DialogActions`, and `DialogAside` read it for their gutter (16 px, 48 px at `full`). |
| `height` | `"auto" \| "fixed"` | `auto` | Dialog height. `auto` (default) sizes to content up to the viewport ceiling. `fixed` pins 700 px so split chrome does not resize when the active pane changes. Prefer `size="full"` when the popup should fill the viewport. |
| `density` | `"compact" \| "comfortable"` | `comfortable` | Compactness of Main / Toolbar / Actions / Aside gutters. `size` already picks the popup width and the default gutter; `density` is the second axis when that coupling is wrong. `comfortable` (the default) is 16 px, 48 px at `full`. `compact` keeps 16 px at every size, including `full`. |

### DialogToolbar

Top chrome of the dialog popup, sibling of `DialogMain`. Its start gutter follows the `size`
chosen on `DialogContent` (16 px, 48 px at `full`); the end gutter is 40 px so an end-aligned
control clears the corner dismiss. `density="compact"` keeps 16 px start at `full`. Bottom border
is flush to the popup, matching `DialogActions`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogTitle

The accessible title for the dialog: Base UI's `Dialog.Title` behavior (links to the popup via
`aria-labelledby`) grafted onto the dialog-owned title chrome (`text-h5-medium`).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DialogTitleState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogDescription

The supporting description for the dialog: Base UI's `Dialog.Description` behavior (links to the
popup via `aria-describedby`) grafted onto the dialog-owned description chrome (`text-body-xs`
tertiary).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DialogDescriptionState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogClose

The behavior that closes the dialog when activated. Use as the `render` target of a `Button` or
`IconButton` so the styled primitive's look wins via render-composition. Corner dismiss belongs
in `DialogCloseGroup`:

```tsx
<DialogCloseGroup>
  <IconButton
    variant="ghost"
    size="xs"
    aria-label={closeLabel}
    icon={<Icon icon={X} />}
    render={<DialogClose />}
  />
</DialogCloseGroup>;
```

Maps 1:1 to `Dialog.Close`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DialogCloseState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogPanes

The side-by-side row of a two-pane dialog: `DialogAside` on the start side, `DialogMain` filling
the rest. Marks its descendants so the ready-made `DialogMain` fills the row (rule 7).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogAside

A 280 px start-side column inside `DialogPanes`, beside `DialogMain`. Stretches to the panes'
height so its end seam is the full popup. Holds navigation for the dialog's content — a step
list, a section list. Its gutter follows the `size` chosen on `DialogContent` (16 px, 48 px at
`full`); `density="compact"` keeps 16 px at `full`. Pass `tabIndex={0}` when its content can
overflow.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogMain

The padded top section of the dialog popup: optional `DialogProgression`, `DialogHeader` /
`DialogHeading`, and the scrollable `DialogBody`. Its gutter follows the `size` chosen on
`DialogContent` (16 px, 48 px at `full`); `density="compact"` keeps 16 px at `full`. Inside
`DialogPanes` it fills the width beside `DialogAside`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogCloseGroup

Positions the dismiss control at the popup's top inline-end corner. Hold an `IconButton` (size
`xs`, variant `ghost`) with `render={<DialogClose />}` so the button chrome wins and Base UI
supplies close behavior.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogProgression

Optional step row at the top of `DialogHeader`: compose a back `IconButton` (size `xs`, variant
`ghost`) and the step label as children. Caption-md tertiary chrome is baked in. Laid out in
normal flow (not Figma's absolute hang into the gutter) — the glyph sits on the Main padding
edge; consumers supply the back handler.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogHeader

The top block inside `DialogMain`: stacks an optional `DialogProgression` over a `DialogHeading`
(title + optional description). Dismiss lives in `DialogCloseGroup` on the popup, not here — this
part carries extra end padding so progression/title clear the absolute close without widening
Main/Body gutters. Full-width header controls (e.g. a command-palette search field) skip this
part and sit directly in `DialogMain` so they keep equal side gutters.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogHeading

The heading block: stacks the `DialogTitle` over an optional `DialogDescription` with a tight
gap. Sit inside `DialogHeader` (optionally under `DialogProgression`). Keep supporting copy here
— not in `DialogBody` — so title/description use the 6 px heading rhythm.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogBody

The scrollable main slot inside `DialogMain`. Grows to fill available space and scrolls
vertically when content exceeds the popup's max-height. `overflow-x-clip` on this node keeps
trackpad deltas from panning sideways; an inner width clipper keeps focus scrolling from shifting
the body under sticky header/footer. A 2 px padded bleed keeps field `ring-2` focus chrome from
being masked by the vertical clip. Pass form fields, lists, or other slot content — keep
`DialogDescription` in `DialogHeading`.

When the body can overflow, pass `tabIndex={0}` so the scroll region is keyboard-reachable (axe
`scrollable-region-focusable`).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogActions

The footer of the dialog popup: an optional `DialogInfo` at the inline-start, then action
buttons. Its gutter follows the `size` chosen on `DialogContent` (16 px, 48 × 18 px at `full`);
`density="compact"` keeps 16 px at `full`. Stays visible while `DialogBody` scrolls.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogInfo

Optional footer helper at the inline-start of `DialogActions`. Compose a `DialogInfoIcon` and the
helper label as children. Flexes to push action buttons to the inline-end.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### DialogInfoIcon

Decorative 14 px icon slot inside `DialogInfo`. Bakes `aria-hidden` and sizes its child via the
shared node-slot. Pass a Propel glyph (or `<Icon icon={…} />`) as children.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
