Dialog
Modal dialog for Tile UI.
packages/react/src/components/dialog/dialog.tsx
import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Slot } from '@radix-ui/react-slot';
import { dialogStyleKeys, getDialogState } from '../lib/core';
import type { DialogBaseProps } from '../lib/core';
import { Button } from '../button';
import { usePortalContainer, type PortalContainer } from '../portal';
import styles from './dialog.module.scss';
interface DialogContextValue {
open: boolean;
setOpen: (open: boolean) => void;
close: () => void;
titleId: string;
descriptionId: string;
}
const DialogContext = createContext<DialogContextValue | null>(null);
function useDialogContext(): DialogContextValue {
const context = useContext(DialogContext);
if (!context) {
throw new Error('Dialog sub-components must be used within <Dialog>.');
}
return context;
}
export interface DialogProps extends DialogBaseProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
function Dialog({ open, defaultOpen = false, onOpenChange, children }: DialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const onOpenChangeRef = useRef(onOpenChange);
useEffect(() => {
onOpenChangeRef.current = onOpenChange;
});
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) {
setInternalOpen(next);
}
onOpenChangeRef.current?.(next);
},
[open],
);
const close = useCallback(() => {
setOpen(false);
}, [setOpen]);
const isOpen = open !== undefined ? open : internalOpen;
const titleId = useId();
const descriptionId = useId();
return <DialogContext.Provider value={{ open: isOpen, setOpen, close, titleId, descriptionId }}>{children}</DialogContext.Provider>;
}
Dialog.displayName = 'Dialog';
export interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(({ asChild = false, className = '', children, onClick, ...props }, ref) => {
const context = useDialogContext();
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
className={className}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
context.setOpen(true);
}}
{...props}>
{children}
</Comp>
);
});
DialogTrigger.displayName = 'DialogTrigger';
export interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(({ asChild = false, className = '', children, onClick, ...props }, ref) => {
const context = useDialogContext();
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
className={className}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
context.close();
}}
{...props}>
{children}
</Comp>
);
});
DialogClose.displayName = 'DialogClose';
export interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlayProps>(({ className = '', onClick, ...props }, ref) => {
const context = useDialogContext();
if (!context.open) {
return null;
}
return (
<div
ref={ref}
data-state={getDialogState(context.open)}
className={`${styles[dialogStyleKeys.overlay]} ${className}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
onClick?.(event);
context.close();
}}
{...props}
/>
);
});
DialogOverlay.displayName = 'DialogOverlay';
export interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
showCloseButton?: boolean;
container?: PortalContainer;
}
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(({ className = '', showCloseButton = true, container, children, ...props }, ref) => {
const { open, close, titleId, descriptionId } = useDialogContext();
const portalContainer = usePortalContainer(container);
const contentRef = useRef<HTMLDivElement | null>(null);
function setContentRef(element: HTMLDivElement | null) {
contentRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
useLayoutEffect(() => {
if (!open) {
return;
}
const previouslyFocused = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
return;
}
if (event.key === 'Tab') {
const container = contentRef.current;
if (!container) {
return;
}
const focusables = Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((el) => el.getClientRects().length > 0);
if (focusables.length === 0) {
event.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || active === container || !container.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last || !container.contains(active)) {
event.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
const contentEl = contentRef.current;
if (contentEl) {
contentEl.focus();
}
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [close, open]);
if (!open || !portalContainer) {
return null;
}
return createPortal(
<>
<DialogOverlay />
<div
ref={setContentRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
data-state={getDialogState(open)}
className={`${styles[dialogStyleKeys.content]} ${className}`}
{...props}>
{children}
{showCloseButton && (
<button type="button" aria-label="关闭" className={styles[dialogStyleKeys.close]} onClick={() => close()}>
<svg
className={styles[dialogStyleKeys.xIcon]}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
</>,
portalContainer,
);
});
DialogContent.displayName = 'DialogContent';
export interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`${styles[dialogStyleKeys.header]} ${className}`} {...props}>
{children}
</div>
);
});
DialogHeader.displayName = 'DialogHeader';
export interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {
/** 是否在底部渲染一个「关闭」按钮 */
showCloseButton?: boolean;
}
const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(({ className = '', showCloseButton = false, children, ...props }, ref) => {
const context = useDialogContext();
return (
<div ref={ref} className={`${styles[dialogStyleKeys.footer]} ${className}`} {...props}>
{children}
{showCloseButton && (
<Button type="button" variant="outline" onClick={() => context.close()}>
Close
</Button>
)}
</div>
);
});
DialogFooter.displayName = 'DialogFooter';
export interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(({ className = '', children, ...props }, ref) => {
const context = useDialogContext();
return (
<h2 ref={ref} id={context.titleId} className={`${styles[dialogStyleKeys.title]} ${className}`} {...props}>
{children}
</h2>
);
});
DialogTitle.displayName = 'DialogTitle';
export interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(({ className = '', children, ...props }, ref) => {
const context = useDialogContext();
return (
<p ref={ref} id={context.descriptionId} className={`${styles[dialogStyleKeys.description]} ${className}`} {...props}>
{children}
</p>
);
});
DialogDescription.displayName = 'DialogDescription';
export { Dialog, DialogTrigger, DialogClose, DialogOverlay, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
export default Dialog;
- Interaction
- modal
DependenciesBundle weight · light
- ui-primitives
- @radix-ui/react-slot