Components

Dialog

A modal surface over the whole interface, for a task or a question that must be finished or dismissed first.

Markdown

A dialog is a surface over the whole interface for a task or a question that must be finished or dismissed before anything else: a confirmation, a short form. An alert is a dialog that interrupts to warn, and asks for an answer.

Files

New folder opens a dialog with its name ready to type over; Return creates it, Escape or a press outside leaves it. Remove asks first, in an alert that opens on Cancel and that a press outside does not close. On a phone, each is a sheet.

Remove “Floor plan.pdf”?

It is moved to the bin, and can be put back from there for thirty days.

Code
"use client";

import { Button } from "@publictheta/ui/components/button/button.tsx";
import { Checkbox } from "@publictheta/ui/components/checkbox/checkbox.tsx";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogTitle,
  DialogTrigger,
} from "@publictheta/ui/components/dialog/dialog.tsx";
import { useState } from "react";

const FILES = ["Budget 2027.numbers", "Cover letter.pages", "Floor plan.pdf", "Holiday.jpg"];

export default function DialogFiles() {
  const [files, setFiles] = useState(FILES);
  const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set(["Floor plan.pdf"]));
  const [folders, setFolders] = useState<readonly string[]>([]);
  const [creating, setCreating] = useState(false);
  const [name, setName] = useState("");

  const count = chosen.size;
  const what = count === 1 ? `“${[...chosen][0]}”` : `${count} files`;

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <div className="flex gap-2">
        <Dialog
          open={creating}
          onOpenChange={(open) => {
            if (open) setName("Untitled folder");
            setCreating(open);
          }}
        >
          <DialogTrigger>New folder…</DialogTrigger>
          <DialogContent>
            <form
              onSubmit={(event) => {
                event.preventDefault();
                if (name.trim() === "") return;
                setFolders((current) => [...current, name.trim()]);
                setCreating(false);
              }}
            >
              <DialogTitle>New folder</DialogTitle>
              <DialogDescription>It is made in Documents, beside these files.</DialogDescription>
              <label className="mt-4 flex flex-col gap-1.5 text-sm font-medium text-fg-strong">
                Name
                <input
                  value={name}
                  onChange={(event) => setName(event.target.value)}
                  onFocus={(event) => event.target.select()}
                  className="h-9 rounded-md border border-border bg-surface px-2.5 text-sm font-normal outline-none focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-focus-ring pointer-coarse:h-11 pointer-coarse:text-base"
                />
              </label>
              <div className="mt-6 flex justify-end gap-2">
                <DialogClose>Cancel</DialogClose>
                <Button
                  type="submit"
                  variant="primary"
                  disabled={name.trim() === ""}
                  disabledReason="A folder needs a name."
                >
                  Create
                </Button>
              </div>
            </form>
          </DialogContent>
        </Dialog>
        <Dialog alert>
          <DialogTrigger
            variant="danger"
            disabled={count === 0}
            disabledReason="Choose the files to remove first."
          >
            Remove…
          </DialogTrigger>
          <DialogContent>
            <DialogTitle>Remove {what}?</DialogTitle>
            <DialogDescription>
              {count === 1 ? "It is" : "They are"} moved to the bin, and can be put back from there for thirty
              days.
            </DialogDescription>
            <div className="mt-6 flex justify-end gap-2">
              <DialogClose>Cancel</DialogClose>
              <DialogClose
                variant="danger"
                onClick={() => {
                  setFiles((current) => current.filter((file) => !chosen.has(file)));
                  setChosen(new Set());
                }}
              >
                Remove
              </DialogClose>
            </div>
          </DialogContent>
        </Dialog>
      </div>
      <ul aria-label="Documents" className="flex flex-col rounded-lg border border-border-muted">
        {folders.map((folder) => (
          <li key={folder} className="flex items-center gap-3 border-b border-border-muted px-3 py-2 text-sm">
            <span aria-hidden className="inline-block size-4 rounded-sm bg-accent/40" />
            {folder}
          </li>
        ))}
        {files.map((file) => (
          <li key={file} className="border-b border-border-muted last:border-b-0">
            <label className="flex items-center gap-3 px-3 py-2 text-sm text-fg-strong pointer-coarse:py-3">
              <Checkbox
                checked={chosen.has(file)}
                onCheckedChange={(on) =>
                  setChosen((current) => {
                    const next = new Set(current);
                    if (on) next.add(file);
                    else next.delete(file);
                    return next;
                  })
                }
              />
              {file}
            </label>
          </li>
        ))}
        {files.length === 0 && folders.length === 0 && (
          <li className="px-3 py-6 text-center text-sm text-fg-muted">Nothing here.</li>
        )}
      </ul>
    </div>
  );
}

When to use

Use a dialog when the interface cannot go on until something is decided — before what cannot be undone — or for a task that needs the person's whole attention for a moment. For a small task beside the control it belongs to, use a popover, which leaves the rest of the interface in reach. For a list of commands, a menu.

Why it is a component

A popover is also a dialog, on the same overlay primitive, but it differs in each of the things that make a dialog what it is:

  • Not anchored. A dialog is about the whole task, not one control: it sits in the middle of the view, whatever opened it — a button, a menu command, the application itself.
  • Always modal. The page behind it is inert and still on every screen, not only on a phone; a press outside reaches nothing beneath. A popover lets the rest of the page be used.
  • Its own role and name. It is announced as a modal dialog, or an alert dialog, named by its title and described by its description.
  • Its own focus rules. Focus opens on its first control — in an alert, on Cancel — whether the dialog was opened by its control or by its owner's state.

What the two share — the dismissal stack, focus moved in and brought back, sheets on phones, the page held inert and still — is the overlay primitive's, which grew the blocking and the alert's indifference to a stray press for it.

Best practices

Title it, and say what it is about. The title names the dialog; a sentence or two under it is its description. An alert's title is the question: Remove three files?

Name the way out. The buttons say what they do — Remove, Cancel — not Yes and No. The one that destroys is the danger button, and is not where focus opens.

Keep it short. A dialog that needs scrolling or steps of its own wants to be a view.

Do not open one dialog over another unless the second is a question about the first.

Platform considerations

Pointer and keyboard. The dialog sits in the middle of the view over a dimmed interface. Focus moves to its first control — to Cancel in an alert — or to the dialog itself when it has nothing to operate. Tab moves through it and does not reach the page behind. Escape closes it, an alert too, and focus returns to what opened it, or, opened by its owner, to what had focus before. A press outside a dialog closes it and does nothing else; a press outside an alert does nothing, and focus stays where it was. Where a scrollbar takes room, the page does not shift sideways as the dialog opens.

Touch, on a phone. The dialog rises from the bottom edge as a sheet, which is dragged away as any sheet is. With no Escape key, it always has a button that closes it: one given none has a close button at its top corner, last in the order of focus and 44 CSS pixels across.

Assistive technology. It is a modal dialog, or an alert dialog, named by its title and described by its description; the page behind it is inert. The control that opens it says that it opens a dialog, and whether it is open.

See also Overlays.

Behaviour

Semantics

The control states that it opens a dialog, and whether it is open
const screen = await render(<Remove />);
await expect.element(trigger(screen)).toHaveAttribute("aria-haspopup", "dialog");
await expect.element(trigger(screen)).toHaveAttribute("aria-expanded", "false");
await open(screen);
await expect.element(trigger(screen)).toHaveAttribute("aria-expanded", "true");
It is a modal dialog, named by its title and described by its description
const screen = await render(<Remove />);
await open(screen);
await expect.element(dialog(screen)).toBeVisible();
await expect.element(dialog(screen)).toHaveAttribute("aria-modal", "true");
await expect
  .element(dialog(screen))
  .toHaveAccessibleDescription("They are moved to the bin, and can be put back from there.");
An alert is an alert dialog
const screen = await render(<Remove alert />);
await open(screen);
await expect.element(dialog(screen, "alertdialog")).toBeVisible();
Has no accessibility violations when open
const screen = await render(<Remove />);
await open(screen);
await expectAccessible(screen.container);
Has no accessibility violations when open as an alert
const screen = await render(<Remove alert />);
await open(screen);
await expectAccessible(screen.container);
One given no title is reported while developing
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
try {
  await render(
    <Dialog>
      <DialogContent>Nameless</DialogContent>
    </Dialog>,
  );
  expect(errors).toHaveBeenCalledWith(expect.stringContaining("give the dialog a `DialogTitle`"));
  errors.mockClear();
  await render(
    <Dialog>
      <DialogContent aria-label="Named">Named</DialogContent>
    </Dialog>,
  );
  await render(<Remove />);
  expect(errors).not.toHaveBeenCalled();
} finally {
  errors.mockRestore();
}

Focus

Opening moves focus to its first control
const screen = await render(<Remove />);
await open(screen);
await expect.element(screen.getByRole("textbox", { name: "Note" })).toHaveFocus();
An alert opens with focus on its Cancel, so that a habitual Return does not do what it warns of
const screen = await render(<Remove alert />);
await open(screen);
await expect.element(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
With nothing to operate, focus moves to the dialog itself
const screen = await render(
  <Dialog>
    <DialogTrigger>About</DialogTrigger>
    <DialogContent>
      <DialogTitle>About</DialogTitle>
    </DialogContent>
  </Dialog>,
);
await (hasTouch
  ? commands.tap(screen.getByRole("button", { name: "About" }).selector)
  : userEvent.click(screen.getByRole("button", { name: "About" })));
// On a phone it has a close button of its own: that is something to operate.
if (hasTouch) await expect.element(screen.getByRole("button", { name: "Close" })).toHaveFocus();
else await expect.element(screen.getByRole("dialog", { name: "About" })).toHaveFocus();
Opened by its owner rather than its control, focus still moves to its first control
function Owned() {
  const [asking, setAsking] = useState(false);
  return (
    <>
      <input aria-label="Before" />
      <Button onClick={() => setAsking(true)}>Ask</Button>
      <Dialog open={asking} onOpenChange={setAsking}>
        <DialogContent>
          <DialogTitle>Question</DialogTitle>
          <input aria-label="Answer" />
        </DialogContent>
      </Dialog>
    </>
  );
}
const screen = await render(<Owned />);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
(screen.getByRole("button", { name: "Ask" }).element() as HTMLElement).focus();
await userEvent.keyboard("{Enter}");
await expect.element(screen.getByRole("textbox", { name: "Answer" })).toHaveFocus();
// With no control of its own, closing returns focus to what had it.
await userEvent.keyboard("{Escape}");
await expect.element(screen.getByRole("button", { name: "Ask" })).toHaveFocus();
Escape closes it and returns focus to the control that opened it
const screen = await render(<Remove />);
await open(screen);
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(trigger(screen)).toHaveFocus();
A close button closes it and returns focus to the control that opened it
const screen = await render(<Remove />);
await open(screen);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(trigger(screen)).toHaveFocus();

Modality

While it is open the page behind is inert, and afterwards exactly as it was
const screen = await render(<Remove />);
const after = screen.getByRole("textbox", { name: "After" }).element();
await open(screen);
expect(after.closest("[inert]")).not.toBeNull();
expect(dialog(screen).element().closest("[inert]")).toBeNull();
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
expect(screen.container.querySelector("[inert]")).toBeNull();
expect(after.closest("[inert]")).toBeNull();
Tab does not take focus to the page behind it
const screen = await render(<Remove />);
await open(screen);
await expect.element(screen.getByRole("textbox", { name: "Note" })).toHaveFocus();
for (let index = 0; index < 4; index += 1) {
  await userEvent.tab();
  const active = document.activeElement;
  expect(active === document.body || dialog(screen).element().contains(active)).toBe(true);
}
The page behind does not scroll while it is open, and scrolls again once it closes
const screen = await render(
  <>
    <Remove />
    <div style={{ height: 3000 }} />
  </>,
);
await open(screen);
expect(getComputedStyle(document.documentElement).overflow).toBe("hidden");
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
expect(getComputedStyle(document.documentElement).overflow).not.toBe("hidden");
Where a scrollbar takes room, it keeps its room while the page is held still; where none does, none is madeChromium, Firefox, WebKit

Not yet written.

Dismissal

A press outside closes it, and does not press what it lands on
const onBehind = vi.fn();
const screen = await render(<Remove onBehind={onBehind} />);
await open(screen);
await userEvent.click(screen.getByRole("button", { name: "Behind" }), regardless);
await expect.poll(() => isOpen(screen)).toBe(false);
expect(onBehind).not.toHaveBeenCalled();
A press outside an alert leaves it open, with focus where it was
const onBehind = vi.fn();
const screen = await render(<Remove alert onBehind={onBehind} />);
await open(screen);
const cancel = screen.getByRole("button", { name: "Cancel" });
await expect.element(cancel).toHaveFocus();
await userEvent.click(screen.getByRole("button", { name: "Behind" }), regardless);
expect(await isOpen(screen, "alertdialog")).toBe(true);
await expect.element(cancel).toHaveFocus();
expect(onBehind).not.toHaveBeenCalled();
Escape closes an alert too: it is the same as Cancel
const onRemove = vi.fn();
const screen = await render(<Remove alert onRemove={onRemove} />);
await open(screen);
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen, "alertdialog")).toBe(false);
expect(onRemove).not.toHaveBeenCalled();
With a popover open inside it, Escape closes the popover alone
const screen = await render(
  <Dialog defaultOpen>
    <DialogContent>
      <DialogTitle>Share</DialogTitle>
      <Popover>
        <PopoverTrigger>Options</PopoverTrigger>
        <PopoverContent aria-label="Options">
          <input aria-label="Expires" />
        </PopoverContent>
      </Popover>
    </DialogContent>
  </Dialog>,
);
const options = screen.getByRole("button", { name: "Options" });
await (hasTouch ? commands.tap(options.selector) : userEvent.click(options));
await expect.element(screen.getByRole("textbox", { name: "Expires" })).toHaveFocus();
await userEvent.keyboard("{Escape}");
await expect.element(options).toHaveFocus();
await expect.element(screen.getByRole("dialog", { name: "Share" })).toBeVisible();
await userEvent.keyboard("{Escape}");
await expect.poll(() => screen.getByRole("dialog", { name: "Share" }).query()).toBeNull();

Presentation

With a pointer it sits in the middle of the view, not against its controlChromium, Firefox, WebKit

Not yet written.

On a phone it rises from the bottom edge across the full widthChromium · touch, WebKit · touch

Not yet written.

On a phone, one given no close button has one all the same, last in its order and 44 pixels acrossChromium · touch, WebKit · touch

Not yet written.

With a pointer, one given no close button has noneChromium, Firefox, WebKit

Not yet written.

API

A controlled open is told of each close, by any means
const onOpenChange = vi.fn();
const screen = await render(<Remove onOpenChange={onOpenChange} />);
await open(screen);
await userEvent.keyboard("{Escape}");
expect(onOpenChange.mock.calls).toEqual([[true], [false]]);
A close button's onClick that prevents the default keeps it open
const screen = await render(
  <Dialog defaultOpen>
    <DialogContent>
      <DialogTitle>Unsaved</DialogTitle>
      <DialogClose onClick={(event) => event.preventDefault()}>Not yet</DialogClose>
    </DialogContent>
  </Dialog>,
);
await userEvent.click(screen.getByRole("button", { name: "Not yet" }));
// Shown, not merely still fading out.
expect(screen.getByRole("dialog", { name: "Unsaved" }).element().matches(":popover-open")).toBe(true);
messages names the close button in another language
const screen = await render(
  <Dialog defaultOpen messages={{ close: "閉じる" }}>
    <DialogContent>
      <DialogTitle>詳細</DialogTitle>
      <DialogClose />
    </DialogContent>
  </Dialog>,
);
await expect.element(screen.getByRole("button", { name: "閉じる" })).toBeVisible();

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/dialog.json
Source

src/components/dialog/dialog.tsx

"use client";

import { createContext, use, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { ComponentProps, MouseEvent, ReactElement, ReactNode } from "react";
import { CloseIcon } from "../../lib/close-icon.tsx";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { overlayLookClassName } from "../../lib/overlay-class.ts";
import { cn } from "../../lib/utils.ts";
import { useCloseButtons, useOverlay, useSheet } from "../../primitives/overlay.ts";
import type { Overlay } from "../../primitives/overlay.ts";
import { Button } from "../button/button.tsx";
import type { ButtonProps } from "../button/button.tsx";

/** What a dialog says in words; pass `messages` to say it in another language. */
export interface DialogMessages {
  /** The name of a close button that is given no label of its own. */
  readonly close: string;
}

const MESSAGES: DialogMessages = { close: "Close" };

interface DialogRoot {
  readonly overlay: Overlay;
  readonly alert: boolean;
  readonly messages: DialogMessages;
  readonly closable: boolean;
  readonly addClose: () => () => void;
  readonly titleId: string | undefined;
  readonly descriptionId: string | undefined;
  /** Say that a title or a description, of this id, is in the dialog — until what is returned is called. */
  readonly addTitle: (id: string) => () => void;
  readonly addDescription: (id: string) => () => void;
}

const DialogContext = createContext<DialogRoot | null>(null);

function useRoot(name: string): DialogRoot {
  const root = use(DialogContext);
  if (root === null) throw new Error(`${name} must be rendered inside a Dialog.`);
  return root;
}

const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

/**
 * Where focus goes on opening. In a dialog, to its first control; in an alert,
 * to the button that only closes it — Cancel — so that a Return pressed out of
 * habit does not do what the alert warns of.
 */
function focusable(alert: boolean) {
  return (content: HTMLElement): HTMLElement[] => {
    const all = Array.from(content.querySelectorAll<HTMLElement>(FOCUSABLE));
    if (!alert) return all;
    // A close button with a label, and not the one that acts: what only closes.
    const cancel = all.filter((element) =>
      element.matches(
        "[data-dialog-close]:not([data-size=icon], [data-variant=danger], [data-variant=primary])",
      ),
    );
    return [...cancel, ...all];
  };
}

export type DialogProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /**
   * An alert: it interrupts to say something that needs an answer — a
   * confirmation before what cannot be undone. A press outside does not close
   * it, and focus opens on its Cancel.
   */
  alert?: boolean;
  messages?: Partial<DialogMessages>;
  children?: ReactNode;
};

/**
 * A modal dialog: a surface over the whole interface, not attached to any
 * control, for a task or a question that must be finished or dismissed before
 * anything else. The page behind is inert and still on every screen; on a phone
 * it is a sheet.
 */
export function Dialog({
  open,
  defaultOpen,
  onOpenChange,
  alert = false,
  messages,
  children,
}: DialogProps): ReactElement {
  const choose = useMemo(() => focusable(alert), [alert]);
  const overlay = useOverlay({
    open,
    defaultOpen,
    onOpenChange,
    blocking: true,
    modal: true,
    dismissOnPressOutside: !alert,
    defaultFocus: "first",
    focusable: choose,
  });
  const { closable, addClose } = useCloseButtons();
  const [titleId, addTitle] = useLabel();
  const [descriptionId, addDescription] = useLabel();
  const root = useMemo(
    () => ({
      overlay,
      alert,
      messages: { ...MESSAGES, ...messages },
      closable,
      addClose,
      titleId,
      descriptionId,
      addTitle,
      addDescription,
    }),
    [overlay, alert, messages, closable, addClose, titleId, descriptionId, addTitle, addDescription],
  );
  return <DialogContext value={root}>{children}</DialogContext>;
}

/** The id of the part that is there to point to, if one is. */
function useLabel(): [string | undefined, (id: string) => () => void] {
  const [id, setId] = useState<string>();
  const add = useCallback((next: string) => {
    setId(next);
    return () => setId((current) => (current === next ? undefined : current));
  }, []);
  return [id, add];
}

/** A button that opens the dialog. Any button may, by setting `open`: this one also says so. */
export function DialogTrigger({ ref, style, onClick, ...props }: ButtonProps): ReactElement {
  const { overlay } = useRoot("DialogTrigger");
  return (
    <Button
      aria-haspopup="dialog"
      {...overlay.triggerProps}
      ref={mergeRefs(overlay.triggerProps.ref, ref)}
      style={{ ...overlay.triggerProps.style, ...style }}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) overlay.setOpen(true, "first");
      }}
      {...props}
    />
  );
}

export function DialogContent({
  className,
  ref,
  style,
  children,
  ...props
}: ComponentProps<"div">): ReactElement {
  const { overlay, alert, closable, titleId, descriptionId } = useRoot("DialogContent");
  const sheet = useSheet();
  const own = useRef<HTMLDivElement>(null);
  const named = props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined;
  useEffect(() => {
    // Looked for in the page: a title says it is there only after this has run.
    if (process.env.NODE_ENV === "production" || named || own.current?.querySelector("[data-dialog-title]")) {
      return;
    }
    console.error(
      "DialogContent: give the dialog a `DialogTitle`, or a name with `aria-label`; without one, assistive technology announces a dialog with no name.",
    );
  }, [named]);

  return (
    <div
      role={alert ? "alertdialog" : "dialog"}
      aria-labelledby={named ? undefined : titleId}
      aria-describedby={descriptionId}
      {...overlay.contentProps}
      ref={mergeRefs(overlay.contentProps.ref, own, ref)}
      tabIndex={-1}
      style={{ ...overlay.contentProps.style, ...style }}
      data-alert={alert ? "" : undefined}
      className={cn(
        overlayLookClassName,
        // In the middle of the view, over a scrim, rather than against a control.
        "fixed inset-0 m-auto h-fit w-[min(30rem,calc(100vw-2rem))] max-w-none p-6 backdrop:bg-scrim",
        "pointer-coarse:max-sm:m-0 pointer-coarse:max-sm:w-full pointer-coarse:max-sm:pb-[max(1.5rem,env(safe-area-inset-bottom))]",
        // Beneath the grabber, a band the close button sits in, clear of the content.
        "pointer-coarse:max-sm:pt-2 pointer-coarse:max-sm:before:mb-8",
        className,
      )}
      {...props}
    >
      {children}
      {sheet && !closable && <CloseButton className="absolute end-1 top-1" />}
    </div>
  );
}

/** The dialog's title, which names it. */
export function DialogTitle({ id, className, children, ...props }: ComponentProps<"h2">): ReactElement {
  const { addTitle } = useRoot("DialogTitle");
  const own = useId();
  const titleId = id ?? own;
  useEffect(() => addTitle(titleId), [addTitle, titleId]);
  return (
    <h2
      id={titleId}
      data-dialog-title=""
      className={cn("text-base font-semibold text-fg-strong", className)}
      {...props}
    >
      {children}
    </h2>
  );
}

/** What the dialog is about, in a sentence or two; it is the dialog's description. */
export function DialogDescription({ id, className, ...props }: ComponentProps<"p">): ReactElement {
  const { addDescription } = useRoot("DialogDescription");
  const own = useId();
  const descriptionId = id ?? own;
  useEffect(() => addDescription(descriptionId), [addDescription, descriptionId]);
  return <p id={descriptionId} className={cn("mt-2 text-sm text-fg", className)} {...props} />;
}

/**
 * A button that closes the dialog. Given a label — Cancel, Done — it is a
 * button like the task's others; given none it is an icon button, named from
 * `messages`.
 */
export function DialogClose(props: ButtonProps): ReactElement {
  const { addClose } = useRoot("DialogClose");
  useEffect(() => addClose(), [addClose]);
  return <CloseButton {...props} />;
}

function CloseButton({ onClick, children, ...props }: ButtonProps): ReactElement {
  const { overlay, messages } = useRoot("DialogClose");
  const icon = children === undefined;
  return (
    <Button
      data-dialog-close=""
      variant={icon ? "ghost" : undefined}
      size={icon ? "icon" : undefined}
      aria-label={icon ? messages.close : undefined}
      onClick={(event: MouseEvent<HTMLButtonElement>) => {
        onClick?.(event);
        if (!event.defaultPrevented) overlay.setOpen(false);
      }}
      {...props}
    >
      {children ?? <CloseIcon />}
    </Button>
  );
}