// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Dialog.tsx * @input Uses React, DialogHTMLAttributes, ReactNode, container (Layout), DialogContext * @output Exports Dialog component, DialogProps, DialogVariant, DialogPurpose types * @position Core implementation; consumed by index.ts, tested by Dialog.test.tsx * * The standard variant treats `width` as the preferred surface width, then * clamps it to the dynamic viewport with spacing-token gutters so narrow * viewports keep content and controls on screen without changing the public API. * Fullscreen dialogs add safe-area protection to the default padding fallback * while preserving explicit prop/theme padding overrides, and fade in without * the centered-dialog translate/scale motion. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Dialog/Dialog.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Dialog/Dialog.test.tsx (tests for new/changed behavior) * - /packages/core/src/Dialog/index.ts (exports if types change) * - /apps/storybook/stories/Dialog.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Dialog/ (showcase blocks) */ import { useCallback, useEffect, useId, useMemo, useRef, type ReactNode } from 'react'; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import { useScrollLock } from "../hooks/useScrollLock.js"; import { LayerDepthProvider } from "../Layer/LayerDepthContext.js"; import { useLayerDismissal } from "../Layer/useLayerDismissal.js"; import "../theme/tokens.stylex.js"; import { colorVars, radiusVars, durationVars, easeVars, shadowVars, spacingVars } from "../theme/tokens.stylex.js"; import { container } from "../Layout/container.stylex.js"; import type { SpacingToken } from "../Layout/container.stylex.js"; import { paddingStyles, containerPaddingInlineVarStyles, containerPaddingBlockStartVarStyles, containerPaddingBlockEndVarStyles, spacingStepToToken, overlayPaddingReset } from "../Layout/padding.stylex.js"; import type { SpacingStep } from "../utils/types.js"; import { mergeProps } from "../utils/index.js"; import { devWarn } from "../utils/devWarning.js"; import { DialogContext } from "./DialogContext.js"; import { themeProps } from "../utils/themeProps.js"; import type { DialogVariantMap } from "./index.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; /** * Calculate a directional translate offset for dialog entry animation. * Returns a normalized vector from the trigger element toward the viewport * center, scaled to the given distance. */ function getDialogDirection(triggerEl: HTMLElement, distance = 16): { x: number; y: number; } { const rect = triggerEl.getBoundingClientRect(); const dx = rect.left + rect.width / 2 - window.innerWidth / 2; const dy = rect.top + rect.height / 2 - window.innerHeight / 2; const dist = Math.sqrt(dx * dx + dy * dy) || 1; return { x: Math.round(dx / dist * distance), y: Math.round(dy / dist * distance) }; } /** * Dialog variant type * - standard: Normal dialog with configurable width/height * - fullscreen: Takes up the entire viewport * * Extensible via module augmentation of DialogVariantMap. */ export type DialogVariant = keyof DialogVariantMap; /** * Dialog purpose type - controls dismissal behavior * - required: Mandatory flows (security, permissions) - disables all exit methods * - form: User forms/flows - prevents backdrop click, allows escape * - info: Informational flows - allows all exit methods */ export type DialogPurpose = 'required' | 'form' | 'info'; /** Block-axis offsets — always allowed, independent of inline direction. */ interface DialogBlockPosition { top?: number | string; bottom?: number | string; } /** * Static position for a dialog. The inline axis is logical XOR physical — the * type forbids mixing the two, so a single dialog can't be positioned both ways: * - Logical `start`/`end` map to `inset-inline-*` and mirror under RTL (preferred). * Block-axis `top`/`bottom` may be combined with either. */ export interface DialogPosition extends DialogBlockPosition { /** Logical inline-start offset (`inset-inline-start`); mirrors under RTL. */ start?: number | string; /** Logical inline-end offset (`inset-inline-end`); mirrors under RTL. */ end?: number | string; } const enterDirectional = "x11lauxq-B"; const enterFullscreen = "x18re5ia-B"; const dialogFullscreenSafeAreaBlockStartPadding = `var(--astryx-dialog-padding-block-start, var(--astryx-dialog-padding, max(${spacingVars['--spacing-4']}, env(safe-area-inset-top, 0px))))`; const dialogFullscreenSafeAreaBlockEndPadding = `var(--astryx-dialog-padding-block-end, var(--astryx-dialog-padding, max(${spacingVars['--spacing-4']}, env(safe-area-inset-bottom, 0px))))`; const dialogFullscreenSafeAreaInlineStartPaddingLtr = `var(--astryx-dialog-padding-inline-start, var(--astryx-dialog-padding-inline, var(--astryx-dialog-padding, max(${spacingVars['--spacing-4']}, env(safe-area-inset-left, 0px)))))`; const dialogFullscreenSafeAreaInlineStartPaddingRtl = `var(--astryx-dialog-padding-inline-start, var(--astryx-dialog-padding-inline, var(--astryx-dialog-padding, max(${spacingVars['--spacing-4']}, env(safe-area-inset-right, 0px)))))`; const dialogFullscreenSafeAreaInlineEndPaddingLtr = `var(--astryx-dialog-padding-inline-end, var(--astryx-dialog-padding-inline, var(--astryx-dialog-padding, max(${spacingVars['--spacing-4']}, env(safe-area-inset-right, 0px)))))`; const dialogFullscreenSafeAreaInlineEndPaddingRtl = `var(--astryx-dialog-padding-inline-end, var(--astryx-dialog-padding-inline, var(--astryx-dialog-padding, max(${spacingVars['--spacing-4']}, env(safe-area-inset-left, 0px)))))`; /** @internal Verified by Dialog.test.tsx; not re-exported from the package entry point. */ export const dialogFullscreenSafeAreaPaddingContract = { blockStart: dialogFullscreenSafeAreaBlockStartPadding, blockEnd: dialogFullscreenSafeAreaBlockEndPadding, inlineStart: { ltr: dialogFullscreenSafeAreaInlineStartPaddingLtr, rtl: dialogFullscreenSafeAreaInlineStartPaddingRtl }, inlineEnd: { ltr: dialogFullscreenSafeAreaInlineEndPaddingLtr, rtl: dialogFullscreenSafeAreaInlineEndPaddingRtl } } as const; /** * Dialog styles using native element * Uses ::backdrop pseudo-element for overlay */ const styles = { dialog: { kVAEAm: "xixxii4", kogj98: "x1bpp3o7", kmVPX3: "x1717udv", kWkggS: "x10xzikg", "--_dialog-radius": "xvuvksw", kaIpWk: "xuacgfc", kGVxlE: "x1kcpxr7", k1xSpc: "x1s85apg", kXwgrk: "xdt5ytf", kZKoxP: "xg7h5cd", kZeWKH: "xish69e", kSiTet: "xg01cxk", k44tkh: "xqgcaz", kyAemX: "x128ha8g", kWV6AL: "xskzprw", $$css: true as const }, open: { k1xSpc: "x78zum5", kSiTet: "x1hc1fzr", kKVMdj: "x1ewfqum x1aquc0h", $$css: true as const }, backdrop: { kGyWv1: "xnixb3f", kba3nw: "x1abwkk1", $$css: true as const }, fullscreen: { kzqmXN: "x1o6l61p", kZKoxP: "xtdtrs8", ks0D6T: "xlbgzzq", kskxy: "x1wj9ous", kaIpWk: "x2u8bby", kogj98: "x1ghz6dp", kpwlN0: "x10a8y8t", $$css: true as const }, fullscreenOpen: { kKVMdj: "xqcmdr3 x1aquc0h", $$css: true as const }, fullscreenSafeArea: { kLKAdn: "x15ld1ci", kGO01o: "x1rgxemn", kZCmMZ: "xqmdmw x1i7f2ot", kwRFfy: "x1by8st6 xtjjor6", $$css: true as const }, inner: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kUk6DE: "x12lumcd", kAzted: "x2lwn1j", kVQacm: "xb3r6kr", kaIpWk: "x1pjcqnp", $$css: true as const }, inlineWrapper: { kmVPX3: "x1717udv", kWkggS: "x10xzikg", "--_dialog-radius": "xvuvksw", kaIpWk: "xuacgfc", kGVxlE: "x1kcpxr7", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kZKoxP: "xg7h5cd", kZeWKH: "xish69e", $$css: true as const } }; const STANDARD_DIALOG_VIEWPORT_GUTTER = spacingVars['--spacing-4']; const STANDARD_DIALOG_VIEWPORT_MAX_WIDTH = `calc(100dvw - ${STANDARD_DIALOG_VIEWPORT_GUTTER} - ${STANDARD_DIALOG_VIEWPORT_GUTTER})`; const STANDARD_DIALOG_MAX_WIDTH = `min(100%, ${STANDARD_DIALOG_VIEWPORT_MAX_WIDTH})`; function formatSizeValue(value: number | string): string { return typeof value === 'number' ? `${value}px` : value; } function resolveDialogSizing(width: number | string, maxHeight: number | string) { return { width: formatSizeValue(width), maxWidth: STANDARD_DIALOG_MAX_WIDTH, maxHeight: formatSizeValue(maxHeight) }; } const _temp = { kogj98: "x1ghz6dp", "$$css": true }; const dynamicStyles = { sizing: (width: string, maxWidth: string, maxHeight: string) => [{ kzqmXN: width != null ? "x5lhr3w" : width, ks0D6T: maxWidth != null ? "xf68679" : maxWidth, kskxy: maxHeight != null ? "x1jols5v" : maxHeight, $$css: true as const }, { "--x-width": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(width), "--x-maxWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(maxWidth), "--x-maxHeight": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(maxHeight) }], position: (top: string, insetInlineStart: string, insetInlineEnd: string, bottom: string) => [_temp, { k87sOh: top != null ? "xjbys53" : top, kLqNvP: insetInlineStart != null ? "x1lxsm33" : insetInlineStart, kt4wiu: insetInlineEnd != null ? "xqxgn94" : insetInlineEnd, krVfgx: bottom != null ? "x1nqzi6q" : bottom, $$css: true as const }, { "--x-top": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(top), "--x-insetInlineStart": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(insetInlineStart), "--x-insetInlineEnd": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(insetInlineEnd), "--x-bottom": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(bottom) }] }; /** * Format position value - numbers become pixels, strings pass through, undefined becomes null */ function formatPosition(value: number | string): string { return typeof value === 'number' ? `${value}px` : value; } /** * Map a {@link DialogPosition} to resolved CSS offsets. Logical `start`/`end` * become `inset-inline-*` (mirror under RTL); each unset offset falls back to * `auto`. * * Not re-exported from the package; internal to Dialog. Directly unit-tested * so the mapping is verified without StyleX class compilation. * * @see DialogPosition */ export function resolveDialogPositionOffsets(position: DialogPosition) { const { top, bottom, start, end } = position; return { top: top !== undefined ? formatPosition(top) : 'auto', bottom: bottom !== undefined ? formatPosition(bottom) : 'auto', // Logical offsets mirror under RTL (preferred replacements). insetInlineStart: start !== undefined ? formatPosition(start) : 'auto', insetInlineEnd: end !== undefined ? formatPosition(end) : 'auto' }; } export interface DialogProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Whether the dialog is open. */ isOpen: boolean; /** * Renders dialog content inline without the element, backdrop, * modal behavior, or dialog-managed autofocus. Intended for documentation * previews and showcases only — not for production UIs. The dialog will not * trap focus or respond to Escape. * @default false */ isInline?: boolean; /** * Callback fired when the dialog visibility changes. * Called with `false` when the dialog requests to be hidden. * Behavior depends on the `purpose` prop: * - required: Never called automatically * - form: Called on Escape key only * - info: Called on Escape key and backdrop click */ onOpenChange: (isOpen: boolean) => unknown; /** * The width of the dialog. * Numbers are treated as pixels, strings are used as-is. * Ignored when variant is 'fullscreen'. * @default 400 */ width?: number | string; /** * The maximum height of the dialog. * The actual height will be the height of its content. * Numbers are treated as pixels, strings are used as-is. * Ignored when variant is 'fullscreen'. * @default '75dvh' */ maxHeight?: number | string; /** * Static position for the dialog on screen. * By default, the dialog will be centered. * Ignored when variant is 'fullscreen'. */ position?: Readonly; /** * The variant of the dialog. * - standard: Normal dialog with configurable dimensions * - fullscreen: Takes up the entire viewport * @default 'standard' */ variant?: DialogVariant; /** * Configures how the dialog enables dismissals. * - required: Disables all exit methods (for mandatory flows) * - form: Prevents backdrop click, allows Escape key * - info: Allows all exit methods (Escape and backdrop click) * @default 'info' */ purpose?: DialogPurpose; /** * Internal padding of the dialog using the spacing scale. * Accepts numeric spacing steps: 0, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 8, 10. * When omitted, uses the theme default for dialogs. */ padding?: SpacingStep; /** * The content of the dialog. * Typically an Layout with header, content, and footer slots. */ children: ReactNode; } /** * A dialog component using the native element. * * Designed to be used with Layout as its child for structured content. * Uses the browser's built-in modal behavior for optimal accessibility. * When a DialogHeader is rendered inside, its title automatically names the * dialog via aria-labelledby; pass `aria-label` or `aria-labelledby` to * override. * * @example * ``` * const [isOpen, setIsOpen] = useState(false); * setIsOpen(open)}> * setIsOpen(open)} />} * content={Content} * footer={Actions} * /> * * ``` */ export function Dialog({ isOpen, isInline = false, onOpenChange, width = 400, maxHeight = '75dvh', position, variant = 'standard', purpose = 'info', padding, children, xstyle, className, style, ref, ...props }: DialogProps) { // When no explicit padding prop, use theme default (--astryx-dialog-padding) const useThemeDefault = padding == null; const effectivePadding = padding ?? 4; const paddingToken = spacingStepToToken[effectivePadding] as SpacingToken; const isFullscreen = variant === 'fullscreen'; const standardSizing = isFullscreen ? null : resolveDialogSizing(width, maxHeight); // Default accessible name: publish a title id through DialogContext so a // DialogHeader applies it to its heading (mirrors AlertDialog's explicit // useId wiring). Whether to emit the default aria-labelledby is decided // imperatively in the callback ref below by checking whether the title // element actually rendered — so a dialog with no header never points at a // missing id, and we avoid the extra render a registration-via-state effect // would cost at the dialog level. const titleId = useId(); const dialogContextValue = useMemo(() => ({ isInline, titleId }), [isInline, titleId]); // Consumer-provided labels always win over the DialogHeader default. const hasConsumerName = props['aria-label'] != null || props['aria-labelledby'] != null; const dialogRef = useRef(null); // Callback ref on the : sync the default aria-labelledby from the // DialogHeader title (if one rendered) in the same commit, with no second // render. Consumer aria-label/aria-labelledby win — when present we leave // the attribute to the {...safeProps} spread and never touch it here. const attachDialog = useCallback((node: HTMLDialogElement | null) => { dialogRef.current = node; if (!node || hasConsumerName) { return; } const hasTitle = node.querySelector(`#${CSS.escape(titleId)}`) != null; if (hasTitle) { node.setAttribute('aria-labelledby', titleId); } else { node.removeAttribute('aria-labelledby'); } }, [titleId, hasConsumerName]); const mergedDialogRef = useMergedRefs(ref, attachDialog); // Capture the element that was focused when the dialog opened, // for directional animation origin and focus restoration on close. const triggerElementRef = useRef(null); // Derive dismissal behavior from purpose const allowEscape = purpose !== 'required'; const allowBackdropClick = purpose === 'info'; // Handle open/close state — skip for inline rendering useEffect(() => { if (isInline) { return; } const dialog = dialogRef.current; if (!dialog) { return; } if (isOpen) { // Capture the currently focused element as the trigger — used for // directional animation origin and focus restoration on close. triggerElementRef.current = document.activeElement as HTMLElement | null; // Set directional CSS custom properties before opening const trigger = triggerElementRef.current; if (trigger && trigger !== document.body) { const dir = getDialogDirection(trigger); dialog.style.setProperty('--dialog-dir-x', `${dir.x}px`); dialog.style.setProperty('--dialog-dir-y', `${dir.y}px`); } else { dialog.style.setProperty('--dialog-dir-x', '0px'); dialog.style.setProperty('--dialog-dir-y', '16px'); } if (!dialog.open) { dialog.showModal(); // React's autoFocus calls .focus() during commit, before showModal() // makes the dialog visible, so the focus silently fails. // Focus the first element with data-autofocus inside the dialog. const autofocusTarget = dialog.querySelector('[data-autofocus]'); if (autofocusTarget) { autofocusTarget.focus(); } } } else { if (dialog.open) { dialog.close(); } // Return focus to the element that opened the dialog triggerElementRef.current?.focus(); triggerElementRef.current = null; } }, [isOpen, isInline]); // Lock body scroll when dialog is open (iOS Safari workaround) // Skip for inline rendering — no modal overlay to compensate for. useScrollLock(isOpen && !isInline); // Join the shared layer dismissal stack. The stack owns the Escape listener // and routes each press to the top-most layer, so this dialog does not listen // itself — that is what stops a modal opened from inside another modal (or a // popover opened inside this one) from closing two layers on one press. // // A `required` dialog registers as `block`: it must not be dismissed, and the // press must not fall through and dismiss a layer behind it either. // // Inline mode opts out — it renders dialog content in normal flow, with // nothing layered over anything. const { shouldDismissOnCloseRequest } = useLayerDismissal({ isActive: isOpen, isEnabled: !isInline, escapeBehavior: allowEscape ? 'close' : 'block', onDismiss: () => { onOpenChange(false); } }); // Dev-time guardrail: an open modal should always have an accessible name. // The header-title check reads the DOM, so this stays in an effect; the ref // keeps it to one warning per component instance. const warnedUnnamedDialogRef = useRef(false); useEffect(() => { const hasHeaderTitle = dialogRef.current?.querySelector(`#${CSS.escape(titleId)}`) != null; if (isOpen && !isInline && !hasConsumerName && !hasHeaderTitle && !warnedUnnamedDialogRef.current) { warnedUnnamedDialogRef.current = true; devWarn('Dialog', 'open dialog has no accessible name. Add a DialogHeader ' + 'with a `title`, or pass `aria-label`/`aria-labelledby`.'); } }, [isOpen, isInline, hasConsumerName, titleId]); // Handle backdrop click — when the user clicks the ::backdrop pseudo-element, // the event target is the element itself; clicks on child content // always target the child. This avoids false positives from native browser // popups (date pickers, selects, color pickers) that render outside the // dialog's bounding rect. const handleClick = (event: React.MouseEvent) => { if (event.target === event.currentTarget && allowBackdropClick) { onOpenChange(false); } }; // The native `cancel` event is the browser's own close-watcher firing: an // Android back gesture, or a close request the stack never saw a press for. // Escape presses the stack owns never arrive here — it preventDefault()s // those, which suppresses the close watcher. // // Always preventDefault so the browser cannot close a controlled // behind React's back, then answer the request with the stack's own rules: // only the top-most layer dismisses, a `required` dialog swallows it, and // nothing dismisses while an IME composition is in progress. const handleCancel = (event: React.SyntheticEvent) => { event.preventDefault(); if (!shouldDismissOnCloseRequest()) { return; } if (allowEscape) { onOpenChange(false); } }; const innerContent =
{children}
; // Filter out native open to prevent InvalidStateError when accidentally passed const hasPosition = position != null && !isFullscreen; const { open: _open, ...safeProps } = props as Record; // --- Inline rendering path (for documentation previews) --- if (isInline) { if (!isOpen) { return null; } return
)['data-testid'] as string | undefined}> {innerContent}
; } // --- Standard modal rendering path --- return { const o = resolveDialogPositionOffsets(position); return dynamicStyles.position(o.top, o.insetInlineStart, o.insetInlineEnd, o.bottom); })(), isFullscreen && styles.fullscreen, isFullscreen && isOpen && styles.fullscreenOpen, xstyle), className, style)} onClick={handleClick} onCancel={handleCancel} aria-modal="true" // The default aria-labelledby (from a DialogHeader title) is set // imperatively in `attachDialog`; a consumer-provided aria-labelledby // flows through {...safeProps} above and wins. {...purpose === 'required' ? { role: 'alertdialog' } : undefined}> {innerContent} ; } Dialog.displayName = 'Dialog';