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'