From c3a3052377f55c76f980991153bec11d2ce2821a Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Tue, 3 Sep 2024 17:08:58 +0800 Subject: [PATCH 01/11] feat(PortalProvider): add component --- .../portal-provider/demos/demo1.tsx | 19 +++ src/components/portal-provider/index.en.md | 19 +++ src/components/portal-provider/index.tsx | 7 + src/components/portal-provider/index.zh.md | 19 +++ .../portal-provider/portal-provider.tsx | 127 ++++++++++++++++++ .../tests/portal-provider.test.tsx | 5 + src/index.ts | 7 +- 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/components/portal-provider/demos/demo1.tsx create mode 100644 src/components/portal-provider/index.en.md create mode 100644 src/components/portal-provider/index.tsx create mode 100644 src/components/portal-provider/index.zh.md create mode 100644 src/components/portal-provider/portal-provider.tsx create mode 100644 src/components/portal-provider/tests/portal-provider.test.tsx diff --git a/src/components/portal-provider/demos/demo1.tsx b/src/components/portal-provider/demos/demo1.tsx new file mode 100644 index 0000000000..0933e9975a --- /dev/null +++ b/src/components/portal-provider/demos/demo1.tsx @@ -0,0 +1,19 @@ +import { ConfigProvider, ErrorBlock, PortalProvider } from 'antd-mobile' +import zhCN from 'antd-mobile/es/locales/zh-CN' +import { DemoBlock } from 'demos' +import React from 'react' + +export default () => { + return ( + <> + + + {/* should be wrapped with `` */} + + + + + + + ) +} 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..22e9166c15 --- /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` 的子组件 (一般是应用入口) | `Locale` | [zh-CN] | - | diff --git a/src/components/portal-provider/portal-provider.tsx b/src/components/portal-provider/portal-provider.tsx new file mode 100644 index 0000000000..69edc80f6f --- /dev/null +++ b/src/components/portal-provider/portal-provider.tsx @@ -0,0 +1,127 @@ +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 +} + +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..651223bbf4 --- /dev/null +++ b/src/components/portal-provider/tests/portal-provider.test.tsx @@ -0,0 +1,5 @@ +describe('PortalProvider', () => { + test('WIP 🚧', () => { + expect(true) + }) +}) diff --git a/src/index.ts b/src/index.ts index a23367e414..d62274aea4 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' @@ -168,6 +171,8 @@ export type { } from './components/popover' export { default as Popup } from './components/popup' export type { PopupProps } from './components/popup' +export { default as PortalProvider } 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' From 3a4caa5c3a164d65b48636a0697920c5fbe716a6 Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Tue, 3 Sep 2024 17:32:57 +0800 Subject: [PATCH 02/11] feat(Modal): add `useModal` --- src/components/modal/index.ts | 1 + src/components/modal/use-modal.tsx | 115 +++++++++++++++++++++++++++++ src/index.ts | 2 +- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/components/modal/use-modal.tsx 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/use-modal.tsx b/src/components/modal/use-modal.tsx new file mode 100644 index 0000000000..0f064e7d77 --- /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.Modal.ok, + 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/index.ts b/src/index.ts index d62274aea4..ed9f32e8d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -139,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, From 75f2f3d6fe4e21a290bf025929f0df08921ca84a Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Tue, 3 Sep 2024 17:44:14 +0800 Subject: [PATCH 03/11] feat(PortalProvider): update demos --- .../portal-provider/demos/demo1.tsx | 81 ++++++++++++++++++- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/src/components/portal-provider/demos/demo1.tsx b/src/components/portal-provider/demos/demo1.tsx index 0933e9975a..86d8bb9086 100644 --- a/src/components/portal-provider/demos/demo1.tsx +++ b/src/components/portal-provider/demos/demo1.tsx @@ -1,5 +1,11 @@ -import { ConfigProvider, ErrorBlock, PortalProvider } from 'antd-mobile' -import zhCN from 'antd-mobile/es/locales/zh-CN' +import { + Button, + ConfigProvider, + PortalProvider, + Space, + useModal, +} from 'antd-mobile' +import enUS from 'antd-mobile/es/locales/en-US' import { DemoBlock } from 'demos' import React from 'react' @@ -7,13 +13,80 @@ export default () => { return ( <> - + {/* should be wrapped with `` */} - + ) } + +const ComponentWantsToUseModal = () => { + const { show, confirm, alert, clear } = useModal() + return ( + + + + + + ) +} From a4db67d4a523dc17c3b792168661a0dc8543fbfa Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Thu, 5 Sep 2024 18:25:49 +0800 Subject: [PATCH 04/11] fix(demo): typo --- src/components/portal-provider/demos/demo1.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/portal-provider/demos/demo1.tsx b/src/components/portal-provider/demos/demo1.tsx index 86d8bb9086..7671724905 100644 --- a/src/components/portal-provider/demos/demo1.tsx +++ b/src/components/portal-provider/demos/demo1.tsx @@ -12,7 +12,7 @@ import React from 'react' export default () => { return ( <> - + {/* should be wrapped with `` */} From 917991f0b7aefdb4c30f82ec5108b504118a6ba0 Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Thu, 5 Sep 2024 18:26:24 +0800 Subject: [PATCH 05/11] test(Modal): add test cases --- src/components/modal/tests/modal.test.tsx | 267 +++++++++++++++++++++- 1 file changed, 266 insertions(+), 1 deletion(-) diff --git a/src/components/modal/tests/modal.test.tsx b/src/components/modal/tests/modal.test.tsx index e212458ec1..2d16d71fb3 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,268 @@ 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 + }) + }) }) From ad10b2ace76f9e9c839c6f96de3ba22f791f1c37 Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Thu, 5 Sep 2024 18:26:41 +0800 Subject: [PATCH 06/11] test(PortalProvider): add test cases --- .../tests/portal-provider.test.tsx | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/components/portal-provider/tests/portal-provider.test.tsx b/src/components/portal-provider/tests/portal-provider.test.tsx index 651223bbf4..e29e772932 100644 --- a/src/components/portal-provider/tests/portal-provider.test.tsx +++ b/src/components/portal-provider/tests/portal-provider.test.tsx @@ -1,5 +1,50 @@ +import React from 'react' +import { fireEvent, render, screen } from 'testing' +import PortalProvider, { usePortal } from '..' +import Modal from '../../modal' + +function $$(className: string) { + return document.querySelectorAll(className) +} + describe('PortalProvider', () => { - test('WIP 🚧', () => { - expect(true) + 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) }) }) From cdfda86a62e3c414f61ed94772402eeb453626fe Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Thu, 5 Sep 2024 18:40:02 +0800 Subject: [PATCH 07/11] fix(Modal): typo --- src/components/modal/use-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/modal/use-modal.tsx b/src/components/modal/use-modal.tsx index 0f064e7d77..ddf0dda0c1 100644 --- a/src/components/modal/use-modal.tsx +++ b/src/components/modal/use-modal.tsx @@ -37,7 +37,7 @@ export const useModal = () => { */ const confirm = useMemoizedFn((p: ModalConfirmProps) => { const defaultProps = { - confirmText: getDefaultConfig().locale.Modal.ok, + confirmText: getDefaultConfig().locale.common.confirm, cancelText: getDefaultConfig().locale.common.cancel, } const props = mergeProps(defaultProps, p) From 2bcac0def1b8fffb9c44feb6024a0c51fd85b32c Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Fri, 6 Sep 2024 11:51:43 +0800 Subject: [PATCH 08/11] test(Modal): add test cases --- src/components/modal/tests/modal.test.tsx | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/components/modal/tests/modal.test.tsx b/src/components/modal/tests/modal.test.tsx index 2d16d71fb3..3b9fccf347 100644 --- a/src/components/modal/tests/modal.test.tsx +++ b/src/components/modal/tests/modal.test.tsx @@ -515,4 +515,43 @@ describe('Modal', () => { 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) + }) }) From 6bef414f8ea861f5c6fc816fcffa138c9d95f082 Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Fri, 6 Sep 2024 14:50:05 +0800 Subject: [PATCH 09/11] test(PortalProvider): add test cases --- .../tests/portal-provider.test.tsx | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/components/portal-provider/tests/portal-provider.test.tsx b/src/components/portal-provider/tests/portal-provider.test.tsx index e29e772932..573d2b14e6 100644 --- a/src/components/portal-provider/tests/portal-provider.test.tsx +++ b/src/components/portal-provider/tests/portal-provider.test.tsx @@ -1,7 +1,9 @@ -import React from 'react' -import { fireEvent, render, screen } from 'testing' +/* 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) @@ -47,4 +49,80 @@ describe('PortalProvider', () => { 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' + ) + }) }) From 4658f08ebbda8b2916bf352dcaa3a941885fd110 Mon Sep 17 00:00:00 2001 From: 0xTYLER Date: Fri, 6 Sep 2024 15:05:19 +0800 Subject: [PATCH 10/11] fix(PortalProvider): demo comment --- src/components/portal-provider/demos/demo1.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/portal-provider/demos/demo1.tsx b/src/components/portal-provider/demos/demo1.tsx index 7671724905..29f7ac3f2c 100644 --- a/src/components/portal-provider/demos/demo1.tsx +++ b/src/components/portal-provider/demos/demo1.tsx @@ -14,7 +14,7 @@ export default () => { <> - {/* should be wrapped with `` */} + {/* to make sure that `` is wrapped inner `` so that `` can access context from `` */} From 82cc469ca66ec78e06f695502bfee51a37219448 Mon Sep 17 00:00:00 2001 From: Tylerrrkd Date: Sat, 7 Sep 2024 22:20:49 +0800 Subject: [PATCH 11/11] feat(PortalProvider): improve code quality --- .../portal-provider/demos/demo1.tsx | 36 ++++++++++++++++++- src/components/portal-provider/index.zh.md | 4 +-- .../portal-provider/portal-provider.tsx | 3 ++ src/index.ts | 5 ++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/components/portal-provider/demos/demo1.tsx b/src/components/portal-provider/demos/demo1.tsx index 29f7ac3f2c..401dbd9fb6 100644 --- a/src/components/portal-provider/demos/demo1.tsx +++ b/src/components/portal-provider/demos/demo1.tsx @@ -1,9 +1,12 @@ 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' @@ -14,8 +17,9 @@ export default () => { <> - {/* to make sure that `` is wrapped inner `` so that `` can access context from `` */} + {/* to make sure that `` is wrapped inner `` so that its children can access context from `` */} + @@ -24,6 +28,36 @@ export default () => { ) } +const ComponentWantsToUsePortal = () => { + const { renderModalInPortal } = usePortal() + return ( + + + + + ) +} + const ComponentWantsToUseModal = () => { const { show, confirm, alert, clear } = useModal() return ( diff --git a/src/components/portal-provider/index.zh.md b/src/components/portal-provider/index.zh.md index 22e9166c15..5bffc84f4e 100644 --- a/src/components/portal-provider/index.zh.md +++ b/src/components/portal-provider/index.zh.md @@ -15,5 +15,5 @@ ### 属性 | 属性 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | --- | -| children | 需要用到 `useModal` 的子组件 (一般是应用入口) | `Locale` | [zh-CN] | - | +| --- | --- | --- | --- | +| children | 需要用到 `useModal` 的子组件 (一般是应用入口) | `ReactNode` | - | diff --git a/src/components/portal-provider/portal-provider.tsx b/src/components/portal-provider/portal-provider.tsx index 69edc80f6f..76d835adaf 100644 --- a/src/components/portal-provider/portal-provider.tsx +++ b/src/components/portal-provider/portal-provider.tsx @@ -30,6 +30,9 @@ interface WrapperProps { unmount: () => void } +/** + * @description refer to `src/utils/render-imperatively` + */ const Wrapper = React.forwardRef( ({ element, unmount }, ref) => { const [visible, setVisible] = useState(false) diff --git a/src/index.ts b/src/index.ts index ed9f32e8d8..5886e5bc14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -171,7 +171,10 @@ export type { } from './components/popover' export { default as Popup } from './components/popup' export type { PopupProps } from './components/popup' -export { default as PortalProvider } from './components/portal-provider' +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'