Modal 弹窗
UI分层弹窗:≤640px 底部抽屉,平板/桌面居中 Dialog。声明式走 Modal,命令式走 openModal / openModalAsync(overlay-kit)。
ModalTrigger声明式。Trigger 写在 Modal 里,由 Dialog / Drawer 自己管开关。
open / onOpenChange受控。外部按钮打开,不使用 ModalTrigger。
open = false
sizesm / 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。 |