diff --git a/src/components/modal/index.ts b/src/components/modal/index.ts
index 1285e59607..55e6f0160c 100644
--- a/src/components/modal/index.ts
+++ b/src/components/modal/index.ts
@@ -12,6 +12,7 @@ export type { ModalShowProps, ModalShowHandler } from './show'
export type { ModalAlertProps } from './alert'
export type { ModalConfirmProps } from './confirm'
+export { useModal } from './use-modal'
export default attachPropertiesToComponent(Modal, {
show,
alert,
diff --git a/src/components/modal/tests/modal.test.tsx b/src/components/modal/tests/modal.test.tsx
index e212458ec1..3b9fccf347 100644
--- a/src/components/modal/tests/modal.test.tsx
+++ b/src/components/modal/tests/modal.test.tsx
@@ -10,7 +10,8 @@ import {
act,
waitFakeTimers,
} from 'testing'
-import Modal, { ModalAlertProps } from '..'
+import Modal, { ModalAlertProps, useModal } from '..'
+import PortalProvider from '../../portal-provider'
const classPrefix = `adm-modal`
@@ -250,4 +251,307 @@ describe('Modal', () => {
await promise
})
})
+
+ const UseModalApp: React.FC<{ element: React.ReactNode }> = ({ element }) => {
+ return {element}
+ }
+
+ const UseModalAlert = (props: ModalAlertProps) => {
+ const { alert } = useModal()
+
+ return (
+
+ )
+ }
+
+ test('afterShow should be called', async () => {
+ const afterShow = jest.fn()
+ render(} />)
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ await waitFor(() => expect(afterShow).toBeCalled())
+ })
+
+ test('onConfirm should be called', async () => {
+ const onConfirm = jest.fn()
+ render(} />)
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ const modal = $$(`.${classPrefix}`)[0]
+ fireEvent.click(screen.getByRole('button', { name: '我知道了' }))
+ expect(onConfirm).toBeCalled()
+ await waitForElementToBeRemoved(modal)
+ })
+
+ test('close on mask click', async () => {
+ const onClose = jest.fn()
+ const afterClose = jest.fn()
+ render(
+
+ }
+ />
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ await waitFakeTimers()
+
+ const mask = document.querySelector('.adm-mask-aria-button')!
+ fireEvent.click(mask)
+
+ await waitForElementToBeRemoved(mask)
+
+ expect(onClose).toBeCalled()
+ expect(afterClose).toBeCalled()
+ })
+
+ test('show close button', async () => {
+ render(} />)
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ expect($$(`.adm-center-popup-close`)).toHaveLength(1)
+ })
+
+ test('custom content', async () => {
+ render(
+ header}
+ title='title'
+ content={content
}
+ image='https://gw.alipayobjects.com/mdn/rms_efa86a/afts/img/A*SE7kRojatZ0AAAAAAAAAAAAAARQnAQ'
+ />
+ }
+ />
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ expect($$(`.${classPrefix}-header`)).toHaveLength(1)
+ expect($$(`.${classPrefix}-title`)).toHaveLength(1)
+ expect($$(`.${classPrefix}-image-container`)).toHaveLength(1)
+ expect($$(`.${classPrefix}-content`)[0].firstChild).not.toHaveClass(
+ 'adm-auto-center'
+ )
+ })
+
+ test('wait for alert to complete', async () => {
+ const fn = jest.fn()
+ const Demo = () => {
+ const { alert } = useModal()
+ return (
+
+ )
+ }
+ render(} />)
+
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ const modal = $$(`.${classPrefix}`)[0]
+ fireEvent.click(screen.getByRole('button', { name: '我知道了' }))
+ await waitForElementToBeRemoved(modal)
+ expect(fn).toBeCalled()
+ })
+
+ test('wait for confirm to complete', async () => {
+ const fn = jest.fn()
+ const Confirm = () => {
+ const { confirm } = useModal()
+ return (
+
+ )
+ }
+
+ render(} />)
+ const btn = screen.getByRole('button', { name: 'btn' })
+ fireEvent.click(btn)
+ fireEvent.click(screen.getByRole('button', { name: '确定' }))
+ await waitForElementToBeRemoved($$(`.${classPrefix}`)[0])
+
+ fireEvent.click(btn)
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
+ await waitForElementToBeRemoved($$(`.${classPrefix}`)[0])
+
+ expect(fn.mock.calls[0][0]).toBe(true)
+ expect(fn.mock.calls[1][0]).toBe(false)
+ })
+
+ test('custom actions', async () => {
+ const actions = [
+ {
+ key: 'read',
+ text: 'read',
+ primary: true,
+ },
+ {
+ key: 'download',
+ text: 'download',
+ danger: true,
+ },
+ {
+ key: 'share',
+ text: 'share',
+ disabled: true,
+ },
+ ]
+
+ const Demo = () => {
+ const { show } = useModal()
+
+ return (
+
+ )
+ }
+
+ render(} />)
+
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ const download = screen.getByRole('button', { name: 'download' })
+ const share = screen.getByRole('button', { name: 'share' })
+ const modal = $$(`.${classPrefix}`)[0]
+ expect($$('.adm-button')).toHaveLength(actions.length)
+ expect(download).toHaveClass('adm-button-danger')
+ expect(share).toHaveClass('adm-button-disabled')
+ expect(share).toBeDisabled()
+ fireEvent.click(download)
+ await waitForElementToBeRemoved(modal)
+ })
+
+ test('without actions', async () => {
+ const Demo = () => {
+ const { show } = useModal()
+ return (
+
+ )
+ }
+ render(} />)
+
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ expect($$(`.${classPrefix}-footer-empty`)).toHaveLength(1)
+ })
+
+ test('action onClick', async () => {
+ const promise = Promise.resolve()
+ const onClick = jest.fn(() => promise)
+ const actions = [
+ {
+ key: 'ok',
+ text: 'ok',
+ onClick,
+ },
+ ]
+
+ const Demo = () => {
+ const { show } = useModal()
+
+ return (
+
+ )
+ }
+
+ render(} />)
+
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ fireEvent.click(screen.getByRole('button', { name: 'ok' }))
+ expect(onClick).toBeCalled()
+ // await the promise instead of returning directly, because act expects a "void" result
+ await act(async () => {
+ await promise
+ })
+ })
+
+ test('close all modals when `clear` is called', async () => {
+ const onClose = jest.fn()
+
+ const Demo = () => {
+ const { show, clear } = useModal()
+
+ const actions = [
+ {
+ key: 'clear',
+ text: 'clear',
+ onClick: clear,
+ },
+ ]
+
+ return (
+
+ )
+ }
+
+ render(} />)
+
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ const modal = $$(`.${classPrefix}`)[0]
+
+ fireEvent.click(screen.getByRole('button', { name: 'clear' }))
+ expect(onClose).toBeCalled()
+ await waitForElementToBeRemoved(modal)
+ })
})
diff --git a/src/components/modal/use-modal.tsx b/src/components/modal/use-modal.tsx
new file mode 100644
index 0000000000..ddf0dda0c1
--- /dev/null
+++ b/src/components/modal/use-modal.tsx
@@ -0,0 +1,115 @@
+import { useMemoizedFn } from 'ahooks'
+import React from 'react'
+import { mergeProps } from '../../utils/with-default-props'
+import { getDefaultConfig } from '../config-provider'
+import { usePortal } from '../portal-provider'
+import { ModalAlertProps } from './alert'
+import { ModalConfirmProps } from './confirm'
+import { Modal } from './modal'
+import { ModalShowProps } from './show'
+
+export const closeFnSet = new Set<() => void>()
+
+export const useModal = () => {
+ const { renderModalInPortal } = usePortal()
+
+ /**
+ * @description refer to Modal.show
+ * @see https://github.com/ant-design/ant-design-mobile/blob/master/src/components/modal/show.tsx
+ */
+ const show = useMemoizedFn((props: ModalShowProps) => {
+ const handler = renderModalInPortal(
+ {
+ closeFnSet.delete(handler.close)
+ props.afterClose?.()
+ }}
+ />
+ )
+ closeFnSet.add(handler.close)
+ return handler
+ })
+
+ /**
+ * @description refer to Modal.confirm
+ * @see https://github.com/ant-design/ant-design-mobile/blob/master/src/components/modal/confirm.tsx
+ */
+ const confirm = useMemoizedFn((p: ModalConfirmProps) => {
+ const defaultProps = {
+ confirmText: getDefaultConfig().locale.common.confirm,
+ cancelText: getDefaultConfig().locale.common.cancel,
+ }
+ const props = mergeProps(defaultProps, p)
+
+ return new Promise(resolve => {
+ show({
+ ...props,
+ closeOnAction: true,
+ onClose: () => {
+ props.onClose?.()
+ resolve(false)
+ },
+ actions: [
+ {
+ key: 'confirm',
+ text: props.confirmText,
+ primary: true,
+ onClick: async () => {
+ await props.onConfirm?.()
+ resolve(true)
+ },
+ },
+ {
+ key: 'cancel',
+ text: props.cancelText,
+ onClick: async () => {
+ await props.onCancel?.()
+ resolve(false)
+ },
+ },
+ ],
+ })
+ })
+ })
+
+ /**
+ * @description refer to Modal.alert
+ * @see https://github.com/ant-design/ant-design-mobile/blob/master/src/components/modal/alert.tsx
+ */
+ const alert = useMemoizedFn((p: ModalAlertProps) => {
+ const defaultProps = {
+ confirmText: getDefaultConfig().locale.Modal.ok,
+ }
+ const props = mergeProps(defaultProps, p)
+ return new Promise(resolve => {
+ show({
+ ...props,
+ closeOnAction: true,
+ actions: [
+ {
+ key: 'confirm',
+ text: props.confirmText,
+ primary: true,
+ },
+ ],
+ onAction: props.onConfirm,
+ onClose: () => {
+ props.onClose?.()
+ resolve()
+ },
+ })
+ })
+ })
+
+ const clear = useMemoizedFn(() => {
+ closeFnSet.forEach(close => close())
+ })
+
+ return {
+ show,
+ clear,
+ confirm,
+ alert,
+ }
+}
diff --git a/src/components/portal-provider/demos/demo1.tsx b/src/components/portal-provider/demos/demo1.tsx
new file mode 100644
index 0000000000..401dbd9fb6
--- /dev/null
+++ b/src/components/portal-provider/demos/demo1.tsx
@@ -0,0 +1,126 @@
+import {
+ Button,
+ ConfigProvider,
+ Modal,
+ Popup,
+ PortalProvider,
+ Space,
+ useModal,
+ usePortal,
+} from 'antd-mobile'
+import enUS from 'antd-mobile/es/locales/en-US'
+import { DemoBlock } from 'demos'
+import React from 'react'
+
+export default () => {
+ return (
+ <>
+
+
+ {/* to make sure that `` is wrapped inner `` so that its children can access context from `` */}
+
+
+
+
+
+
+ >
+ )
+}
+
+const ComponentWantsToUsePortal = () => {
+ const { renderModalInPortal } = usePortal()
+ return (
+
+
+
+
+ )
+}
+
+const ComponentWantsToUseModal = () => {
+ const { show, confirm, alert, clear } = useModal()
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/components/portal-provider/index.en.md b/src/components/portal-provider/index.en.md
new file mode 100644
index 0000000000..3ccccf02c3
--- /dev/null
+++ b/src/components/portal-provider/index.en.md
@@ -0,0 +1,19 @@
+# PortalProvider
+
+Use `Modal` or `Popup` component imperatively with `hooks` style globally.
+
+## When to use
+
+- If you want to use `useModal` from `Modal` or `usePopup` `Popup` (`WIP 🚧 `) components
+
+## Demos
+
+
+
+## PortalProvider
+
+### Props
+
+| Name | Description | Type | Default |
+| -------- | ------------------------------------- | ----------- | ------- |
+| children | Component who wants to use `useModal` | `ReactNode` | - |
diff --git a/src/components/portal-provider/index.tsx b/src/components/portal-provider/index.tsx
new file mode 100644
index 0000000000..9e1e404442
--- /dev/null
+++ b/src/components/portal-provider/index.tsx
@@ -0,0 +1,7 @@
+import { PortalProvider } from './portal-provider'
+
+export type { PortalProviderProps } from './portal-provider'
+
+export { usePortal } from './portal-provider'
+
+export default PortalProvider
diff --git a/src/components/portal-provider/index.zh.md b/src/components/portal-provider/index.zh.md
new file mode 100644
index 0000000000..5bffc84f4e
--- /dev/null
+++ b/src/components/portal-provider/index.zh.md
@@ -0,0 +1,19 @@
+# PortalProvider 配置
+
+让你能够像风一般自由地以 `hooks` 形式指令式调用 `Modal` 或者 `Popup` 等组件。
+
+## 何时使用
+
+- 假设您想试用 `useModal` 或者 `usePopup` (`🚧 假设本PR通过时`) 时需要 `PortalProvider`
+
+## 示例
+
+
+
+## PortalProvider
+
+### 属性
+
+| 属性 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| children | 需要用到 `useModal` 的子组件 (一般是应用入口) | `ReactNode` | - |
diff --git a/src/components/portal-provider/portal-provider.tsx b/src/components/portal-provider/portal-provider.tsx
new file mode 100644
index 0000000000..76d835adaf
--- /dev/null
+++ b/src/components/portal-provider/portal-provider.tsx
@@ -0,0 +1,130 @@
+import { useMemoizedFn } from 'ahooks'
+import React, {
+ createContext,
+ createRef,
+ PropsWithChildren,
+ useContext,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react'
+import type { ImperativeHandler } from '../../utils/render-imperatively'
+
+const PortalContext = createContext(undefined)
+
+type ImperativeProps = {
+ visible?: boolean
+ onClose?: () => void
+ afterClose?: () => void
+}
+
+type TargetElement = React.ReactElement
+
+interface PortalContextType {
+ renderModalInPortal: (element: TargetElement) => ImperativeHandler
+}
+
+interface WrapperProps {
+ element: TargetElement
+ unmount: () => void
+}
+
+/**
+ * @description refer to `src/utils/render-imperatively`
+ */
+const Wrapper = React.forwardRef(
+ ({ element, unmount }, ref) => {
+ const [visible, setVisible] = useState(false)
+ const closedRef = useRef(false)
+ const [elementToRender, setElementToRender] = useState(element)
+ const keyRef = useRef(0)
+ useEffect(() => {
+ if (!closedRef.current) {
+ setVisible(true)
+ } else {
+ afterClose()
+ }
+ }, [])
+ function onClose() {
+ closedRef.current = true
+ setVisible(false)
+ elementToRender.props.onClose?.()
+ }
+ function afterClose() {
+ unmount()
+ elementToRender.props.afterClose?.()
+ }
+ useImperativeHandle(ref, () => ({
+ close: onClose,
+ replace: element => {
+ keyRef.current++
+ elementToRender.props.afterClose?.()
+ setElementToRender(element)
+ },
+ }))
+ return React.cloneElement(elementToRender, {
+ ...elementToRender.props,
+ key: keyRef.current,
+ visible,
+ onClose,
+ afterClose,
+ })
+ }
+)
+
+export type PortalProviderProps = PropsWithChildren & {}
+
+let wrapperId = 0
+
+export const PortalProvider: React.FC = ({ children }) => {
+ const [portalElements, setPortalElements] = useState([])
+ const wrapperRef = createRef()
+
+ const renderModalInPortal = useMemoizedFn((element: TargetElement) => {
+ const unmount = () => {
+ setPortalElements(elements =>
+ elements.filter(el => el.key !== wrappedElement.key)
+ )
+ }
+ const wrappedElement = (
+
+ )
+ setPortalElements(elements => [...elements, wrappedElement])
+ return {
+ close: async () => {
+ if (!wrapperRef.current) {
+ // it means the wrapper is not mounted yet, call `unmount` directly
+ unmount()
+ // call `afterClose` to make sure the callback is called
+ element.props.afterClose?.()
+ } else {
+ wrapperRef.current?.close()
+ }
+ },
+ replace: element => {
+ wrapperRef.current?.replace(element)
+ },
+ isRendered: () => !!wrapperRef.current,
+ } as ImperativeHandler
+ })
+ return (
+
+ {children}
+ {portalElements}
+
+ )
+}
+
+export const usePortal = () => {
+ const context = useContext(PortalContext)
+ if (!context) {
+ throw new Error('usePortal must be used within a PortalProvider')
+ }
+ return context
+}
diff --git a/src/components/portal-provider/tests/portal-provider.test.tsx b/src/components/portal-provider/tests/portal-provider.test.tsx
new file mode 100644
index 0000000000..573d2b14e6
--- /dev/null
+++ b/src/components/portal-provider/tests/portal-provider.test.tsx
@@ -0,0 +1,128 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import React, { useRef } from 'react'
+import { fireEvent, render, screen, waitFor } from 'testing'
+import PortalProvider, { usePortal } from '..'
+import Modal from '../../modal'
+import Popup from '../../popup'
+
+function $$(className: string) {
+ return document.querySelectorAll(className)
+}
+
+describe('PortalProvider', () => {
+ const UsePortalApp: React.FC<{ element: React.ReactNode }> = ({
+ element,
+ }) => {
+ return {element}
+ }
+
+ test('usePortal should only has `renderModalInPortal`', () => {
+ let portal: ReturnType
+
+ const Demo = () => {
+ portal = usePortal()
+ return null
+ }
+ render(
+
+
+
+ )
+
+ expect(Object.keys(portal!)).toEqual(['renderModalInPortal'])
+ })
+
+ test('`renderModalInPortal` should auto add `visible` prop', () => {
+ const Demo = () => {
+ const { renderModalInPortal } = usePortal()
+ return (
+
+ )
+ }
+ render(} />)
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ expect($$(`.adm-modal`)).toHaveLength(1)
+ })
+
+ test('element render by `renderModalInPortal` can be replaced', () => {
+ const Demo = () => {
+ const { renderModalInPortal } = usePortal()
+ const ref = useRef>()
+
+ const actions = [
+ {
+ key: 'replace',
+ text: 'replace',
+ onClick: () => {
+ ref.current?.replace?.(Popup content)
+ },
+ },
+ ]
+
+ return (
+
+ )
+ }
+ render(} />)
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ expect($$(`.adm-modal`)).toHaveLength(1)
+ fireEvent.click(screen.getByRole('button', { name: 'replace' }))
+ expect($$(`.adm-modal`)).toHaveLength(0)
+ expect($$(`.adm-popup`)).toHaveLength(1)
+ })
+
+ test('status isRendered `renderModalInPortal` should be `true` when element rendered', async () => {
+ const afterShow = jest.fn()
+ let isRendered = false
+ const Demo = () => {
+ const { renderModalInPortal } = usePortal()
+ const ref = useRef>()
+
+ return (
+
+ )
+ }
+ render(} />)
+ fireEvent.click(screen.getByRole('button', { name: 'btn' }))
+ await waitFor(() => expect(afterShow).toBeCalled())
+ expect(isRendered).toEqual(true)
+ })
+
+ test('call `usePortal` does not wrapped by `PortalProvider` should throw error', async () => {
+ const Demo = () => {
+ usePortal()
+ return null
+ }
+
+ expect(() => render()).toThrow(
+ 'usePortal must be used within a PortalProvider'
+ )
+ })
+})
diff --git a/src/index.ts b/src/index.ts
index a23367e414..5886e5bc14 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -61,7 +61,10 @@ export type {
} from './components/checkbox'
export { default as Collapse } from './components/collapse'
export type { CollapseProps, CollapsePanelProps } from './components/collapse'
-export { default as ConfigProvider, useConfig } from './components/config-provider'
+export {
+ default as ConfigProvider,
+ useConfig,
+} from './components/config-provider'
export type { ConfigProviderProps } from './components/config-provider'
export { default as DatePicker } from './components/date-picker'
export type { DatePickerProps, DatePickerRef } from './components/date-picker'
@@ -136,7 +139,7 @@ export { default as Loading } from './components/loading'
export type { LoadingProps } from './components/loading'
export { default as Mask } from './components/mask'
export type { MaskProps } from './components/mask'
-export { default as Modal } from './components/modal'
+export { default as Modal, useModal } from './components/modal'
export type {
ModalProps,
ModalShowProps,
@@ -168,6 +171,11 @@ export type {
} from './components/popover'
export { default as Popup } from './components/popup'
export type { PopupProps } from './components/popup'
+export {
+ default as PortalProvider,
+ usePortal,
+} from './components/portal-provider'
+export type { PortalProviderProps } from './components/portal-provider'
export { default as ProgressBar } from './components/progress-bar'
export type { ProgressBarProps } from './components/progress-bar'
export { default as ProgressCircle } from './components/progress-circle'