diff --git a/components/ui/__tests__/dialog.test.tsx b/components/ui/__tests__/dialog.test.tsx
new file mode 100644
index 000000000..34046f93d
--- /dev/null
+++ b/components/ui/__tests__/dialog.test.tsx
@@ -0,0 +1,162 @@
+import { useRef, useState } from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Dialog } from '../dialog';
+import { ConfirmDialog } from '../confirm-dialog';
+import { PromptDialog, nameSelectionRange } from '../prompt-dialog';
+
+afterEach(() => vi.useRealTimers());
+
+function backdropOf(panel: HTMLElement) {
+ return panel.parentElement as HTMLElement;
+}
+
+describe('Dialog', () => {
+ it('renders in a portal on body, labelled by its title and described by its description', () => {
+ const { container } = render(
+
+
,
+ );
+ const dialog = screen.getByRole('dialog', { name: 'Rename' });
+ expect(container.contains(dialog)).toBe(false);
+ expect(dialog.closest('body > div.fixed')).not.toBeNull();
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
+ expect(dialog).toHaveAccessibleDescription('Pick a name');
+ expect(screen.getByRole('heading', { level: 2, name: 'Rename' })).toHaveClass('text-lg', 'font-semibold');
+ expect(backdropOf(dialog)).toHaveClass('z-[60]', 'bg-black/50');
+ });
+
+ it('has a named close button at the top end that closes it', () => {
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('focuses the first field, or the initialFocus target, and restores the focus on close', () => {
+ function Host() {
+ const [open, setOpen] = useState(false);
+ const target = useRef(null);
+ return (
+ <>
+
+
+
);
}
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 000000000..bb589d7d6
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useEffect, useId, useRef, type ReactNode, type RefObject, type FormEvent } from "react";
+import { createPortal } from "react-dom";
+import { useTranslations } from "next-intl";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { IconButton } from "@/components/ui/icon-button";
+import { X } from "@/components/icons";
+import { cn } from "@/lib/utils";
+
+export type DialogSize = "sm" | "md" | "lg" | "full-on-phone";
+
+export interface DialogProps {
+ isOpen: boolean;
+ /** Called by Escape, the close X and an outside click. Ask about unsaved input here (R5). */
+ onClose: () => void;
+ title: ReactNode;
+ /** One line under the title; becomes the dialog's description. */
+ description?: ReactNode;
+ /** The body, under the title and description. */
+ children?: ReactNode;
+ /** The buttons: Cancel (outline) first, then the primary. Laid out at the end. */
+ footer?: ReactNode;
+ /** `alertdialog` for confirmations. */
+ role?: "dialog" | "alertdialog";
+ /** sm 384px, md 448px (default), lg 672px; full-on-phone fills a phone screen and is lg above. */
+ size?: DialogSize;
+ /** Drawn before the title, e.g. the warning icon of a destructive confirm. */
+ icon?: ReactNode;
+ /** The close X at the top end. Default true. */
+ showClose?: boolean;
+ /** Ignore clicks outside, e.g. while the dialog holds unsaved input. */
+ preventOutsideClose?: boolean;
+ /** Same as preventOutsideClose: the dialog holds unsaved input. */
+ dirty?: boolean;
+ /** Where the focus goes on open. Default: the first field, else the first control. */
+ initialFocus?: RefObject;
+ /** Wraps body and footer in a form, so Enter in a field submits. */
+ onSubmit?: (e: FormEvent) => void;
+ /** Extra classes for the panel. */
+ className?: string;
+ /** Extra classes for the body. */
+ bodyClassName?: string;
+ /** Test hook on the panel. */
+ testId?: string;
+}
+
+const SIZES: Record = {
+ sm: "max-w-sm",
+ md: "max-w-md",
+ lg: "max-w-2xl",
+ "full-on-phone":
+ "max-w-2xl max-sm:max-w-none max-sm:h-full max-sm:max-h-none max-sm:rounded-none max-sm:border-0",
+};
+
+const FIELD_SELECTOR =
+ 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"]';
+
+/**
+ * The one dialog shell (R4): a portal on at z-[60] over a dimmed
+ * backdrop, labelled by its title, focus moved in and given back, Escape
+ * from anywhere inside, outside click closes unless `dirty`.
+ *
+ * ```tsx
+ * >}>
+ *
+ *
+ * ```
+ */
+export function Dialog({
+ isOpen,
+ onClose,
+ title,
+ description,
+ children,
+ footer,
+ role = "dialog",
+ size = "md",
+ icon,
+ showClose = true,
+ preventOutsideClose = false,
+ dirty = false,
+ initialFocus,
+ onSubmit,
+ className,
+ bodyClassName,
+ testId,
+}: DialogProps) {
+ const tCommon = useTranslations("common");
+ const id = useId();
+ const titleId = `${id}-title`;
+ const descriptionId = `${id}-description`;
+ const panelRef = useFocusTrap({ isActive: isOpen, onEscape: onClose, restoreFocus: true });
+ // A press that started on the backdrop, so a text selection dragged out of
+ // a field and released outside doesn't count as an outside click.
+ const pressedOnBackdrop = useRef(false);
+
+ // The focus trap has put the focus on the first control; a field or an
+ // explicit target is the better start.
+ useEffect(() => {
+ if (!isOpen) return;
+ const panel = panelRef.current;
+ if (!panel) return;
+ const target = initialFocus?.current ?? panel.querySelector(FIELD_SELECTOR);
+ target?.focus();
+ // Only on open: a later render must not pull the focus back.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen]);
+
+ if (!isOpen || typeof document === "undefined") return null;
+
+ const blockOutside = preventOutsideClose || dirty;
+
+ const body = (
+ <>
+