Dialog
16px 圆角模态框与危险确认框:GENTLE 弹簧入场、Esc / 遮罩关闭、焦点还原,异步确认期间锁定。
已删除 0 项
'use client';
import { useState } from 'react';
import { Trash } from '@phosphor-icons/react';
import { Button } from '@/components/ui/button';
import { ConfirmDialog, Dialog } from '@/components/ui/dialog';
import { Field, Input, Label } from '@/components/ui/input';
export default function DialogDemo() {
const [confirm, setConfirm] = useState(false);
const [guarded, setGuarded] = useState(false);
const [edit, setEdit] = useState(false);
const [deleted, setDeleted] = useState(0);
const wait = () => new Promise<void>((r) => setTimeout(r, 1200));
return (
<div className="flex flex-col items-center gap-4">
<div className="flex flex-wrap justify-center gap-2">
<Button variant="secondary" onClick={() => setEdit(true)}>重命名</Button>
<Button variant="danger" onClick={() => setConfirm(true)}>
<Trash /> 删除笔记
</Button>
<Button variant="ghost" onClick={() => setGuarded(true)}>删除站点…</Button>
</div>
<p className="text-[13px] text-text-muted tabular-nums">已删除 {deleted} 项</p>
<Dialog
open={edit}
onOpenChange={setEdit}
title="重命名笔记"
description="新名称会同步到所有设备。"
closeLabel="关闭"
footer={
<>
<Button variant="ghost" onClick={() => setEdit(false)}>取消</Button>
<Button onClick={() => setEdit(false)}>保存</Button>
</>
}
>
<Field>
<Label htmlFor="note-name">名称</Label>
<Input id="note-name" defaultValue="周会记录 10/18" data-autofocus />
</Field>
</Dialog>
<ConfirmDialog
open={confirm}
onOpenChange={setConfirm}
title="删除笔记?"
description="「周会记录 10/18」将被永久删除,此操作无法撤销。"
confirmLabel="删除"
cancelLabel="取消"
onConfirm={async () => {
await wait();
setDeleted((n) => n + 1);
}}
/>
<ConfirmDialog
open={guarded}
onOpenChange={setGuarded}
title="删除站点 mio-blog?"
description="站点下的全部页面、域名与统计数据都会被清除。"
confirmText="mio-blog"
confirmTextLabel="输入 mio-blog 以确认"
confirmLabel="永久删除"
cancelLabel="取消"
onConfirm={async () => {
await wait();
setDeleted((n) => n + 1);
}}
/>
</div>
);
}
安装
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { Warning, X } from '@phosphor-icons/react';
import { useId, useRef, useState, type HTMLAttributes, type ReactNode, type RefObject } from 'react';
import { twMerge } from 'tailwind-merge';
import { SPRING_GENTLE } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { Portal, useFocusTrap, useLayer, useScrollLock } from './overlay';
import { Button } from './button';
import { FieldError, Input, Label } from './input';
/* ==========================================================================
Dialog:Portal + 遮罩淡入 + 面板 GENTLE 弹簧 .96→1,16px 圆角
Esc / 点遮罩关闭,打开聚焦首个可聚焦元素,关闭还原焦点,锁滚动
========================================================================== */
export interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
/** 底部操作区(右对齐) */
footer?: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
/** center(默认)或 top:靠上(命令面板用) */
align?: 'center' | 'top';
/** false 时 Esc / 遮罩 / × 都不能关闭(用于异步提交中) */
dismissible?: boolean;
/** 点遮罩是否关闭,默认 true */
closeOnOverlay?: boolean;
/** 隐藏右上角 × */
hideClose?: boolean;
closeLabel?: string;
/** 无标题时请提供 */
'aria-label'?: string;
role?: 'dialog' | 'alertdialog';
/** 打开时优先聚焦的元素;否则找 [data-autofocus],再找首个可聚焦元素 */
initialFocus?: RefObject<HTMLElement | null>;
/** 面板类名(可覆盖 padding / 宽度) */
className?: string;
}
const sizes = { sm: 'max-w-[400px]', md: 'max-w-[480px]', lg: 'max-w-[600px]', xl: 'max-w-[760px]' };
export function Dialog({
open,
onOpenChange,
title,
description,
children,
footer,
size = 'md',
align = 'center',
dismissible = true,
closeOnOverlay = true,
hideClose,
closeLabel = 'Close',
'aria-label': ariaLabel,
role = 'dialog',
initialFocus,
className,
}: DialogProps) {
const panel = useRef<HTMLDivElement>(null);
const titleId = useId();
const descId = useId();
const reduce = useReducedMotionPref();
const close = () => dismissible && onOpenChange(false);
const isTop = useLayer(open, close);
useScrollLock(open);
useFocusTrap(panel, open, { initialFocus, isTop });
const panelMotion = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
: {
initial: { opacity: 0, scale: 0.96, y: align === 'top' ? -8 : 4 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.97, y: align === 'top' ? -4 : 2, transition: { duration: 0.12 } },
transition: SPRING_GENTLE,
};
return (
<Portal>
<AnimatePresence>
{open && (
<div
className={twMerge(
'fixed inset-0 z-50 flex justify-center overflow-y-auto p-4',
align === 'top' ? 'items-start pt-[12vh]' : 'items-center',
)}
>
<motion.div
aria-hidden
className="fixed inset-0 bg-black/30 dark:bg-black/55"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.18, ease: [0.16, 1, 0.3, 1] }}
onClick={() => closeOnOverlay && close()}
/>
<motion.div
ref={panel}
role={role}
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descId : undefined}
aria-label={title ? undefined : ariaLabel}
tabIndex={-1}
className={twMerge(
'relative w-full rounded-2xl bg-surface-2 p-6 text-text-primary shadow-popover outline-none dark:bg-popover-surface',
sizes[size],
className,
)}
{...panelMotion}
>
{(title || description) && (
<div className={twMerge('mb-4 flex flex-col gap-1.5', !hideClose && 'pr-8')}>
{title && (
<h2 id={titleId} className="font-title text-[18px] leading-snug text-text-primary">
{title}
</h2>
)}
{description && (
<div id={descId} className="text-sm leading-relaxed text-text-secondary">
{description}
</div>
)}
</div>
)}
{children}
{footer && <DialogFooter>{footer}</DialogFooter>}
{!hideClose && (
<button
type="button"
aria-label={closeLabel}
disabled={!dismissible}
onClick={close}
data-focus-skip
className="cds-focus absolute right-3 top-3 inline-flex size-7 items-center justify-center rounded-lg text-text-muted transition-colors duration-[120ms] ease-cds hover:bg-ghost-hover hover:text-text-primary disabled:opacity-40 [&_svg]:size-4"
>
<X />
</button>
)}
</motion.div>
</div>
)}
</AnimatePresence>
</Portal>
);
}
export function DialogFooter({ className, ...p }: HTMLAttributes<HTMLDivElement>) {
return <div className={twMerge('mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...p} />;
}
/* ==========================================================================
ConfirmDialog:危险确认。异步 onConfirm 期间显示转圈、锁定两个按钮并禁止关闭;
可选“输入名称以确认”。
========================================================================== */
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description?: ReactNode;
/** 可返回 Promise;resolve 后自动关闭,reject 时保持打开并显示错误信息 */
onConfirm: () => void | Promise<void>;
variant?: 'danger' | 'default';
confirmLabel?: ReactNode;
cancelLabel?: ReactNode;
/** 设置后需要输入完全一致的文本才能确认 */
confirmText?: string;
/** 输入框上方提示,例如“输入 xxx 以确认” */
confirmTextLabel?: ReactNode;
/** 自定义图标;danger 默认 Warning */
icon?: ReactNode;
children?: ReactNode;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
onConfirm,
variant = 'danger',
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
confirmText,
confirmTextLabel,
icon,
children,
}: ConfirmDialogProps) {
const [pending, setPending] = useState(false);
const [typed, setTyped] = useState('');
const [error, setError] = useState<string | null>(null);
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const guardOk = !confirmText || typed === confirmText;
const change = (o: boolean) => {
if (pending) return;
if (!o) {
setTyped('');
setError(null);
}
onOpenChange(o);
};
const run = async () => {
if (!guardOk || pending) return;
setError(null);
try {
const r = onConfirm();
if (r && typeof (r as Promise<void>).then === 'function') {
setPending(true);
await r;
}
setPending(false);
setTyped('');
onOpenChange(false);
} catch (e) {
setPending(false);
setError(e instanceof Error ? e.message : String(e ?? 'Error'));
}
};
const danger = variant === 'danger';
const ic = icon ?? (danger ? <Warning weight="fill" /> : null);
return (
<Dialog
open={open}
onOpenChange={change}
role="alertdialog"
size="sm"
dismissible={!pending}
hideClose
initialFocus={confirmText ? inputRef : undefined}
aria-label={typeof title === 'string' ? title : undefined}
className="p-5"
>
<div className="flex gap-3.5">
{ic && (
<span
aria-hidden
className={twMerge(
'mt-0.5 inline-flex size-9 shrink-0 items-center justify-center rounded-full [&_svg]:size-[18px]',
danger ? 'bg-danger/10 text-danger' : 'bg-accent-soft text-accent-text',
)}
>
{ic}
</span>
)}
<div className="min-w-0 flex-1">
<h2 className="font-title text-[17px] leading-snug text-text-primary">{title}</h2>
{description && <div className="mt-1.5 text-sm leading-relaxed text-text-secondary">{description}</div>}
{children}
{confirmText && (
<div className="mt-4 flex flex-col gap-1.5">
{confirmTextLabel && <Label htmlFor={inputId} className="font-normal text-text-secondary">{confirmTextLabel}</Label>}
<Input
id={inputId}
ref={inputRef}
value={typed}
disabled={pending}
autoComplete="off"
spellCheck={false}
placeholder={confirmText}
className="font-mono text-[13px]"
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') run();
}}
/>
</div>
)}
<FieldError className="mt-3">{error}</FieldError>
</div>
</div>
<DialogFooter className="mt-5">
<Button data-autofocus={confirmText ? undefined : ''} variant="ghost" disabled={pending} onClick={() => change(false)}>
{cancelLabel}
</Button>
<Button variant={danger ? 'danger' : 'primary'} disabled={pending || !guardOk} aria-busy={pending || undefined} onClick={run}>
{pending && <Spinner />}
{confirmLabel}
</Button>
</DialogFooter>
</Dialog>
);
}
/** 内联小转圈(与 feedback.tsx 的 Spinner 同形,避免循环依赖) */
function Spinner() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-3.5 animate-spin">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
用法
import { Dialog, ConfirmDialog } from "@/components/ui/dialog";
<Dialog open={open} onOpenChange={setOpen} title="重命名" footer={<Button>保存</Button>}>
…
</Dialog>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="删除笔记?"
description="此操作无法撤销。"
confirmLabel="删除"
cancelLabel="取消"
confirmText="mio-blog" // 可选:输入名称才能确认
onConfirm={async () => { await api.delete(); }}
/>| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
open / onOpenChange | boolean / (o) => void | — | 受控开关 |
size | sm md lg xl | md | 面板最大宽度 |
align | center top | center | top 适合命令面板 |
dismissible | boolean | true | 为 false 时禁止任何方式关闭 |
ConfirmDialog onConfirm | () => void | Promise | — | Promise 期间转圈并锁定;reject 显示错误 |
variant | danger default | danger | 按钮与图标色调 |
confirmText | string | — | 需输入一致文本 |