MioUI

Dialog

A 16px-radius modal and a destructive confirm: GENTLE spring entrance, Esc / overlay close, focus return, locked while an async confirm runs.

Deleted: 0

Installation

components/ui/dialog.tsx
'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>
  );
}

Usage

import { Dialog, ConfirmDialog } from "@/components/ui/dialog";

<Dialog open={open} onOpenChange={setOpen} title="Rename" footer={<Button>Save</Button>}>
  …
</Dialog>

<ConfirmDialog
  open={open}
  onOpenChange={setOpen}
  title="Delete note?"
  description="This can’t be undone."
  confirmText="mio-blog"   // optional: type the name to confirm
  onConfirm={async () => { await api.delete(); }}
/>
PropTypeDefaultNotes
open / onOpenChangeboolean / (o) => void—Controlled state
sizesm md lg xlmdPanel max width
aligncenter topcentertop suits a command palette
dismissiblebooleantrueWhen false nothing can close it
ConfirmDialog onConfirm() => void | Promise—Spinner + lock while pending; shows error on reject
variantdanger defaultdangerButton and icon tone
confirmTextstring—Text that must be typed

On this page