Component
Nested Dialogs
A dialog opening another dialog: a member-removal confirmation stacked over the share dialog, with Escape closing only the top-most and focus restoring down the chain.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/dialogs/dialog-nested/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Dialogs/dialog-nested React/Components/Dialogs/dialog-nested import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
RefObject,
} from "react";
/**
* DevSnips React Dialog — Nested.
*
* The shared dialog core composed two layers deep: a confirmation dialog
* stacked over its parent. The module-level open stack makes Escape close
* only the top-most dialog, the shared scroll-lock counter keeps the page
* locked until every dialog closes, and each root restores focus to the
* element that opened it.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------------ */
/* Module-level dialog stack + scroll lock */
/* ------------------------------------------------------------------------ */
/**
* Every open dialog registers its key here; the last entry is the top-most
* dialog. Escape handling uses the stack so only the top-most dialog closes.
*/
const openDialogKeys: string[] = [];
let scrollLockCount = 0;
let previousOverflow = "";
let previousPaddingRight = "";
function lockScroll(): void {
scrollLockCount += 1;
if (scrollLockCount === 1) {
previousOverflow = document.body.style.overflow;
previousPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
document.body.style.overflow = "hidden";
}
}
function unlockScroll(): void {
scrollLockCount -= 1;
if (scrollLockCount === 0) {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
}
}
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Focusable elements inside `root` (excludes hidden / aria-hidden nodes). */
function focusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.hidden && el.getAttribute("aria-hidden") !== "true",
);
}
const TRIGGER_CLASSES =
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const CONTENT_CLASSES =
"fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] shadow-[var(--ds-shadow-lg)] focus:outline-none";
const HEADER_CLASSES = "flex flex-col gap-1.5 px-5 pt-5";
const TITLE_CLASSES =
"text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES = "text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const FOOTER_CLASSES =
"flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:justify-end [&>button]:w-full sm:[&>button]:w-auto";
// One class constant per close-action kind — the kinds never rely on
// conflicting-utility overrides (Tailwind resolves conflicts by stylesheet
// order, not class order, so overrides of bg-*/border-* are unreliable).
const CLOSE_VARIANT_CLASSES: Record<DialogCloseVariant, string> = {
outline: TRIGGER_CLASSES,
primary:
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
destructive:
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-destructive)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-destructive-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-destructive)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-destructive)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
ghost:
"absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
};
interface DialogContextValue {
open: boolean;
modal: boolean;
contentId: string;
titleId: string;
descriptionId: string;
hasTitle: boolean;
hasDescription: boolean;
registerTitle(): () => void;
registerDescription(): () => void;
rememberFocus(element: HTMLElement | null): void;
requestOpen(next: boolean): void;
requestClose(): void;
triggerRef: RefObject<HTMLButtonElement>;
}
const DialogContext = createContext<DialogContextValue | null>(null);
function useDialog(component: string): DialogContextValue {
const context = useContext(DialogContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Dialog>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* Dialog (root) */
/* ------------------------------------------------------------------------ */
export interface DialogProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the dialog requests to open or close. */
onOpenChange?: (open: boolean) => void;
/**
* Modal behavior (default true): overlay, scroll lock, focus trap, and
* `aria-modal`. Set false for a non-modal floating panel (page stays
* interactive; close on Escape or outside pointer down).
*/
modal?: boolean;
children?: ReactNode;
}
export function Dialog({
open,
defaultOpen = false,
onOpenChange,
modal = true,
children,
}: DialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const reactId = useId();
const dialogKey = `ds-dialog${reactId}`;
const contentId = `ds-dialog-content${reactId}`;
const titleId = `ds-dialog-title${reactId}`;
const descriptionId = `ds-dialog-description${reactId}`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const openRef = useRef(actualOpen);
openRef.current = actualOpen;
const requestOpen = useCallback(
(next: boolean) => {
// Remember the focused element at the moment the open is REQUESTED
// (the trigger, or a row action). Capturing here — not in the open
// effect — because mounted-child effects run before the root's, so by
// effect time focus has already moved into the dialog.
if (next && !openRef.current) {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
}
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isControlled, onOpenChange],
);
const requestClose = useCallback(() => requestOpen(false), [requestOpen]);
const requestCloseRef = useRef(requestClose);
requestCloseRef.current = requestClose;
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
// Fallback focus capture for opens that never pass through requestOpen
// (e.g. a parent flipping controlled `open` directly): DialogContent calls
// this from its open effect — the first code that moves focus — while the
// pre-open focused element is still active.
const rememberFocus = useCallback((element: HTMLElement | null) => {
if (restoreFocusRef.current === null) {
restoreFocusRef.current = element;
}
}, []);
// While open: register on the dialog stack and lock body scroll (modal
// only); on close restore focus to the trigger (or whatever was focused
// when the dialog opened — captured in requestOpen).
useEffect(() => {
if (!actualOpen) return;
openDialogKeys.push(dialogKey);
if (modal) lockScroll();
return () => {
const index = openDialogKeys.indexOf(dialogKey);
if (index !== -1) openDialogKeys.splice(index, 1);
if (modal) unlockScroll();
const element = restoreFocusRef.current;
restoreFocusRef.current = null;
if (element && element.isConnected) element.focus();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen, modal, dialogKey]);
// Escape closes only the top-most open dialog (nesting-safe).
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && openDialogKeys[openDialogKeys.length - 1] === dialogKey) {
event.preventDefault();
requestCloseRef.current();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen, dialogKey]);
const value: DialogContextValue = {
open: actualOpen,
modal,
contentId,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
rememberFocus,
requestOpen,
requestClose,
triggerRef,
};
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
export default Dialog;
/* ------------------------------------------------------------------------ */
/* DialogTrigger */
/* ------------------------------------------------------------------------ */
export interface DialogTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode;
}
export function DialogTrigger({ children, className, onClick, ...rest }: DialogTriggerProps) {
const context = useDialog("DialogTrigger");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.requestClose();
} else {
context.requestOpen(true);
}
}
return (
<button
type="button"
ref={context.triggerRef}
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={handleClick}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* DialogContent (portal to document.body) */
/* ------------------------------------------------------------------------ */
export interface DialogContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogContent({ children, className, onKeyDown, ...rest }: DialogContentProps) {
const context = useDialog("DialogContent");
const contentRef = useRef<HTMLDivElement>(null);
const open = context.open;
// Move focus into the dialog on open: the first focusable element, or the
// dialog container itself when it has no focusable children. This effect
// is the first code that moves focus (child effects run before the root's
// open effect), so it also captures the pre-open focused element for
// externally-controlled opens that never passed through requestOpen.
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
context.rememberFocus(document.activeElement as HTMLElement | null);
const items = focusableElements(node);
(items[0] ?? node).focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Non-modal mode: an outside pointer down closes the dialog (modal closes
// via its overlay instead).
useEffect(() => {
if (!open || context.modal) return;
function onPointerDown(event: PointerEvent) {
const node = contentRef.current;
const target = event.target as Node;
const trigger = context.triggerRef.current;
if (node && !node.contains(target) && !(trigger && trigger.contains(target))) {
context.requestClose();
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, context.modal]);
// Focus trap (modal only): wrap Tab / Shift+Tab at the first / last
// focusable element. Skipped while focus lives inside a NESTED dialog —
// the nested dialog traps its own keys.
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Tab" || !context.modal) return;
const node = contentRef.current;
if (!node || !node.contains(document.activeElement)) return;
const items = focusableElements(node);
if (items.length === 0) {
event.preventDefault();
node.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<>
{context.modal ? (
<div
aria-hidden="true"
data-ds-dialog-overlay=""
className={OVERLAY_CLASSES}
// Canceling pointerdown suppresses the compatibility mousedown, so
// the browser's default "focus the clicked surface" behavior never
// steals focus from the trigger the close is about to restore.
onPointerDown={(event) => {
event.preventDefault();
context.requestClose();
}}
/>
) : null}
<div
ref={contentRef}
id={context.contentId}
role="dialog"
aria-modal={context.modal ? true : undefined}
aria-labelledby={context.hasTitle ? context.titleId : undefined}
aria-describedby={context.hasDescription ? context.descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cx(CONTENT_CLASSES, className)}
{...rest}
>
{children}
</div>
</>,
document.body,
);
}
/* ------------------------------------------------------------------------ */
/* DialogHeader / DialogTitle / DialogDescription */
/* ------------------------------------------------------------------------ */
export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogHeader({ className, children, ...rest }: DialogHeaderProps) {
return (
<div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export interface DialogTitleProps extends HTMLAttributes<HTMLHeadingElement> {
children?: ReactNode;
}
export function DialogTitle({ className, children, ...rest }: DialogTitleProps) {
const context = useDialog("DialogTitle");
// Register so DialogContent can wire `aria-labelledby` — and omit it when
// no title is rendered (content then relies on an explicit `aria-label`).
useEffect(() => context.registerTitle(), [context]);
return (
<h2 id={context.titleId} className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h2>
);
}
export interface DialogDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
children?: ReactNode;
}
export function DialogDescription({ className, children, ...rest }: DialogDescriptionProps) {
const context = useDialog("DialogDescription");
// Register so DialogContent can wire `aria-describedby`.
useEffect(() => context.registerDescription(), [context]);
return (
<p id={context.descriptionId} className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>
);
}
/* ------------------------------------------------------------------------ */
/* DialogFooter / DialogClose */
/* ------------------------------------------------------------------------ */
export interface DialogFooterProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogFooter({ className, children, ...rest }: DialogFooterProps) {
return (
<div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export type DialogCloseVariant = "outline" | "primary" | "destructive" | "ghost";
export interface DialogCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* `outline` (default) is the bordered footer cancel action; `primary` /
* `destructive` are confirming actions that also close; `ghost` is the
* icon-sized corner close button (positioned top-right of the panel —
* give it an `aria-label`).
*/
variant?: DialogCloseVariant;
children?: ReactNode;
}
export function DialogClose({ children, className, onClick, variant = "outline", ...rest }: DialogCloseProps) {
const context = useDialog("DialogClose");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestClose();
}
return (
<button
type="button"
onClick={handleClick}
className={cx(CLOSE_VARIANT_CLASSES[variant], className)}
{...rest}
>
{children}
</button>
);
} /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState
} from "react";
import { createPortal } from "react-dom";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const openDialogKeys = [];
let scrollLockCount = 0;
let previousOverflow = "";
let previousPaddingRight = "";
function lockScroll() {
scrollLockCount += 1;
if (scrollLockCount === 1) {
previousOverflow = document.body.style.overflow;
previousPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
document.body.style.overflow = "hidden";
}
}
function unlockScroll() {
scrollLockCount -= 1;
if (scrollLockCount === 0) {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
}
}
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusableElements(root) {
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.hidden && el.getAttribute("aria-hidden") !== "true"
);
}
const TRIGGER_CLASSES = "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const CONTENT_CLASSES = "fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] shadow-[var(--ds-shadow-lg)] focus:outline-none";
const HEADER_CLASSES = "flex flex-col gap-1.5 px-5 pt-5";
const TITLE_CLASSES = "text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES = "text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const FOOTER_CLASSES = "flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:justify-end [&>button]:w-full sm:[&>button]:w-auto";
const CLOSE_VARIANT_CLASSES = {
outline: TRIGGER_CLASSES,
primary: "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
destructive: "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-destructive)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-destructive-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-destructive)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-destructive)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
ghost: "absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"
};
const DialogContext = createContext(null);
function useDialog(component) {
const context = useContext(DialogContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Dialog>.`);
}
return context;
}
function Dialog({
open,
defaultOpen = false,
onOpenChange,
modal = true,
children
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef(null);
const restoreFocusRef = useRef(null);
const reactId = useId();
const dialogKey = `ds-dialog${reactId}`;
const contentId = `ds-dialog-content${reactId}`;
const titleId = `ds-dialog-title${reactId}`;
const descriptionId = `ds-dialog-description${reactId}`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const openRef = useRef(actualOpen);
openRef.current = actualOpen;
const requestOpen = useCallback(
(next) => {
if (next && !openRef.current) {
restoreFocusRef.current = document.activeElement;
}
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isControlled, onOpenChange]
);
const requestClose = useCallback(() => requestOpen(false), [requestOpen]);
const requestCloseRef = useRef(requestClose);
requestCloseRef.current = requestClose;
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
const rememberFocus = useCallback((element) => {
if (restoreFocusRef.current === null) {
restoreFocusRef.current = element;
}
}, []);
useEffect(() => {
if (!actualOpen) return;
openDialogKeys.push(dialogKey);
if (modal) lockScroll();
return () => {
const index = openDialogKeys.indexOf(dialogKey);
if (index !== -1) openDialogKeys.splice(index, 1);
if (modal) unlockScroll();
const element = restoreFocusRef.current;
restoreFocusRef.current = null;
if (element && element.isConnected) element.focus();
};
}, [actualOpen, modal, dialogKey]);
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event) {
if (event.key === "Escape" && openDialogKeys[openDialogKeys.length - 1] === dialogKey) {
event.preventDefault();
requestCloseRef.current();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [actualOpen, dialogKey]);
const value = {
open: actualOpen,
modal,
contentId,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
rememberFocus,
requestOpen,
requestClose,
triggerRef
};
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
function DialogTrigger({ children, className, onClick, ...rest }) {
const context = useDialog("DialogTrigger");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.requestClose();
} else {
context.requestOpen(true);
}
}
return <button
type="button"
ref={context.triggerRef}
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={handleClick}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children}
</button>;
}
function DialogContent({ children, className, onKeyDown, ...rest }) {
const context = useDialog("DialogContent");
const contentRef = useRef(null);
const open = context.open;
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
context.rememberFocus(document.activeElement);
const items = focusableElements(node);
(items[0] ?? node).focus();
}, [open]);
useEffect(() => {
if (!open || context.modal) return;
function onPointerDown(event) {
const node = contentRef.current;
const target = event.target;
const trigger = context.triggerRef.current;
if (node && !node.contains(target) && !(trigger && trigger.contains(target))) {
context.requestClose();
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, context.modal]);
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Tab" || !context.modal) return;
const node = contentRef.current;
if (!node || !node.contains(document.activeElement)) return;
const items = focusableElements(node);
if (items.length === 0) {
event.preventDefault();
node.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<>
{context.modal ? <div
aria-hidden="true"
data-ds-dialog-overlay=""
className={OVERLAY_CLASSES}
onPointerDown={(event) => {
event.preventDefault();
context.requestClose();
}}
/> : null}
<div
ref={contentRef}
id={context.contentId}
role="dialog"
aria-modal={context.modal ? true : undefined}
aria-labelledby={context.hasTitle ? context.titleId : undefined}
aria-describedby={context.hasDescription ? context.descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cx(CONTENT_CLASSES, className)}
{...rest}
>
{children}
</div>
</>,
document.body
);
}
function DialogHeader({ className, children, ...rest }) {
return <div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DialogTitle({ className, children, ...rest }) {
const context = useDialog("DialogTitle");
useEffect(() => context.registerTitle(), [context]);
return <h2 id={context.titleId} className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h2>;
}
function DialogDescription({ className, children, ...rest }) {
const context = useDialog("DialogDescription");
useEffect(() => context.registerDescription(), [context]);
return <p id={context.descriptionId} className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>;
}
function DialogFooter({ className, children, ...rest }) {
return <div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DialogClose({ children, className, onClick, variant = "outline", ...rest }) {
const context = useDialog("DialogClose");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestClose();
}
return <button
type="button"
onClick={handleClick}
className={cx(CLOSE_VARIANT_CLASSES[variant], className)}
{...rest}
>
{children}
</button>;
}
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose };
export default Dialog; # Nested Dialogs
A dialog opening another dialog: a member-removal confirmation stacked over the share dialog, with Escape closing only the top-most and focus restoring down the chain.
## Usage
```tsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "./dialog-nested";
// The nested dialog is composed inside the parent's content:
<Dialog>
<DialogTrigger>Share project</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Share “Design system”</DialogTitle></DialogHeader>
<Dialog> {/* nested root, one DialogTrigger per row */}
{members.map((m) => (
<DialogTrigger key={m.id} onClick={() => setTarget(m)}>Remove</DialogTrigger>
))}
<DialogContent role="alertdialog" className="max-w-md">
<DialogHeader>
<DialogTitle>Remove {target?.name}?</DialogTitle>
<DialogDescription>They will lose access immediately.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>Keep member</DialogClose>
<DialogClose variant="destructive" onClick={removeTarget}>Remove member</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "./dialog-nested";
// The nested dialog is composed inside the parent's content:
<Dialog>
<DialogTrigger>Share project</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Share “Design system”</DialogTitle></DialogHeader>
<Dialog> {/* nested root, one DialogTrigger per row */}
{members.map((m) => (
<DialogTrigger key={m.id} onClick={() => setTarget(m)}>Remove</DialogTrigger>
))}
<DialogContent role="alertdialog" className="max-w-md">
<DialogHeader>
<DialogTitle>Remove {target?.name}?</DialogTitle>
<DialogDescription>They will lose access immediately.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>Keep member</DialogClose>
<DialogClose variant="destructive" onClick={removeTarget}>Remove member</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
```
## Props
### `<Dialog>`
| Name | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | — | Open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the dialog requests to open or close. |
| `modal` | `boolean` | `true` | Modal behavior: overlay, scroll lock, focus trap, `aria-modal`. `false` renders a non-modal floating panel (no overlay; closes on Escape / outside pointer down). |
| `children` | `ReactNode` | — | `DialogTrigger` + `DialogContent`. |
### `<DialogTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible trigger label. |
A real `<button type="button">` with `aria-haspopup="dialog"` + `aria-expanded`; every native button attribute (`disabled`, `aria-label`, …) is forwarded. Focus is restored here when the dialog closes.
### `<DialogContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `role="dialog"` panel (e.g. a larger `max-w-*`). |
| `children` | `ReactNode` | — | Header, body content, footer, close button. |
Portaled to `document.body` and rendered only while open. Labelled by `DialogTitle` / described by `DialogDescription` automatically when they are rendered; pass `aria-label` when a dialog intentionally has no visible title, and `role="alertdialog"` for confirmation dialogs (forwarded via `...rest`).
### `<DialogHeader>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the header. |
| `children` | `ReactNode` | — | `DialogTitle` + `DialogDescription`. |
### `<DialogTitle>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<h2>`. |
| `children` | `ReactNode` | — | Title text. |
Registers itself so `DialogContent` sets `aria-labelledby` only while a title exists.
### `<DialogDescription>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<p>`. |
| `children` | `ReactNode` | — | Supporting description text. |
Registers itself so `DialogContent` sets `aria-describedby` only while a description exists.
### `<DialogFooter>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the action row. |
| `children` | `ReactNode` | — | Footer actions (`DialogClose`, plain buttons, links). |
Buttons stack full-width below `sm` and lay out right-aligned inline from `sm` up.
### `<DialogClose>`
| Name | Type | Default | Description |
|---|---|---|---|
| `variant` | `"outline" \| "primary" \| "destructive" \| "ghost"` | `"outline"` | `outline` is the bordered footer cancel action; `primary` / `destructive` are confirming actions that also close; `ghost` is the icon-sized corner close button (positioned absolute top-right — give it an `aria-label` such as `"Close"`). |
| `onClick` | `(event) => void` | — | Called before the dialog closes; `event.preventDefault()` keeps the dialog open. |
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible label (or the close icon for `ghost`). |
A real `<button type="button">` that requests close; every native button attribute is forwarded.
## Composition
- `Dialog` — the root provider. Owns the open state (controlled `open` + `onOpenChange`, or uncontrolled `defaultOpen`), the `modal` behavior switch, the generated ids, and the focus-restore memory.
- `DialogTrigger` — a real `<button type="button">` with `aria-haspopup="dialog"` + `aria-expanded`. Click toggles the dialog.
- `DialogContent` — the portaled `role="dialog"` panel (`aria-modal` when modal) plus, in modal mode, the overlay that blocks the background. Rendered only while open; moves focus inside on open and traps Tab while modal.
- `DialogHeader` — the header layout slot (`DialogTitle` + `DialogDescription`).
- `DialogTitle` — an `<h2>` that registers itself so the panel is labelled by it.
- `DialogDescription` — a `<p>` that registers itself so the panel is described by it.
- `DialogFooter` — the action row; buttons go full-width stacked on small screens, right-aligned inline from `sm` up.
- `DialogClose` — a real `<button>` that closes the dialog (footer cancel actions, or the header close with `variant="ghost"`).
The nested dialog is an ordinary `<Dialog>` root composed inside the parent's `DialogContent` — no special API. Because each root has its own context, the nested trigger/content wire to the nested root, and both panels portal to `document.body` in open order, so the nested layer always paints above the parent. The nested confirmation is narrowed with `className="max-w-md"` so the stacked layers read clearly.
## Dialog Behavior
Three pieces of the core cooperate to make nesting safe:
- **The module-level open stack** — every open dialog registers itself; Escape closes only the last (top-most) entry, so Escape in a nested confirmation never closes the parent behind it.
- **The shared scroll-lock counter** — body scroll stays locked until every open dialog has closed; closing the nested dialog never unlocks the page early.
- **Per-root focus memory** — each dialog remembers the element focused when it opened. Closing the nested dialog restores focus to the Remove button inside the parent; closing the parent later restores focus to the page trigger. If the remembered element was unmounted in the meantime (a removed member row), the restore is skipped instead of throwing.
The parent's focus trap is scoped to its own DOM subtree, so while focus lives in the nested panel the parent trap stays out of the way.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Enter` / `Space` (trigger) | Open the dialog, focus the first focusable element inside |
| `Tab` (modal dialog) | Move to the next focusable element; wraps from the last element back to the first |
| `Shift+Tab` (modal dialog) | Move to the previous focusable element; wraps from the first element to the last |
| `Escape` | Close the top-most open dialog and restore focus to its trigger |
| `Tab` (non-modal dialog) | Moves forward naturally — the page stays reachable |
The trigger and every action are native `<button>` elements, so Enter/Space activation follows normal browser behavior. Disabled actions use the native `disabled` attribute: they are skipped by Tab and cannot be activated.
## Accessibility
The structure follows the WAI-ARIA dialog (modal) pattern.
- The trigger is a native `<button>` with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` pointing at the panel.
- The panel is `role="dialog"` with `aria-modal="true"` in modal mode. `DialogTitle` / `DialogDescription` register themselves, and the panel wires `aria-labelledby` / `aria-describedby` only when they are present — a dialog without a visible title must pass `aria-label` to `DialogContent` (the attributes are omitted, never left pointing at nothing). Confirmation-style dialogs can pass `role="alertdialog"` through `DialogContent`.
- Focus is real DOM focus: opening moves focus into the dialog, Tab is trapped while modal, and closing restores focus to the trigger — focus is never left on an unmounted element (the restore target is checked against `isConnected`).
- The background is unreachable while modal: the overlay blocks pointer interaction, the focus trap blocks keyboard access, and `aria-modal` tells assistive technology the rest of the page is inert.
- Disabled actions carry the native `disabled` attribute, which assistive technology announces as unavailable.
Each layer is a complete `role="dialog"` (the nested one `role="alertdialog"`) with its own label; the nested overlay blocks interaction with the parent behind it, and focus never lands on an unmounted element — the restore target is checked with `isConnected`.
## States
- **Trigger (idle)** — bordered surface button per the shared control system (36px, radius-sm, `shadow-xs`).
- **Trigger (open)** — `aria-expanded="true"`; keeps the hover surface.
- **Overlay (modal)** — `var(--ds-color-overlay)` backdrop covering the viewport; pointer down on it closes the dialog.
- **Panel** — `--ds-color-surface-elevated` with a 1px `--ds-color-border` and the `--ds-shadow-lg` elevation, radius-md, centered, capped at `100dvh - 2rem` with internal column layout, per the Dialog token rules.
- **Title / description** — heading-md (18px, 600) on foreground; body-sm on `--ds-color-muted-foreground`.
- **Footer actions** — full-width stacked below `sm`, right-aligned inline from `sm` up.
- **Disabled actions** — native `disabled`: 50% opacity, no pointer events, out of the tab order.
## Responsive Behavior
The panel is `width: calc(100vw - 2rem)` up to `max-w-lg` (512px, inside the 400–640px dialog token range) and capped at `max-height: calc(100dvh - 2rem)`, so it stays inside the viewport at every width from 375px up — including small landscape screens — without page overflow. Long content scrolls inside a `min-h-0 flex-1 overflow-y-auto` body region while the header and footer stay pinned (see `dialog-scrollable`). Footer actions stack full-width below `sm` and lay out inline from `sm` up. The trigger keeps the shared 36px control height — a comfortable touch target — at every breakpoint.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface-elevated)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This dialog variant uses the semantic color, radius, shadow, typography, and motion tokens — including `color.overlay` for the backdrop and the Dialog row of the component token rules (radius-md, shadow-lg).
## Notes
Removing a member deletes its row (including the button that opened the nested dialog); the focus-restore guard detects the disconnected node and leaves focus placement to the browser rather than focusing a dead element. 502 lines UTF-8 · LF · Spaces: 2
Continue browsing