组件列表

Modal 弹窗

UI

分层弹窗:≤640px 底部抽屉,平板/桌面居中 Dialog。声明式走 Modal,命令式走 openModal / openModalAsync(overlay-kit)。

ModalTrigger

声明式。Trigger 写在 Modal 里,由 Dialog / Drawer 自己管开关。

open / onOpenChange

受控。外部按钮打开,不使用 ModalTrigger。

open = false

size

sm / md / lg 只作用于居中 Dialog。手机抽屉忽略 size。

size = md

openModal

命令式。不必持有 open;根上要有 OverlayProvider。

openModalAsync

打开后请求草稿;Esc / 取消 abort 并结束 Promise。确认后先删除再 close 结果。

使用示例
import { useEffect, useState } from 'react'
import {
  Modal,
  ModalTrigger,
  ModalContent,
  ModalHeader,
  ModalTitle,
  ModalDescription,
  ModalBody,
  ModalFooter,
  ModalClose,
} from '@components/ui/Modal'
import { openModal, openModalAsync } from '@components/overlay/modal'

// ModalTrigger:声明式,内部自己管开关
<Modal size="md">
  <ModalTrigger asChild>
    <button type="button">打开弹窗</button>
  </ModalTrigger>
  <ModalContent>
    <ModalHeader>
      <ModalTitle>标题</ModalTitle>
      <ModalDescription>描述</ModalDescription>
    </ModalHeader>
    <ModalBody>内容</ModalBody>
    <ModalFooter>
      <ModalClose asChild>
        <button type="button">关闭</button>
      </ModalClose>
    </ModalFooter>
  </ModalContent>
</Modal>

// open / onOpenChange:受控
function Controlled() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>打开</button>
      <Modal size="md" open={open} onOpenChange={setOpen}>
        <ModalContent>
          <ModalHeader>
            <ModalTitle>受控</ModalTitle>
          </ModalHeader>
        </ModalContent>
      </Modal>
    </>
  )
}

// size:sm / md / lg,只作用于 Dialog
<Modal size="lg" open={open} onOpenChange={setOpen}>
  <ModalContent />
</Modal>

// openModal:根上已挂 OverlayProvider
openModal({
  size: 'sm',
  children: ({ close }) => (
    <>
      <ModalHeader>
        <ModalTitle>提示</ModalTitle>
      </ModalHeader>
      <ModalFooter>
        <button type="button" onClick={close}>知道了</button>
      </ModalFooter>
    </>
  ),
})

// openModalAsync:打开时拉数据,关闭 abort;确认后再发请求
type DeleteDraftResult = { deleted: true; title: string } | false

function DeleteDraftDialog({
  draftId,
  close,
}: {
  draftId: string
  close: (value?: DeleteDraftResult) => void
}) {
  const [title, setTitle] = useState<string | null>(null)

  useEffect(() => {
    const ac = new AbortController()
    fetch(`/api/drafts/${draftId}`, { signal: ac.signal })
      .then((res) => res.json())
      .then((draft) => setTitle(draft.title))
    return () => ac.abort()
  }, [draftId])

  async function onConfirm() {
    if (!title) return
    await fetch(`/api/drafts/${draftId}`, { method: 'DELETE' })
    close({ deleted: true, title })
  }

  return (
    <>
      <ModalHeader>
        <ModalTitle>删除草稿?</ModalTitle>
        <ModalDescription>打开拉标题;关掉 abort。确认后再删除。</ModalDescription>
      </ModalHeader>
      <ModalBody>{title ? `将永久删除「${title}」` : '正在加载…'}</ModalBody>
      <ModalFooter>
        <button type="button" onClick={() => close(false)}>取消</button>
        <button type="button" disabled={!title} onClick={() => void onConfirm()}>
          确认删除
        </button>
      </ModalFooter>
    </>
  )
}

const result = await openModalAsync<DeleteDraftResult>({
  size: 'sm',
  children: ({ close }) => (
    <DeleteDraftDialog draftId="draft-1" close={close} />
  ),
})
组件源码
// ===== ui/Modal/index.ts =====
export { Modal } from './Modal'
export { ModalTrigger } from './ModalTrigger'
export { ModalContent } from './ModalContent'
export { ModalHeader } from './ModalHeader'
export { ModalTitle } from './ModalTitle'
export { ModalDescription } from './ModalDescription'
export { ModalBody } from './ModalBody'
export { ModalFooter } from './ModalFooter'
export { ModalClose } from './ModalClose'

export type {
  ModalProps,
  ModalSize,
  ModalTriggerProps,
  ModalCloseProps,
  ModalContentProps,
  ModalSectionProps,
} from './types'


// ===== ui/Modal/types.ts =====
import type { ReactNode } from 'react'

export type ModalSize = 'sm' | 'md' | 'lg'

export interface ModalProps {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  size?: ModalSize
  onExit?: () => void
  children: ReactNode
}

export interface ModalTriggerProps {
  className?: string
  asChild?: boolean
  children?: ReactNode
}

export interface ModalCloseProps {
  className?: string
  asChild?: boolean
  children?: ReactNode
}

export interface ModalContentProps {
  className?: string
  children?: ReactNode
  showCloseButton?: boolean
}

export interface ModalSectionProps {
  className?: string
  children?: ReactNode
}


// ===== ui/Modal/Modal.tsx =====
'use client'

import { useCallback, useEffect, useMemo, useRef } from 'react'

import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@components/ui/dialog'
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@components/ui/drawer'
import { useMediaQuery } from '@hooks/useMediaQuery'

import { ModalContext } from './context'
import type { ModalProps } from './types'

/** jsdom 没有关动画;真实浏览器会先走 animationend。 */
const EXIT_FALLBACK_MS = 400

/**
 * 弹窗根:≤640px 用 Drawer,否则用 Dialog。
 * 不依赖 overlay-kit;只认 open / onOpenChange / size / onExit。
 */
export function Modal({ children, size = 'md', onExit, ...props }: ModalProps) {
  const isMobile = useMediaQuery('mobile', {
    defaultValue: false,
    initializeWithValue: false,
  })
  const onExitRef = useRef(onExit)
  onExitRef.current = onExit
  const exitedRef = useRef(false)

  const fireExit = useCallback(() => {
    if (exitedRef.current) return
    exitedRef.current = true
    onExitRef.current?.()
  }, [])

  useEffect(() => {
    if (props.open) exitedRef.current = false
  }, [props.open])

  useEffect(() => {
    if (props.open !== false) return
    const id = window.setTimeout(() => fireExit(), EXIT_FALLBACK_MS)
    return () => window.clearTimeout(id)
  }, [props.open, fireExit])

  const contextValue = useMemo(
    () => ({
      isMobile,
      size,
      fireExit,
      Trigger: isMobile ? DrawerTrigger : DialogTrigger,
      Close: isMobile ? DrawerClose : DialogClose,
      Content: isMobile ? DrawerContent : DialogContent,
      Header: isMobile ? DrawerHeader : DialogHeader,
      Title: isMobile ? DrawerTitle : DialogTitle,
      Description: isMobile ? DrawerDescription : DialogDescription,
      Footer: isMobile ? DrawerFooter : DialogFooter,
    }),
    [isMobile, size, fireExit],
  )

  const Comp = isMobile ? Drawer : Dialog
  const drawerProps = isMobile ? { autoFocus: true } : {}

  return (
    <ModalContext value={contextValue}>
      <Comp {...props} {...drawerProps}>
        {children}
      </Comp>
    </ModalContext>
  )
}


// ===== overlay/modal.tsx =====
'use client'

import type { ReactNode } from 'react'
import { overlay } from 'overlay-kit'

import { Modal, ModalContent, type ModalSize } from '@components/ui/Modal'

export interface OpenModalClose {
  close: () => void
}

export interface OpenModalAsyncClose<T> {
  close: (value?: T) => void
}

export interface OpenModalOptions {
  size?: ModalSize
  children: (ctx: OpenModalClose) => ReactNode
}

export interface OpenModalAsyncOptions<T> {
  size?: ModalSize
  children: (ctx: OpenModalAsyncClose<T>) => ReactNode
}

export function openModal({ size, children }: OpenModalOptions) {
  overlay.open(({ isOpen, close, unmount }) => (
    <Modal
      open={isOpen}
      size={size}
      onOpenChange={(next) => {
        if (!next) close()
      }}
      onExit={unmount}
    >
      <ModalContent>{children({ close })}</ModalContent>
    </Modal>
  ))
}

export function openModalAsync<T>({ size, children }: OpenModalAsyncOptions<T>) {
  return overlay.openAsync<T | undefined>(({ isOpen, close, unmount }) => (
    <Modal
      open={isOpen}
      size={size}
      onOpenChange={(next) => {
        if (!next) close(undefined)
      }}
      onExit={unmount}
    >
      <ModalContent>{children({ close })}</ModalContent>
    </Modal>
  ))
}


// ===== previews/Exhibition/index.tsx =====
'use client'

import { useEffect, useRef, useState, type ReactNode } from 'react'
import { OverlayProvider } from 'overlay-kit'

import { Spinner } from '@components/ui/Spinner'

import { openModal, openModalAsync } from '@components/overlay/modal'
import {
  Modal,
  ModalBody,
  ModalClose,
  ModalContent,
  ModalDescription,
  ModalFooter,
  ModalHeader,
  ModalTitle,
  ModalTrigger,
  type ModalSize,
} from '@components/ui/Modal'

function ApiCard({
  api,
  note,
  children,
}: {
  api: string
  note: string
  children: ReactNode
}) {
  return (
    <div className="flex min-w-[240px] flex-col gap-2 rounded-lg border border-accent/20 bg-background/50 p-3 text-left">
      <code className="text-xs font-medium text-accent">{api}</code>
      <p className="text-xs text-foreground/70 text-pretty">{note}</p>
      {children}
    </div>
  )
}

function Status({ children }: { children: ReactNode }) {
  return <p className="mt-1 font-mono text-[11px] text-foreground/60">{children}</p>
}

const triggerBtn =
  'rounded-md border border-accent px-3 py-1.5 text-sm text-accent hover:bg-accent/10'
const outlineBtn = 'btn btn-outline'

export function TriggerDemo() {
  return (
    <ApiCard
      api="ModalTrigger"
      note="声明式。Trigger 写在 Modal 里,由 Dialog / Drawer 自己管开关。"
    >
      <Modal size="md">
        <ModalTrigger asChild>
          <button className={triggerBtn} type="button">
            打开弹窗
          </button>
        </ModalTrigger>
        <ModalContent>
          <ModalHeader>
            <ModalTitle>组件标题</ModalTitle>
            <ModalDescription>平板和桌面居中,手机从底部滑出。</ModalDescription>
          </ModalHeader>
          <ModalBody>
            <p>≤640px 为 Drawer,更宽为 Dialog。</p>
          </ModalBody>
          <ModalFooter>
            <ModalClose asChild>
              <button type="button" className={outlineBtn}>
                关闭
              </button>
            </ModalClose>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </ApiCard>
  )
}

export function ControlledDemo() {
  const [open, setOpen] = useState(false)

  return (
    <ApiCard api="open / onOpenChange" note="受控。外部按钮打开,不使用 ModalTrigger。">
      <button className={triggerBtn} type="button" onClick={() => setOpen(true)}>
        打开弹窗
      </button>
      <Status>open = {String(open)}</Status>
      <Modal size="md" open={open} onOpenChange={setOpen}>
        <ModalContent>
          <ModalHeader>
            <ModalTitle>受控弹窗</ModalTitle>
            <ModalDescription>open 由父级持有。</ModalDescription>
          </ModalHeader>
          <ModalBody>
            <p>关闭走 onOpenChange(false)。</p>
          </ModalBody>
          <ModalFooter>
            <ModalClose asChild>
              <button type="button" className={outlineBtn}>
                取消
              </button>
            </ModalClose>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </ApiCard>
  )
}

export function SizeDemo() {
  const [size, setSize] = useState<ModalSize>('md')
  const [open, setOpen] = useState(false)

  return (
    <ApiCard api="size" note="sm / md / lg 只作用于居中 Dialog。手机抽屉忽略 size。">
      <div className="flex flex-wrap gap-1">
        {(['sm', 'md', 'lg'] as const).map((next) => (
          <button
            key={next}
            type="button"
            className={triggerBtn}
            onClick={() => {
              setSize(next)
              setOpen(true)
            }}
          >
            {next}
          </button>
        ))}
      </div>
      <Status>size = {size}</Status>
      <Modal size={size} open={open} onOpenChange={setOpen}>
        <ModalContent>
          <ModalHeader>
            <ModalTitle>size={size}</ModalTitle>
            <ModalDescription>桌面比平板更宽一档。</ModalDescription>
          </ModalHeader>
          <ModalFooter>
            <ModalClose asChild>
              <button type="button" className={outlineBtn}>
                关闭
              </button>
            </ModalClose>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </ApiCard>
  )
}

export function OpenModalDemo() {
  return (
    <ApiCard api="openModal" note="命令式。不必持有 open;根上要有 OverlayProvider。">
      <button
        className={triggerBtn}
        type="button"
        onClick={() => {
          openModal({
            size: 'sm',
            children: ({ close }) => (
              <>
                <ModalHeader>
                  <ModalTitle>命令式弹窗</ModalTitle>
                  <ModalDescription>openModal,无需本地 open 状态。</ModalDescription>
                </ModalHeader>
                <ModalBody>
                  <p>由 overlay-kit 在 Provider 内挂载。</p>
                </ModalBody>
                <ModalFooter>
                  <button type="button" className={outlineBtn} onClick={close}>
                    知道了
                  </button>
                </ModalFooter>
              </>
            ),
          })
        }}
      >
        打开弹窗
      </button>
    </ApiCard>
  )
}

function wait(ms: number, signal: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    const onAbort = () => {
      window.clearTimeout(id)
      reject(new DOMException('Aborted', 'AbortError'))
    }
    const id = window.setTimeout(() => {
      signal.removeEventListener('abort', onAbort)
      resolve()
    }, ms)
    if (signal.aborted) {
      onAbort()
      return
    }
    signal.addEventListener('abort', onAbort, { once: true })
  })
}

async function fetchDraft(id: string, signal: AbortSignal) {
  await wait(700, signal)
  return { id, title: '未完成的分层设计笔记' }
}

async function deleteDraft(_id: string, signal: AbortSignal) {
  await wait(500, signal)
}

type DeleteDraftResult = { deleted: true; title: string } | false

function DeleteDraftDialog({
  draftId,
  close,
}: {
  draftId: string
  close: (value?: DeleteDraftResult) => void
}) {
  const [title, setTitle] = useState<string | null>(null)
  const [phase, setPhase] = useState<'loading' | 'ready' | 'deleting' | 'error'>(
    'loading',
  )
  const loadAcRef = useRef<AbortController | null>(null)
  const deleteAcRef = useRef<AbortController | null>(null)

  useEffect(() => {
    const ac = new AbortController()
    loadAcRef.current = ac
    setPhase('loading')
    setTitle(null)

    fetchDraft(draftId, ac.signal)
      .then((draft) => {
        if (ac.signal.aborted) return
        setTitle(draft.title)
        setPhase('ready')
      })
      .catch((error: unknown) => {
        if (error instanceof DOMException && error.name === 'AbortError') return
        setPhase('error')
      })

    return () => {
      ac.abort()
      deleteAcRef.current?.abort()
    }
  }, [draftId])

  function abortPending() {
    loadAcRef.current?.abort()
    deleteAcRef.current?.abort()
  }

  async function onConfirm() {
    if (!title) return
    const ac = new AbortController()
    deleteAcRef.current = ac
    setPhase('deleting')
    try {
      await deleteDraft(draftId, ac.signal)
      if (ac.signal.aborted) return
      close({ deleted: true, title })
    } catch (error: unknown) {
      if (error instanceof DOMException && error.name === 'AbortError') return
      setPhase('error')
    }
  }

  return (
    <>
      <ModalHeader>
        <ModalTitle>删除草稿?</ModalTitle>
        <ModalDescription>
          打开时拉取标题;关掉会 abort 请求。确认后再发删除。
        </ModalDescription>
      </ModalHeader>
      <ModalBody>
        {phase === 'loading' && (
          <p className="flex items-center gap-2">
            <Spinner size="sm" />
            正在加载草稿…
          </p>
        )}
        {phase === 'error' && <p>请求失败,请关闭后重试。</p>}
        {(phase === 'ready' || phase === 'deleting') && title && (
          <p>将永久删除「{title}」。此操作不能撤销。</p>
        )}
      </ModalBody>
      <ModalFooter>
        <button
          type="button"
          className={outlineBtn}
          disabled={phase === 'deleting'}
          onClick={() => {
            abortPending()
            close(false)
          }}
        >
          取消
        </button>
        <button
          type="button"
          className={outlineBtn}
          disabled={phase !== 'ready'}
          onClick={() => void onConfirm()}
        >
          {phase === 'deleting' ? '删除中…' : '确认删除'}
        </button>
      </ModalFooter>
    </>
  )
}

export function OpenModalAsyncDemo() {
  const [log, setLog] = useState('—')

  return (
    <ApiCard
      api="openModalAsync"
      note="打开后请求草稿;Esc / 取消 abort 并结束 Promise。确认后先删除再 close 结果。"
    >
      <button
        className={triggerBtn}
        type="button"
        onClick={async () => {
          setLog('等待确认…')
          const result = await openModalAsync<DeleteDraftResult>({
            size: 'sm',
            children: ({ close }) => (
              <DeleteDraftDialog draftId="draft-1" close={close} />
            ),
          })

          if (result) {
            setLog(`已删除「${result.title}」`)
            return
          }
          if (result === false) {
            setLog('已取消,未发删除请求')
            return
          }
          setLog('已关闭(Esc),请求已 abort')
        }}
      >
        删除草稿
      </button>
      <Status>{log}</Status>
    </ApiCard>
  )
}

/** 与 SelectApiPreview 相同:按 API 拆成卡片,而不是一排裸按钮。 */
export function ModalApiPreview() {
  return (
    <OverlayProvider>
      <div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
        <TriggerDemo />
        <ControlledDemo />
        <SizeDemo />
        <OpenModalDemo />
        <OpenModalAsyncDemo />
      </div>
    </OverlayProvider>
  )
}

API

Modal

根组件。≤640px 用 Drawer,否则用居中 Dialog。不依赖 overlay-kit。

属性 类型 默认值 说明
open boolean 受控开关。命令式路径由适配器传入 overlay 的 isOpen。
defaultOpen boolean 非受控初始打开状态。仅在未传 open 时生效。
onOpenChange (open: boolean) => void 打开或关闭时回调。点遮罩 / Esc / ModalClose 都会走到这里。
size 'sm' | 'md' | 'lg' 'md' 只作用于 Dialog。平板与桌面宽度不同;手机抽屉忽略。
onExit () => void 关闭动画结束(或 400ms 兜底)后调用。命令式适配器用来 unmount overlay。
children ReactNode 必填 通常为 ModalTrigger(可选)与 ModalContent。

ModalTrigger / ModalClose

打开与关闭。asChild 时把行为合并到子按钮上。

属性 类型 默认值 说明
asChild boolean false 为 true 时不渲染自己的 button,把 props 交给 children。
className string 触发器 / 关闭控件的 class。
children ReactNode 按钮文案或 asChild 时的实际控件。

ModalContent

面板。Dialog 上应用 size;抽屉为全宽 + max-h-[80vh]。

属性 类型 默认值 说明
showCloseButton boolean true Dialog 右上角关闭按钮。Drawer 无此按钮。
className string 面板容器 class。可覆盖默认 max-h 与 size 宽度。
children ReactNode 通常为 Header、Body、Footer。

ModalHeader / Title / Description / Body / Footer

复合布局。Body 滚动,Header 与 Footer 钉住。

属性 类型 默认值 说明
className string 各段容器 class。
children ReactNode 该段内容。

openModal / openModalAsync

overlay-kit 适配器。从 @components/overlay/modal 导入,不要从 ui/Modal 导入。应用根需 OverlayProvider。

属性 类型 默认值 说明
size 'sm' | 'md' | 'lg' 'md' 传给内部 Modal,规则相同。
children ({ close }) => ReactNode 必填 面板内容。openModal 的 close() 无返回值;openModalAsync 的 close(value) 决定 Promise。弹层内发起的请求应在卸载时 abort。