diff --git a/package.json b/package.json index bb54beda73..4d16c4d601 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "rc-animate": "^3.1.0", "rc-calendar": "9.15.11", "rc-checkbox": "^2.2.0", - "rc-dialog": "^8.1.0", + "rc-dialog": "^8.6.0", "rc-drawer": "^4.1.0", "rc-field-form": "^1.11.0", "rc-menu": "^8.5.1", diff --git a/src/index.ts b/src/index.ts index 63c411c047..10c615f9cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,7 +40,7 @@ export { ModalStaticFuncReturn, ModalStaticFuncType, ModalStaticFunc, -} from './modal'; +} from './legacy/modal'; export { default as Page, PageProps } from './page'; export { default as Pagination, PaginationProps } from './pagination'; export { default as Popconfirm, PopconfirmProps } from './legacy/popconfirm'; diff --git a/src/modal/CalloutModal.tsx b/src/legacy/modal/CalloutModal.tsx similarity index 96% rename from src/modal/CalloutModal.tsx rename to src/legacy/modal/CalloutModal.tsx index e5e39b5b99..d0edb41d14 100644 --- a/src/modal/CalloutModal.tsx +++ b/src/legacy/modal/CalloutModal.tsx @@ -3,8 +3,8 @@ import React, { useState } from 'react'; import classnames from 'classnames'; import Modal from './Modal'; import { ICalloutModalProps } from './interface'; -import usePrefixCls from '../utils/hooks/use-prefix-cls'; -import Button from '../legacy/button'; +import usePrefixCls from '../../utils/hooks/use-prefix-cls'; +import Button from '../../legacy/button'; const CalloutModal: React.FC = ({ visible, diff --git a/src/modal/Footer.tsx b/src/legacy/modal/Footer.tsx similarity index 96% rename from src/modal/Footer.tsx rename to src/legacy/modal/Footer.tsx index 72983a3706..9317731e69 100644 --- a/src/modal/Footer.tsx +++ b/src/legacy/modal/Footer.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import classnames from 'classnames'; -import Button from '../legacy/button'; +import Button from '../../legacy/button'; import { IFooterProps } from './interface'; import ModalPrefixClsContext from './ModalContext'; diff --git a/src/legacy/modal/Modal.tsx b/src/legacy/modal/Modal.tsx new file mode 100644 index 0000000000..c25639d7de --- /dev/null +++ b/src/legacy/modal/Modal.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import classnames from 'classnames'; +import { useLocale, usePrefixCls } from '@gio-design/utils'; +import RcDialog from 'rc-dialog'; +import { CloseOutlined } from '@gio-design/icons'; +import { ButtonProps } from '../../legacy/button'; +import { IModalProps, ModalLocale } from './interface'; +import ModalPrefixClsContext from './ModalContext'; +import Title from './Title'; +import Footer from './Footer'; +import defaultLocale from './locales/zh-CN'; + +const Modal: React.FC = ({ + prefixCls: customPrefixCls, + size = 'small', + className, + wrapClassName, + useBack, + title, + additionalFooter, + onBack, + closeAfterOk, + dropCloseButton, + okText: customizeOKText, + closeText: customizeCloseText, + okButtonProps, + closeButtonProps, + onOk, + onClose, + pending, + ...restProps +}: IModalProps) => { + const prefix = usePrefixCls('modal', customPrefixCls); + const locale = useLocale('Modal'); + const { closeText, okText } = { + ...defaultLocale, + ...locale, + } as ModalLocale; + + const modalCls = classnames(className, { + [`${prefix}--small`]: size === 'small', + [`${prefix}--middle`]: size === 'middle', + [`${prefix}--full`]: size === 'full', + }); + const wrapperCls = classnames(wrapClassName, `${prefix}__wrapper`); + const closeCls = classnames(`${prefix}__close`, { + [`${prefix}__close--disabled`]: pending, + }); + + const useOkBtn = !!onOk && typeof onOk === 'function'; + let useFooter = useOkBtn || !dropCloseButton || !!additionalFooter; + if ('footer' in restProps && (restProps.footer === false || restProps.footer === null)) { + useFooter = false; + } + const okBtnProps: ButtonProps = { + loading: pending, + disabled: pending, + ...okButtonProps, + }; + const closeBtnProps: ButtonProps = { + disabled: pending, + ...closeButtonProps, + }; + + const handleOk = async (e: React.MouseEvent) => { + if (onOk && typeof onOk === 'function') { + e.persist(); + try { + await Promise.resolve(onOk(e)); + if (closeAfterOk) { + onClose?.(e); + } + } catch (error) { + const err = error ?? 'onOk 执行 reject 或抛出错误。'; + console.error(err); + } + } + }; + + const handleClose = (e: React.SyntheticEvent) => { + if (!pending) { + onClose?.(e as React.MouseEvent); + } + }; + return ( + + } + title={title !== false && } + footer={ + useFooter && ( + <Footer + okText={customizeOKText ?? okText} + closeText={customizeCloseText ?? closeText} + okButtonProps={okBtnProps} + closeButtonProps={closeBtnProps} + footer={restProps.footer} + additionalFooter={additionalFooter} + onOk={handleOk} + onClose={handleClose} + useOk={useOkBtn} + useClose={!dropCloseButton} + /> + ) + } + /> + </ModalPrefixClsContext.Provider> + ); +}; + +export default Modal; diff --git a/src/modal/ModalContext.ts b/src/legacy/modal/ModalContext.ts similarity index 68% rename from src/modal/ModalContext.ts rename to src/legacy/modal/ModalContext.ts index 19ea73b82b..d8faad04ee 100644 --- a/src/modal/ModalContext.ts +++ b/src/legacy/modal/ModalContext.ts @@ -1,5 +1,5 @@ import { createContext } from 'react'; -import { defaultRootPrefixCls } from '../components/config-provider'; +import { defaultRootPrefixCls } from '../../components/config-provider'; const ModalPrefixClsContext = createContext(`${defaultRootPrefixCls}-modal`); diff --git a/src/modal/StepModal.tsx b/src/legacy/modal/StepModal.tsx similarity index 100% rename from src/modal/StepModal.tsx rename to src/legacy/modal/StepModal.tsx diff --git a/src/modal/Title.tsx b/src/legacy/modal/Title.tsx similarity index 100% rename from src/modal/Title.tsx rename to src/legacy/modal/Title.tsx diff --git a/src/modal/__test__/Modal.test.tsx b/src/legacy/modal/__test__/Modal.test.tsx similarity index 98% rename from src/modal/__test__/Modal.test.tsx rename to src/legacy/modal/__test__/Modal.test.tsx index dd40a238f1..af1d4b1b0e 100644 --- a/src/modal/__test__/Modal.test.tsx +++ b/src/legacy/modal/__test__/Modal.test.tsx @@ -7,8 +7,8 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { Default } from '../demos/Modal.stories'; import Modal from '..'; import { IModalStaticFuncReturn } from '../interface'; -import enUS from '../../locales/en-US'; -import zhCN from '../../locales/zh-CN'; +import enUS from '../../../locales/en-US'; +import zhCN from '../../../locales/zh-CN'; describe('Modal Testing', () => { it('renders with multi languages', () => { diff --git a/src/modal/__test__/Steps.tsx b/src/legacy/modal/__test__/Steps.tsx similarity index 98% rename from src/modal/__test__/Steps.tsx rename to src/legacy/modal/__test__/Steps.tsx index 0c0015065d..7c187b2e28 100644 --- a/src/modal/__test__/Steps.tsx +++ b/src/legacy/modal/__test__/Steps.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/prop-types */ /* eslint-disable no-console */ import React from 'react'; -import Button from '../../legacy/button'; +import Button from '../../../legacy/button'; interface Props { [key: string]: any; diff --git a/src/modal/__test__/staticFunc.test.tsx b/src/legacy/modal/__test__/staticFunc.test.tsx similarity index 98% rename from src/modal/__test__/staticFunc.test.tsx rename to src/legacy/modal/__test__/staticFunc.test.tsx index 591921ac16..7f37e3d2a1 100644 --- a/src/modal/__test__/staticFunc.test.tsx +++ b/src/legacy/modal/__test__/staticFunc.test.tsx @@ -5,8 +5,8 @@ import { WarningCircleFilled } from '@gio-design/icons'; import CalloutModal from '../CalloutModal'; import { withConfirm, withInfo, withSuccess, withWarn, withError, configModal } from '../callout'; import Modal from '..'; -import { sleep } from '../../utils/test'; -import { defaultRootPrefixCls } from '../../components/config-provider'; +import { sleep } from '../../../utils/test'; +import { defaultRootPrefixCls } from '../../../components/config-provider'; const { confirm } = Modal; diff --git a/src/modal/__test__/stepModal.test.tsx b/src/legacy/modal/__test__/stepModal.test.tsx similarity index 100% rename from src/modal/__test__/stepModal.test.tsx rename to src/legacy/modal/__test__/stepModal.test.tsx diff --git a/src/modal/__test__/utils.test.tsx b/src/legacy/modal/__test__/utils.test.tsx similarity index 100% rename from src/modal/__test__/utils.test.tsx rename to src/legacy/modal/__test__/utils.test.tsx diff --git a/src/modal/callout.tsx b/src/legacy/modal/callout.tsx similarity index 97% rename from src/modal/callout.tsx rename to src/legacy/modal/callout.tsx index daf343be65..7c11e42eec 100644 --- a/src/modal/callout.tsx +++ b/src/legacy/modal/callout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { WarningCircleFilled, InfoCircleFilled, CheckCircleFilled, CloseCircleFilled } from '@gio-design/icons'; import { PaletteBlue4, PaletteYellow5, PaletteGreen6, PaletteRed5 } from '@gio-design/tokens'; -import { defaultRootPrefixCls } from '../components/config-provider'; +import { defaultRootPrefixCls } from '../../components/config-provider'; import CalloutModal from './CalloutModal'; import { IModalStaticFuncConfig, IModalStaticFuncReturn, IModalConfigs } from './interface'; diff --git a/src/legacy/modal/demos/Modal.stories.tsx b/src/legacy/modal/demos/Modal.stories.tsx new file mode 100644 index 0000000000..1eaeffdde8 --- /dev/null +++ b/src/legacy/modal/demos/Modal.stories.tsx @@ -0,0 +1,233 @@ +/* eslint-disable no-console */ +import React, { useContext, useState } from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import { withDesign } from 'storybook-addon-designs'; +import Modal, { ModalProps, StepModalProps, StepModal } from '../index'; +import '../style'; +import Button from '../../../legacy/button'; +import { ConfigContext } from '../../../components/config-provider'; +import { IModalStaticFuncConfig } from '../interface'; +import Docs from './ModalPage'; + +export default { + title: 'Components/Modal', + component: Modal, + decorators: [withDesign], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/kP3A6S2fLUGVVMBgDuUx0f/GrowingIO-Design-Components?node-id=889%3A6757', + allowFullscreen: true, + }, + docs: { + page: Docs, + }, + }, +} as Meta; + +const Template: Story<ModalProps> = (args) => { + const [visible, setVisible] = useState(false); + return ( + <div> + <Button onClick={() => setVisible(true)}>Open Modal</Button> + <Modal + {...args} + visible={visible} + onClose={() => { + setVisible(false); + }} + > + Default Modal + </Modal> + </div> + ); +}; +export const Default = Template.bind({}); +Default.args = { + title: 'title', +}; + +const CustomHeightTemplate: Story<ModalProps> = (args) => { + const [visible, setVisible] = useState(false); + return ( + <div> + <Button onClick={() => setVisible(true)}>Open Modal</Button> + <Modal + {...args} + style={{ top: 100, width: 500, margin: '0 auto' }} + bodyStyle={{ height: 200 }} + visible={visible} + onClose={() => { + setVisible(false); + }} + > + {'Custom Height '.repeat(40)} + </Modal> + </div> + ); +}; +export const CustomHeight = CustomHeightTemplate.bind({}); +CustomHeight.args = { + title: 'title', +}; + +const StepModalTemplate: Story<StepModalProps> = (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [visible, setVisible] = useState(false); + const steps = [ + { + key: '1', + content: '这是内容1', + return: null, + }, + { + key: '2', + content: '这是内容2', + return: '1', + }, + { + key: '3', + content: '这是内容3', + return: '2', + }, + ]; + return ( + <> + <Button onClick={() => setVisible(true)}>Open StepModal</Button> + <StepModal + {...args} + visible={visible} + title="操作" + onOk={() => setVisible(false)} + steps={steps} + onClose={() => setVisible(false)} + /> + </> + ); +}; + +export const StepModalDemo = StepModalTemplate.bind({}); +StepModalDemo.args = { + title: '操作', +}; + +const buttonStyle = { + marginRight: 10, +}; +const FunctionModalTemplate: Story<IModalStaticFuncConfig> = (args) => { + const handleInfo = () => { + Modal.info({ + ...args, + title: 'Info', + content: 'Info content', + }); + }; + + const handleSuccess = () => { + Modal.success({ + ...args, + title: 'Success', + content: 'Success content', + }); + }; + + const handleWarn = () => { + Modal.warn({ + ...args, + title: 'Warn', + content: 'Warn content', + }); + }; + + const handleError = () => { + Modal.error({ + ...args, + title: 'Error', + content: 'Error content', + }); + }; + return ( + <> + <Button type="secondary" style={buttonStyle} onClick={() => handleInfo()}> + Info + </Button> + <Button type="secondary" style={buttonStyle} onClick={() => handleSuccess()}> + Success + </Button> + <Button type="secondary" style={buttonStyle} onClick={() => handleWarn()}> + Warn + </Button> + <Button type="secondary" style={buttonStyle} onClick={() => handleError()}> + Error + </Button> + </> + ); +}; + +export const FunctionModal = FunctionModalTemplate.bind({}); +FunctionModal.args = {}; + +const UseModalTemplate: Story<IModalStaticFuncConfig> = (args) => { + const [modalFuncs, hookModal] = Modal.useModal(); + const handleConfirm = () => { + modalFuncs.confirm({ + ...args, + title: 'Confirm', + content: 'Confirm content', + }); + }; + const handleInfo = () => { + modalFuncs.info({ + ...args, + title: 'Info', + content: 'Info content', + }); + }; + const handleSuccess = () => { + modalFuncs.success({ + ...args, + title: 'Success', + content: 'Success content', + }); + }; + const handleWarn = () => { + modalFuncs.warn({ + ...args, + title: 'Warn', + content: 'Warn content', + }); + }; + const handleError = () => { + modalFuncs.error({ + ...args, + title: 'Error', + content: 'Error content', + }); + }; + const context = useContext(ConfigContext); + return ( + <ConfigContext.Provider value={{ ...context, rootPrefixCls: 'gio' }}> + <> + <Button type="secondary" style={buttonStyle} onClick={() => handleConfirm()}> + confirm + </Button> + <Button type="secondary" style={buttonStyle} onClick={() => handleInfo()}> + Info + </Button> + <Button type="secondary" style={buttonStyle} onClick={() => handleSuccess()}> + Success + </Button> + <Button type="secondary" style={buttonStyle} onClick={() => handleWarn()}> + Warn + </Button> + <Button type="secondary" style={buttonStyle} onClick={() => handleError()}> + Error + </Button> + {hookModal} + </> + </ConfigContext.Provider> + ); +}; + +export const UseModal = UseModalTemplate.bind({}); +UseModal.args = {}; diff --git a/src/legacy/modal/demos/ModalPage.tsx b/src/legacy/modal/demos/ModalPage.tsx new file mode 100644 index 0000000000..c3280014f5 --- /dev/null +++ b/src/legacy/modal/demos/ModalPage.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Canvas, Title, Heading, Story, Subheading, ArgsTable } from '@storybook/addon-docs'; +import { useIntl } from 'react-intl'; +import Modal from '../Modal'; + +export default function ListPage() { + const { formatMessage } = useIntl(); + + return ( + <> + <Title>{formatMessage({ defaultMessage: 'Modal 弹窗' })} +

+ {formatMessage({ + defaultMessage: '在当前页面正中打开一个浮层', + })} +

+ {formatMessage({ defaultMessage: '代码演示' })} + {formatMessage({ defaultMessage: '基本样式' })} + + + + + {formatMessage({ defaultMessage: '自定义高的 Modal' })} + + + + + {formatMessage({ defaultMessage: '分步骤的 Modal' })} + + + + + {formatMessage({ defaultMessage: '函数式调用' })} + + + + + {formatMessage({ defaultMessage: 'Hook 调用' })} + + + + + {formatMessage({ defaultMessage: '参数说明' })} + + + ); +} diff --git a/src/legacy/modal/index.tsx b/src/legacy/modal/index.tsx new file mode 100644 index 0000000000..29148b1ccd --- /dev/null +++ b/src/legacy/modal/index.tsx @@ -0,0 +1,47 @@ +import GioModal from './Modal'; +import StepModal from './StepModal'; +import callout, { configModal, withConfirm, withInfo, withSuccess, withWarn, withError } from './callout'; +import useModal from './useModal'; +import { IModalStaticFuncConfig, IModalStaticFunctions, IUseModal, IModalConfigs } from './interface'; + +export { + IModalProps as ModalProps, + IStepModalProps as StepModalProps, + TModalSize as ModalSize, + IStep as Step, + TStepChange as StepChange, + IModalStaticFuncConfig as ModalStaticFuncConfig, + IModalStaticFuncReturn as ModalStaticFuncReturn, + TModalStaticFuncType as ModalStaticFuncType, + IModalStaticFunc as ModalStaticFunc, + IModalConfigs as ModalConfigs, +} from './interface'; + +export { StepModal, useModal }; + +export type TModal = typeof GioModal & + IModalStaticFunctions & { + config: (configs: IModalConfigs) => void; + useModal: IUseModal; + StepModal: typeof StepModal; + }; + +const Modal = GioModal as TModal; + +Modal.confirm = (config: IModalStaticFuncConfig) => callout(withConfirm(config)); + +Modal.info = (config: IModalStaticFuncConfig) => callout(withInfo(config)); + +Modal.success = (config: IModalStaticFuncConfig) => callout(withSuccess(config)); + +Modal.warn = (config: IModalStaticFuncConfig) => callout(withWarn(config)); + +Modal.error = (config: IModalStaticFuncConfig) => callout(withError(config)); + +Modal.useModal = useModal; + +Modal.config = configModal; + +Modal.StepModal = StepModal; + +export default Modal; diff --git a/src/legacy/modal/interface.ts b/src/legacy/modal/interface.ts new file mode 100644 index 0000000000..04749b0a22 --- /dev/null +++ b/src/legacy/modal/interface.ts @@ -0,0 +1,269 @@ +import { ReactElement, ReactNode, CSSProperties } from 'react'; +import { ButtonProps } from '../../legacy/button'; + +export type IStringOrHtmlElement = string | HTMLElement; + +export type TModalSize = 'small' | 'middle' | 'full'; + +export type TStepNoParamFn = () => void | Promise; + +export type ModalLocale = { + okText: string; + closeText: string; +}; + +export interface ITitleProps { + title?: ReactNode; + useBack?: boolean; + onBack?: TStepNoParamFn; +} + +export interface IFooterProps { + // 可以自定义除了 okButton 以及 closeBtn 外的组件 + additionalFooter?: ReactNode; + // 完全自定义 Footer 区域 + footer?: ReactNode; + okButtonProps?: ButtonProps; + closeButtonProps?: ButtonProps; + okText?: string; + closeText?: string; + onOk?: (e: React.MouseEvent) => void | Promise; + onClose?: (e: React.MouseEvent) => void | Promise; + useOk: boolean; + useClose: boolean; +} + +export interface IModalProps extends ITitleProps, Omit { + /** + 替代 `Modal` 组件 `class` 的 `gio-modal` 前缀 + */ + prefixCls?: string; + /** + `Modal` 根节点 `className` + */ + className?: string; + /** + `Modal` `wrap` 的 `className` + */ + wrapClassName?: string; + /** + `Modal` 根节点的样式 + */ + style?: CSSProperties; + /** + `Modal` `wrap` 内联样式 + */ + wrapStyle?: Record; + /** + `Modal` `body` 内联样式 + */ + bodyStyle?: Record; + /** + `Modal` `mask` 内联样式 + */ + maskStyle?: Record; + /** + `Modal` `body` `props` + */ + bodyProps?: Record; + /** + `Modal` `mask` `props` + */ + maskProps?: Record; + /** + `Modal wrap props` + */ + wrapProps?: Record; + /** + `Modal` 层级 + */ + zIndex?: number; + /** + `Modal` 右上角关闭 `Icon` + */ + closeIcon?: ReactNode; + /** + 被包裹的元素 + */ + children?: ReactNode; + /** + 是否支持按 ESC 关闭 Modal + */ + keyboard?: boolean; + /** + `Modal` 的尺寸 + */ + size?: TModalSize; + /** + `Modal` 是否可见 + */ + visible?: boolean; + /** + 是否不使用 `Footer` 中的关闭按钮 + */ + dropCloseButton?: boolean; + /** + 组件 `pending` 状态 + */ + pending?: boolean; + /** + 执行 `close` 后紧接着执行的操作 + */ + afterClose?: () => void; + /** + `Modal` `onOk` 执行后是否执行 `onClose` + */ + closeAfterOk?: boolean; + /** + `Modal` `onClose` 执行后是否卸载 `Modal` 组件 + */ + destroyOnClose?: boolean; + getContainer?: IStringOrHtmlElement | (() => HTMLElement) | false; + forceRender?: boolean; + focusTriggerAfterClose?: boolean; +} + +export type TStepChange = (nextStep: string) => void; + +export interface IStepModalNodeRenderProps { + step: IStep; + push: TStepChange; + pop: TStepNoParamFn; +} + +export type TModalNodeRender = ReactNode | ((renderProps: IStepModalNodeRenderProps) => ReactNode); + +export interface IStep { + /** + 当前 `Step` 的唯一标识 + */ + key: string; + /** + 当前 Step 的上一步 + */ + return: string | null; + /** + 多分支路径下,当前步骤是否是默认的下一步 + */ + firstNextInTier?: boolean; + /** + 多分支路径下的出口标识 + */ + wayout?: boolean; + /** + 下一步 回调 + */ + onNext?: TStepNoParamFn; + /** + 上一步 回调 + */ + onBack?: TStepNoParamFn; + /** + 当前步骤 `Modal` 的 `Title` + */ + title?: TModalNodeRender; + /** + 当前步骤 `Modal` 的 `Body` + */ + content?: TModalNodeRender; + /** + 当前步骤 `Modal` 的 `Footer` + */ + footer?: TModalNodeRender; + /** + 除了 `OkButton` 及 `CloseButton` 外的自定义 `Footer` + */ + additionalFooter?: ReactNode; + /** + 传递给下一步按钮的 props + */ + nextButtonProps?: ButtonProps; + /** + 传递给取消按钮的 `props` 元素 + */ + cancelButtonProps?: ButtonProps; + /** + 传递给上一步按钮的 `props` 元素 + @deprecated + */ + backButtonProps?: ButtonProps; + /** + 传递给下一步按钮的显示文案 + */ + nextText?: string; + /** + 传递给取消按钮的显示文案 + */ + cancelText?: string; + /** + 传递给上一步按钮的显示文案 + @deprecated + */ + backText?: string; +} + +export interface IStepInner extends IStep { + next?: string[]; +} + +export interface IStepMap { + [key: string]: IStepInner; +} + +export interface IStepModalProps extends Omit { + steps?: IStep[]; + onStepChange?: (step: string) => void; +} + +export type TModalStaticFuncType = 'confirm' | 'info' | 'success' | 'warn' | 'error'; + +export interface IModalStaticFuncConfig extends Omit { + content?: ReactNode; + // 函数式调用类型 + type?: TModalStaticFuncType; + // 函数式调用时的前缀 icon + icon?: ReactNode; + // 是否显示取消按钮 + showClose?: boolean; + onOk?: () => void | Promise; + onClose?: () => void | Promise; +} + +export interface IModalStaticFuncReturn { + destroy: () => void; + update: (config: IModalStaticFuncConfig) => void; +} + +export interface ICalloutModalProps extends IModalStaticFuncConfig { + visible: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + close: (...args: any[]) => void; + afterClose?: () => void; +} + +export interface IModalStaticFunc { + (config: IModalStaticFuncConfig): IModalStaticFuncReturn; +} + +export interface IModalStaticFunctions { + info: IModalStaticFunc; + success: IModalStaticFunc; + error: IModalStaticFunc; + warn: IModalStaticFunc; + confirm: IModalStaticFunc; +} + +export type THookModalRef = IModalStaticFuncReturn; + +export interface IHookModalProps { + config: IModalStaticFuncConfig; + afterClose: () => void; +} + +export interface IUseModal { + (): [IModalStaticFunctions, ReactElement]; +} + +export interface IModalConfigs { + prefixCls?: string; +} diff --git a/src/modal/locales/en-US.ts b/src/legacy/modal/locales/en-US.ts similarity index 100% rename from src/modal/locales/en-US.ts rename to src/legacy/modal/locales/en-US.ts diff --git a/src/modal/locales/zh-CN.ts b/src/legacy/modal/locales/zh-CN.ts similarity index 100% rename from src/modal/locales/zh-CN.ts rename to src/legacy/modal/locales/zh-CN.ts diff --git a/src/modal/style/callout.less b/src/legacy/modal/style/callout.less similarity index 100% rename from src/modal/style/callout.less rename to src/legacy/modal/style/callout.less diff --git a/src/legacy/modal/style/index.less b/src/legacy/modal/style/index.less new file mode 100644 index 0000000000..a7ee4ea1cc --- /dev/null +++ b/src/legacy/modal/style/index.less @@ -0,0 +1,3 @@ +@import './modal.less'; +@import './mask.less'; +@import './callout.less'; diff --git a/src/legacy/modal/style/index.ts b/src/legacy/modal/style/index.ts new file mode 100644 index 0000000000..d74e52ee9f --- /dev/null +++ b/src/legacy/modal/style/index.ts @@ -0,0 +1 @@ +import './index.less'; diff --git a/src/modal/style/mask.less b/src/legacy/modal/style/mask.less similarity index 100% rename from src/modal/style/mask.less rename to src/legacy/modal/style/mask.less diff --git a/src/modal/style/mixin.less b/src/legacy/modal/style/mixin.less similarity index 91% rename from src/modal/style/mixin.less rename to src/legacy/modal/style/mixin.less index c61a83fa57..e98eed43e1 100644 --- a/src/modal/style/mixin.less +++ b/src/legacy/modal/style/mixin.less @@ -1,5 +1,5 @@ -@import '../../stylesheet/index.less'; -@import '../../stylesheet/mixin/animation.less'; +@import '../../../stylesheet/index.less'; +@import '../../../stylesheet/mixin/animation.less'; @modal-prefix-cls: ~'@{component-prefix}-modal'; @modal-small: ~'@{modal-prefix-cls}--small'; diff --git a/src/modal/style/modal.less b/src/legacy/modal/style/modal.less similarity index 100% rename from src/modal/style/modal.less rename to src/legacy/modal/style/modal.less diff --git a/src/modal/useModal/HookModal.tsx b/src/legacy/modal/useModal/HookModal.tsx similarity index 100% rename from src/modal/useModal/HookModal.tsx rename to src/legacy/modal/useModal/HookModal.tsx diff --git a/src/legacy/modal/useModal/index.tsx b/src/legacy/modal/useModal/index.tsx new file mode 100644 index 0000000000..03845ae18c --- /dev/null +++ b/src/legacy/modal/useModal/index.tsx @@ -0,0 +1,51 @@ +import React, { createRef } from 'react'; +import usePatchElement from '../../../utils/hooks/usePatchElement'; +import { withConfirm, withInfo, withSuccess, withWarn, withError } from '../callout'; +import HookModal from './HookModal'; +import { IUseModal, IModalStaticFuncConfig, THookModalRef, IModalStaticFuncReturn } from '../interface'; + +let modalId = 0; + +const useModal: IUseModal = () => { + const [modalElements, patchModalElements] = usePatchElement(); + + const getHookCalloutFnc = (withConfig: (config: IModalStaticFuncConfig) => IModalStaticFuncConfig) => + function hookCallout(config: IModalStaticFuncConfig): IModalStaticFuncReturn { + modalId += 1; + + const hookModalRef = createRef(); + + let handleClose: () => void; + const modal = ( + handleClose()} + /> + ); + handleClose = patchModalElements(modal); + + return { + destroy: () => { + hookModalRef.current?.destroy(); + }, + update: (newConfig: IModalStaticFuncConfig) => { + hookModalRef.current?.update(newConfig); + }, + }; + }; + + return [ + { + confirm: getHookCalloutFnc(withConfirm), + info: getHookCalloutFnc(withInfo), + success: getHookCalloutFnc(withSuccess), + warn: getHookCalloutFnc(withWarn), + error: getHookCalloutFnc(withError), + }, + <>{modalElements}, + ]; +}; + +export default useModal; diff --git a/src/modal/utils.ts b/src/legacy/modal/utils.ts similarity index 100% rename from src/modal/utils.ts rename to src/legacy/modal/utils.ts diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 183d4d4179..dfde3a5f1b 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -5,7 +5,7 @@ import dateRangeSelectorLocale from '../date-range-selector/locales/en-US'; import dateSelectorLocale from '../date-selector/locales/en-US'; import emptyLocale from '../empty/locales/en-US'; import listPickerLocale from '../list-picker/locales/en-US'; -import modalLocale from '../modal/locales/en-US'; +import modalLocale from '../legacy/modal/locales/en-US'; import timePickerLocale from '../time-picker/locales/en-US'; import timeSelectorLocale from '../time-selector/locales/en-US'; import searchBarLocale from '../legacy/search-bar/locales/en-US'; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index b560fa103c..bfa2a19116 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -5,7 +5,7 @@ import dateRangeSelectorLocale from '../date-range-selector/locales/zh-CN'; import dateSelectorLocale from '../date-selector/locales/zh-CN'; import emptyLocale from '../empty/locales/zh-CN'; import listPickerLocale from '../list-picker/locales/zh-CN'; -import modalLocale from '../modal/locales/zh-CN'; +import modalLocale from '../legacy/modal/locales/zh-CN'; import timePickerLocale from '../time-picker/locales/zh-CN'; import timeSelectorLocale from '../time-selector/locales/zh-CN'; import searchBarLocale from '../legacy/search-bar/locales/zh-CN'; diff --git a/src/modal/ConfirmModal.tsx b/src/modal/ConfirmModal.tsx new file mode 100644 index 0000000000..84f63cdeed --- /dev/null +++ b/src/modal/ConfirmModal.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { CloseOutlined } from '@gio-design/icons'; +import Modal from './Modal'; +import { ConfirmModalProps } from './interface'; + +const ConfirmModal = (props: ConfirmModalProps) => { + const { close, content, onOk } = props; + + const handleOk = () => { + onOk?.(); + close({ triggerCancel: true }); + }; + + return ( + close({ triggerCancel: true })} closeIcon={}> + {content} + + ); +}; + +export default ConfirmModal; diff --git a/src/modal/Modal.tsx b/src/modal/Modal.tsx index 2f74d79582..cf93bd24ba 100644 --- a/src/modal/Modal.tsx +++ b/src/modal/Modal.tsx @@ -3,118 +3,104 @@ import classnames from 'classnames'; import { useLocale, usePrefixCls } from '@gio-design/utils'; import RcDialog from 'rc-dialog'; import { CloseOutlined } from '@gio-design/icons'; -import { ButtonProps } from '../legacy/button'; -import { IModalProps, ModalLocale } from './interface'; -import ModalPrefixClsContext from './ModalContext'; -import Title from './Title'; -import Footer from './Footer'; -import defaultLocale from './locales/zh-CN'; +import Button, { IconButton } from '../button'; +import { ModalProps, ModalLocale } from './interface'; -const Modal: React.FC = ({ +const Modal: React.FC = ({ prefixCls: customPrefixCls, - size = 'small', + size = 'normal', className, wrapClassName, - useBack, title, - additionalFooter, - onBack, - closeAfterOk, - dropCloseButton, + confirmLoading, okText: customizeOKText, - closeText: customizeCloseText, + cancelText: customizeCloseText, okButtonProps, closeButtonProps, onOk, onClose, - pending, + closeIcon, + maskClosable = false, ...restProps -}: IModalProps) => { - const prefix = usePrefixCls('modal', customPrefixCls); +}: ModalProps) => { + const prefix = usePrefixCls('modal-new', customPrefixCls); + const modalCls = classnames(className, { + [`${prefix}-normal`]: size === 'normal', + [`${prefix}-fixed`]: size === 'fixed', + [`${prefix}-full`]: size === 'full', + }); + const wrapperCls = classnames(wrapClassName, `${prefix}__wrapper`); + const closeCls = classnames(`${prefix}__close`); + const locale = useLocale('Modal'); const { closeText, okText } = { - ...defaultLocale, ...locale, } as ModalLocale; - const modalCls = classnames(className, { - [`${prefix}--small`]: size === 'small', - [`${prefix}--middle`]: size === 'middle', - [`${prefix}--full`]: size === 'full', - }); - const wrapperCls = classnames(wrapClassName, `${prefix}__wrapper`); - const closeCls = classnames(`${prefix}__close`, { - [`${prefix}__close--disabled`]: pending, - }); + const renderFooter = () => { + const cls = classnames(`${prefix}__footer`); + const closeBtnCls = classnames(`${prefix}__btn-close`, closeButtonProps?.className ?? ''); + const okBtnCls = classnames(`${prefix}__btn-ok`, okButtonProps?.className ?? ''); + const useOkBtn = !!onOk && typeof onOk === 'function'; - const useOkBtn = !!onOk && typeof onOk === 'function'; - let useFooter = useOkBtn || !dropCloseButton || !!additionalFooter; - if ('footer' in restProps && (restProps.footer === false || restProps.footer === null)) { - useFooter = false; - } - const okBtnProps: ButtonProps = { - loading: pending, - disabled: pending, - ...okButtonProps, - }; - const closeBtnProps: ButtonProps = { - disabled: pending, - ...closeButtonProps, + return ( +
+ {restProps.footer || ( +
+ + {useOkBtn && ( + + )} +
+ )} +
+ ); }; - const handleOk = async (e: React.MouseEvent) => { - if (onOk && typeof onOk === 'function') { - e.persist(); - try { - await Promise.resolve(onOk(e)); - if (closeAfterOk) { - onClose?.(e); - } - } catch (error) { - const err = error ?? 'onOk 执行 reject 或抛出错误。'; - console.error(err); - } + const handleClose = (e: React.MouseEvent) => { + if (onClose && typeof onClose === 'function') { + onClose?.(e); } }; - const handleClose = (e: React.SyntheticEvent) => { - if (!pending) { - onClose?.(e as React.MouseEvent); - } - }; return ( - - } - title={title !== false && } - footer={ - useFooter && ( - <Footer - okText={customizeOKText ?? okText} - closeText={customizeCloseText ?? closeText} - okButtonProps={okBtnProps} - closeButtonProps={closeBtnProps} - footer={restProps.footer} - additionalFooter={additionalFooter} - onOk={handleOk} - onClose={handleClose} - useOk={useOkBtn} - useClose={!dropCloseButton} - /> - ) - } - /> - </ModalPrefixClsContext.Provider> + <RcDialog + data-testid="modal" + keyboard + maskClosable={maskClosable} + onClose={handleClose} + prefixCls={prefix} + className={modalCls} + wrapClassName={wrapperCls} + closable={title !== false} + closeIcon={ + closeIcon || ( + <IconButton type="text" size="small"> + <CloseOutlined className={closeCls} /> + </IconButton> + ) + } + title={title} + footer={renderFooter()} + {...restProps} + /> ); }; diff --git a/src/modal/demos/Modal.stories.tsx b/src/modal/demos/Modal.stories.tsx index 85c2b918b5..a6bc6b1b16 100644 --- a/src/modal/demos/Modal.stories.tsx +++ b/src/modal/demos/Modal.stories.tsx @@ -1,27 +1,17 @@ /* eslint-disable no-console */ -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { Story, Meta } from '@storybook/react/types-6-0'; -import { withDesign } from 'storybook-addon-designs'; -import Modal, { ModalProps, StepModalProps, StepModal } from '../index'; -import '../style'; +import Modal from '../index'; import Button from '../../legacy/button'; -import { ConfigContext } from '../../components/config-provider'; -import { IModalStaticFuncConfig } from '../interface'; +import { ModalProps, IModalStaticFuncConfig } from '../interface'; import Docs from './ModalPage'; +import '../style'; export default { - title: 'Components/Modal', + title: 'Upgraded/Modal', component: Modal, - decorators: [withDesign], - parameters: { - design: { - type: 'figma', - url: 'https://www.figma.com/file/kP3A6S2fLUGVVMBgDuUx0f/GrowingIO-Design-Components?node-id=889%3A6757', - allowFullscreen: true, - }, - docs: { - page: Docs, - }, + docs: { + page: Docs, }, } as Meta; @@ -36,196 +26,126 @@ const Template: Story<ModalProps> = (args) => { onClose={() => { setVisible(false); }} + onOk={() => { + setVisible(false); + }} > - Default Modal + 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 + 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 宽度自动撑开 </Modal> </div> ); }; -export const Default = Template.bind({}); -Default.args = { - title: 'title', +export const AdaptiveWidthDemo = Template.bind({}); +AdaptiveWidthDemo.args = { + title: '弹窗标题', }; -const CustomHeightTemplate: Story<ModalProps> = (args) => { +const FixedTemplate: Story<ModalProps> = (args) => { const [visible, setVisible] = useState(false); return ( <div> <Button onClick={() => setVisible(true)}>Open Modal</Button> <Modal {...args} - style={{ top: 100, width: 500, margin: '0 auto' }} - bodyStyle={{ height: 200 }} visible={visible} onClose={() => { setVisible(false); }} + onOk={() => { + setVisible(false); + }} > - {'Custom Height '.repeat(40)} + 宽度固定500 宽度固定500 宽度固定500 宽度固定500 宽度固定500 宽度固定500 宽度固定500 宽度固定500 </Modal> </div> ); }; -export const CustomHeight = CustomHeightTemplate.bind({}); -CustomHeight.args = { - title: 'title', + +export const FixedWidthDemo = FixedTemplate.bind({}); +FixedWidthDemo.args = { + title: '弹窗标题', + size: 'fixed', +}; + +export const FullModal = Template.bind({}); +FullModal.args = { + title: '弹窗标题', + size: 'full', }; -const StepModalTemplate: Story<StepModalProps> = (args) => { - // eslint-disable-next-line react-hooks/rules-of-hooks +const HeightOverflowModalTemplate: Story<ModalProps> = (args) => { const [visible, setVisible] = useState(false); - const steps = [ - { - key: '1', - content: '这是内容1', - return: null, - }, - { - key: '2', - content: '这是内容2', - return: '1', - }, - { - key: '3', - content: '这是内容3', - return: '2', - }, - ]; return ( - <> - <Button onClick={() => setVisible(true)}>Open StepModal</Button> - <StepModal + <div> + <Button onClick={() => setVisible(true)}>Open Modal</Button> + <Modal {...args} visible={visible} - title="操作" - onOk={() => setVisible(false)} - steps={steps} - onClose={() => setVisible(false)} - /> - </> + onClose={() => { + setVisible(false); + }} + onOk={() => { + setVisible(false); + }} + > + <div style={{ height: '2000px', background: '#E3FFF0' }}> + HeightOverflowModalTemplate HeightOverflowModalTemplate + </div> + </Modal> + </div> ); }; -export const StepModalDemo = StepModalTemplate.bind({}); -StepModalDemo.args = { - title: '操作', +export const HeightOverflowModal = HeightOverflowModalTemplate.bind({}); +HeightOverflowModal.args = { + title: '弹窗标题', + size: 'fixed', }; -const buttonStyle = { - marginRight: 10, +const ConfirmTemplate: Story<ModalProps> = () => ( + <div> + <Button + onClick={() => { + Modal.open({ + title: '弹窗标题', + content: 'Some descriptions', + size: 'fixed', + onOk() { + console.log('OK'); + }, + onClose() { + console.log('Cancel'); + }, + }); + }} + > + Open Modal + </Button> + </div> +); +export const OpenModal = ConfirmTemplate.bind({}); +OpenModal.args = { + title: '弹窗标题', }; -const FunctionModalTemplate: Story<IModalStaticFuncConfig> = (args) => { - const handleInfo = () => { - Modal.info({ - ...args, - title: 'Info', - content: 'Info content', - }); - }; - - const handleSuccess = () => { - Modal.success({ - ...args, - title: 'Success', - content: 'Success content', - }); - }; - - const handleWarn = () => { - Modal.warn({ - ...args, - title: 'Warn', - content: 'Warn content', - }); - }; - - const handleError = () => { - Modal.error({ - ...args, - title: 'Error', - content: 'Error content', - }); - }; - return ( - <> - <Button type="secondary" style={buttonStyle} onClick={() => handleInfo()}> - Info - </Button> - <Button type="secondary" style={buttonStyle} onClick={() => handleSuccess()}> - Success - </Button> - <Button type="secondary" style={buttonStyle} onClick={() => handleWarn()}> - Warn - </Button> - <Button type="secondary" style={buttonStyle} onClick={() => handleError()}> - Error - </Button> - </> - ); -}; - -export const FunctionModal = FunctionModalTemplate.bind({}); -FunctionModal.args = {}; const UseModalTemplate: Story<IModalStaticFuncConfig> = (args) => { const [modalFuncs, hookModal] = Modal.useModal(); const handleConfirm = () => { - modalFuncs.confirm({ + modalFuncs.open({ ...args, - title: 'Confirm', + title: '弹窗标题', content: 'Confirm content', }); }; - const handleInfo = () => { - modalFuncs.info({ - ...args, - title: 'Info', - content: 'Info content', - }); - }; - const handleSuccess = () => { - modalFuncs.success({ - ...args, - title: 'Success', - content: 'Success content', - }); - }; - const handleWarn = () => { - modalFuncs.warn({ - ...args, - title: 'Warn', - content: 'Warn content', - }); - }; - const handleError = () => { - modalFuncs.error({ - ...args, - title: 'Error', - content: 'Error content', - }); - }; - const context = useContext(ConfigContext); + return ( - <ConfigContext.Provider value={{ ...context, rootPrefixCls: 'gio' }}> - <> - <Button type="secondary" style={buttonStyle} onClick={() => handleConfirm()}> - confirm - </Button> - <Button type="secondary" style={buttonStyle} onClick={() => handleInfo()}> - Info - </Button> - <Button type="secondary" style={buttonStyle} onClick={() => handleSuccess()}> - Success - </Button> - <Button type="secondary" style={buttonStyle} onClick={() => handleWarn()}> - Warn - </Button> - <Button type="secondary" style={buttonStyle} onClick={() => handleError()}> - Error - </Button> - {hookModal} - </> - </ConfigContext.Provider> + <> + <Button type="secondary" onClick={() => handleConfirm()}> + open + </Button> + {hookModal} + </> ); }; diff --git a/src/modal/demos/ModalPage.tsx b/src/modal/demos/ModalPage.tsx index c3280014f5..ec079ba122 100644 --- a/src/modal/demos/ModalPage.tsx +++ b/src/modal/demos/ModalPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Canvas, Title, Heading, Story, Subheading, ArgsTable } from '@storybook/addon-docs'; import { useIntl } from 'react-intl'; -import Modal from '../Modal'; +import Modal from '../index'; export default function ListPage() { const { formatMessage } = useIntl(); diff --git a/src/modal/index.tsx b/src/modal/index.tsx index 29148b1ccd..c40af4d64c 100644 --- a/src/modal/index.tsx +++ b/src/modal/index.tsx @@ -1,47 +1,32 @@ -import GioModal from './Modal'; -import StepModal from './StepModal'; -import callout, { configModal, withConfirm, withInfo, withSuccess, withWarn, withError } from './callout'; +import OriginalModal from './Modal'; +import open, { destroyFns } from './open'; import useModal from './useModal'; -import { IModalStaticFuncConfig, IModalStaticFunctions, IUseModal, IModalConfigs } from './interface'; - -export { - IModalProps as ModalProps, - IStepModalProps as StepModalProps, - TModalSize as ModalSize, - IStep as Step, - TStepChange as StepChange, - IModalStaticFuncConfig as ModalStaticFuncConfig, - IModalStaticFuncReturn as ModalStaticFuncReturn, - TModalStaticFuncType as ModalStaticFuncType, - IModalStaticFunc as ModalStaticFunc, - IModalConfigs as ModalConfigs, -} from './interface'; - -export { StepModal, useModal }; - -export type TModal = typeof GioModal & + +import { ModalFuncProps, IUseModal, IModalStaticFunctions } from './interface'; + +type ModalType = typeof OriginalModal & IModalStaticFunctions & { - config: (configs: IModalConfigs) => void; + destroyAll: () => void; useModal: IUseModal; - StepModal: typeof StepModal; }; -const Modal = GioModal as TModal; - -Modal.confirm = (config: IModalStaticFuncConfig) => callout(withConfirm(config)); - -Modal.info = (config: IModalStaticFuncConfig) => callout(withInfo(config)); - -Modal.success = (config: IModalStaticFuncConfig) => callout(withSuccess(config)); - -Modal.warn = (config: IModalStaticFuncConfig) => callout(withWarn(config)); +const Modal = OriginalModal as ModalType; -Modal.error = (config: IModalStaticFuncConfig) => callout(withError(config)); +Modal.open = function confirmFn(props: ModalFuncProps) { + return open(props); +}; Modal.useModal = useModal; -Modal.config = configModal; +Modal.destroyAll = function destroyAllFn() { + while (destroyFns.length) { + const close = destroyFns.pop(); + if (close) { + close(); + } + } +}; -Modal.StepModal = StepModal; +export type { ModalProps } from './interface'; export default Modal; diff --git a/src/modal/interface.ts b/src/modal/interface.ts index 38642c20d8..d4dc86195c 100644 --- a/src/modal/interface.ts +++ b/src/modal/interface.ts @@ -1,226 +1,113 @@ -import { ReactElement, ReactNode, CSSProperties } from 'react'; -import { ButtonProps } from '../legacy/button'; +import { ReactElement, ReactNode } from 'react'; +import { ButtonProps, ButtonType } from '../button'; -export type IStringOrHtmlElement = string | HTMLElement; - -export type TModalSize = 'small' | 'middle' | 'full'; - -export type TStepNoParamFn = () => void | Promise<unknown>; +export type TModalSize = 'normal' | 'fixed' | 'full'; export type ModalLocale = { okText: string; closeText: string; }; -export interface ITitleProps { - title?: ReactNode; - useBack?: boolean; - onBack?: TStepNoParamFn; +type getContainerFunc = () => HTMLElement; + +export interface ConfirmModalProps extends ModalFuncProps { + afterClose?: () => void; + close: (...args: any[]) => void; + autoFocusButton?: null | 'ok' | 'cancel'; + iconPrefixCls?: string; } -export interface IFooterProps { - // 可以自定义除了 okButton 以及 closeBtn 外的组件 - additionalFooter?: ReactNode; - // 完全自定义 Footer 区域 - footer?: ReactNode; +export interface ModalProps { + /** 对话框是否可见 */ + visible?: boolean; + /** 确定按钮 loading */ + confirmLoading?: boolean; + /** 标题 */ + title?: React.ReactNode | string; + /** 是否显示右上角的关闭按钮 */ + closable?: boolean; + /** 点击确定回调 */ + onOk?: (e: React.MouseEvent<HTMLElement>) => void; + /** 点击模态框右上角叉、取消按钮、Props.maskClosable 值为 true 时的遮罩层或键盘按下 Esc 时的回调 */ + onClose?: (e: React.MouseEvent<HTMLElement>) => void; + afterClose?: () => void; + /** 垂直居中 */ + centered?: boolean; + /** 宽度 */ + width?: string | number; + /** 底部内容 */ + footer?: React.ReactNode; + /** 确认按钮文字 */ + okText?: React.ReactNode; + /** 确认按钮类型 */ + okType?: ButtonType; + /** 取消按钮文字 */ + cancelText?: React.ReactNode; + /** 点击蒙层是否允许关闭 */ + maskClosable?: boolean; + /** 强制渲染 Modal */ + forceRender?: boolean; + /** 大小 */ + size: TModalSize; okButtonProps?: ButtonProps; closeButtonProps?: ButtonProps; - okText?: string; - closeText?: string; - onOk?: (e: React.MouseEvent<HTMLElement>) => void | Promise<unknown>; - onClose?: (e: React.MouseEvent<HTMLElement>) => void | Promise<unknown>; - useOk: boolean; - useClose: boolean; -} - -export interface IModalProps extends ITitleProps, Omit<IFooterProps, 'useOk' | 'useClose'> { - /** - 替代 `Modal` 组件 `class` 的 `gio-modal` 前缀 - */ - prefixCls?: string; - /** - `Modal` 根节点 `className` - */ - className?: string; - /** - `Modal` `wrap` 的 `className` - */ + destroyOnClose?: boolean; + style?: React.CSSProperties; wrapClassName?: string; - /** - `Modal` 根节点的样式 - */ - style?: CSSProperties; - /** - `Modal` `wrap` 内联样式 - */ - wrapStyle?: Record<string, unknown>; - /** - `Modal` `body` 内联样式 - */ - bodyStyle?: Record<string, unknown>; - /** - `Modal` `mask` 内联样式 - */ - maskStyle?: Record<string, unknown>; - /** - `Modal` `body` `props` - */ - bodyProps?: Record<string, unknown>; - /** - `Modal` `mask` `props` - */ - maskProps?: Record<string, unknown>; - /** - `Modal wrap props` - */ - wrapProps?: Record<string, unknown>; - /** - `Modal` 层级 - */ + maskTransitionName?: string; + transitionName?: string; + className?: string; + getContainer?: string | HTMLElement | getContainerFunc | false; zIndex?: number; - /** - `Modal` 右上角关闭 `Icon` - */ - closeIcon?: ReactNode; - /** - 被包裹的元素 - */ - children?: ReactNode; - /** - 是否支持按 ESC 关闭 Modal - */ + bodyStyle?: React.CSSProperties; + maskStyle?: React.CSSProperties; + mask?: boolean; keyboard?: boolean; - /** - `Modal` 的尺寸 - */ - size?: TModalSize; - /** - `Modal` 是否可见 - */ - visible?: boolean; - /** - 是否不使用 `Footer` 中的关闭按钮 - */ - dropCloseButton?: boolean; - /** - 组件 `pending` 状态 - */ - pending?: boolean; - /** - 执行 `close` 后紧接着执行的操作 - */ - afterClose?: () => void; - /** - `Modal` `onOk` 执行后是否执行 `onClose` - */ - closeAfterOk?: boolean; - /** - `Modal` `onClose` 执行后是否卸载 `Modal` 组件 - */ - destroyOnClose?: boolean; - getContainer?: IStringOrHtmlElement | (() => HTMLElement) | false; - forceRender?: boolean; + prefixCls?: string; + closeIcon?: React.ReactNode; + modalRender?: (node: React.ReactNode) => React.ReactNode; focusTriggerAfterClose?: boolean; } -export type TStepChange = (nextStep: string) => void; - -export interface IStepModalNodeRenderProps { - step: IStep; - push: TStepChange; - pop: TStepNoParamFn; -} - -export type TModalNodeRender = ReactNode | ((renderProps: IStepModalNodeRenderProps) => ReactNode); - -export interface IStep { - /** - 当前 `Step` 的唯一标识 - */ - key: string; - /** - 当前 Step 的上一步 - */ - return: string | null; - /** - 多分支路径下,当前步骤是否是默认的下一步 - */ - firstNextInTier?: boolean; - /** - 多分支路径下的出口标识 - */ - wayout?: boolean; - /** - 下一步 回调 - */ - onNext?: TStepNoParamFn; - /** - 上一步 回调 - */ - onBack?: TStepNoParamFn; - /** - 当前步骤 `Modal` 的 `Title` - */ - title?: TModalNodeRender; - /** - 当前步骤 `Modal` 的 `Body` - */ - content?: TModalNodeRender; - /** - 当前步骤 `Modal` 的 `Footer` - */ - footer?: TModalNodeRender; - /** - 除了 `OkButton` 及 `CloseButton` 外的自定义 `Footer` - */ - additionalFooter?: ReactNode; - /** - 传递给下一步按钮的 props - */ - nextButtonProps?: ButtonProps; - /** - 传递给取消按钮的 `props` 元素 - */ +export interface ModalFuncProps { + prefixCls?: string; + className?: string; + visible?: boolean; + title?: React.ReactNode; + closable?: boolean; + content?: React.ReactNode; + size: TModalSize; + // TODO: find out exact types + onOk?: (...args: any[]) => any; + onClose?: (...args: any[]) => any; + afterClose?: () => void; + okButtonProps?: ButtonProps; cancelButtonProps?: ButtonProps; - /** - 传递给上一步按钮的 `props` 元素 - @deprecated - */ - backButtonProps?: ButtonProps; - /** - 传递给下一步按钮的显示文案 - */ - nextText?: string; - /** - 传递给取消按钮的显示文案 - */ - cancelText?: string; - /** - 传递给上一步按钮的显示文案 - @deprecated - */ - backText?: string; -} - -export interface IStepInner extends IStep { - next?: string[]; -} - -export interface IStepMap { - [key: string]: IStepInner; -} - -export interface IStepModalProps extends Omit<IModalProps, 'pending'> { - steps?: IStep[]; - onStepChange?: (step: string) => void; + centered?: boolean; + width?: string | number; + okText?: React.ReactNode; + okType?: ButtonType; + cancelText?: React.ReactNode; + icon?: React.ReactNode; + mask?: boolean; + maskClosable?: boolean; + zIndex?: number; + okCancel?: boolean; + style?: React.CSSProperties; + maskStyle?: React.CSSProperties; + keyboard?: boolean; + getContainer?: string | HTMLElement | getContainerFunc | false; + autoFocusButton?: null | 'ok' | 'cancel'; + transitionName?: string; + maskTransitionName?: string; + bodyStyle?: React.CSSProperties; + closeIcon?: React.ReactNode; + modalRender?: (node: React.ReactNode) => React.ReactNode; + focusTriggerAfterClose?: boolean; } -export type TModalStaticFuncType = 'confirm' | 'info' | 'success' | 'warn' | 'error'; - -export interface IModalStaticFuncConfig extends Omit<IModalProps, 'visible' | 'onOk' | 'onClose' | 'pending'> { +export interface IModalStaticFuncConfig extends Omit<ModalProps, 'visible' | 'onOk' | 'onClose' | 'pending'> { content?: ReactNode; - // 函数式调用类型 - type?: TModalStaticFuncType; // 函数式调用时的前缀 icon icon?: ReactNode; // 是否显示取消按钮 @@ -234,23 +121,12 @@ export interface IModalStaticFuncReturn { update: (config: IModalStaticFuncConfig) => void; } -export interface ICalloutModalProps extends IModalStaticFuncConfig { - visible: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - close: (...args: any[]) => void; - afterClose?: () => void; -} - export interface IModalStaticFunc { (config: IModalStaticFuncConfig): IModalStaticFuncReturn; } export interface IModalStaticFunctions { - info: IModalStaticFunc; - success: IModalStaticFunc; - error: IModalStaticFunc; - warn: IModalStaticFunc; - confirm: IModalStaticFunc; + open: IModalStaticFunc; } export type THookModalRef = IModalStaticFuncReturn; diff --git a/src/modal/open.tsx b/src/modal/open.tsx new file mode 100644 index 0000000000..94c9dd6a98 --- /dev/null +++ b/src/modal/open.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import type { ModalFuncProps, ConfirmModalProps } from './interface'; +import ConfirmModal from './ConfirmModal'; + +export const destroyFns: Array<() => void> = []; + +type ConfigUpdate = ModalFuncProps | ((prevConfig: ModalFuncProps) => ModalFuncProps); + +export type ModalFunc = (props: ModalFuncProps) => { + destroy: () => void; + update: (configUpdate: ConfigUpdate) => void; +}; + +export default function open(config: ModalFuncProps) { + const div = document.createElement('div'); + document.body.appendChild(div); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + let currentConfig = { ...config, close, visible: true } as any; + + function destroy(...args: any[]) { + const unmountResult = ReactDOM.unmountComponentAtNode(div); + if (unmountResult && div.parentNode) { + div.parentNode.removeChild(div); + } + const triggerCancel = args.some((param) => param && param.triggerCancel); + if (config.onClose && triggerCancel) { + config.onClose(...args); + } + for (let i = 0; i < destroyFns.length; i += 1) { + const fn = destroyFns[i]; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + if (fn === close) { + destroyFns.splice(i, 1); + break; + } + } + } + + function render({ okText, cancelText, prefixCls, ...props }: ConfirmModalProps) { + setTimeout(() => { + ReactDOM.render(<ConfirmModal {...props} prefixCls={prefixCls} okText={okText} cancelText={cancelText} />, div); + }); + } + + function close(...args: any[]) { + currentConfig = { + ...currentConfig, + visible: false, + afterClose: () => { + if (typeof config.afterClose === 'function') { + config.afterClose(); + } + destroy.apply(this, args); + }, + }; + render(currentConfig); + } + + function update(configUpdate: ConfigUpdate) { + if (typeof configUpdate === 'function') { + currentConfig = configUpdate(currentConfig); + } else { + currentConfig = { + ...currentConfig, + ...configUpdate, + }; + } + render(currentConfig); + } + + render(currentConfig); + + destroyFns.push(close); + + return { + destroy: close, + update, + }; +} diff --git a/src/modal/style/index.less b/src/modal/style/index.less index a7ee4ea1cc..18244f39d0 100644 --- a/src/modal/style/index.less +++ b/src/modal/style/index.less @@ -1,3 +1,183 @@ -@import './modal.less'; -@import './mask.less'; -@import './callout.less'; +@import '../../stylesheet/index.less'; +@import '../../stylesheet/mixin/animation.less'; +@import '../../stylesheet/mixin/index.less'; +@import '../../stylesheet/variables/index.less'; + +@modal-prefix-cls: ~'@{component-prefix}-modal-new'; +@modal-normal: ~'@{modal-prefix-cls}-normal'; +@modal-fixed: ~'@{modal-prefix-cls}-fixed'; +@modal-full: ~'@{modal-prefix-cls}-full'; +@modal-callout: ~'@{modal-prefix-cls}-callout'; +@btn-cls: ~'@{component-prefix}-btn'; + +.@{modal-prefix-cls} { + @modal-radius: 8px; + position: relative; + margin: 100px auto; + + &-mask { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: @z-index-modal; + height: 100%; + background-color: @gray-5; + opacity: 0.8; + + &-hidden { + display: none; + } + } + + .btn-style() { + display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + color: @gray-5; + background-color: @gray-0; + border: 0; + border-radius: 50%; + outline: 0; + cursor: pointer; + } + + &-wrap { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: @z-index-modal; + overflow: hidden; + outline: 0; + -webkit-overflow-scrolling: touch; + } + + &-header { + padding: 20px 36px; + background: @gray-0; + border-radius: @modal-radius @modal-radius 0 0; + } + + &-footer { + padding: 20px 36px; + text-align: right; + border-radius: 0 0 @modal-radius @modal-radius; + } + + &__footer { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + + .@{modal-prefix-cls}__btn-close { + margin-right: 8px; + } + } + + &-title { + position: relative; + overflow: hidden; + color: @gray-5; + font-weight: 500; + font-size: 16px; + line-height: 24px; + white-space: nowrap; + text-align: center; + text-overflow: ellipsis; + } + + &-content { + position: relative; + display: flex; + flex-direction: column; + min-height: 190px; + @modal-full-height: 100vh; + max-height: calc(~'@{modal-full-height} - 200px'); + background-color: @gray-0; + background-clip: padding-box; + border: 1px solid @gray-2; + .elevation(2); + border-radius: 8px; + } + + &-close { + position: absolute; + top: 24px; + right: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + color: @gray-5; + background-color: @gray-0; + border: 0; + border-radius: 50%; + outline: 0; + cursor: pointer; + } + + &__close-disabled { + color: @gray-0; + cursor: not-allowed; + } + + &-body { + margin: auto; + padding: 8px 36px; + overflow-y: auto; + color: @gray-5; + + &:last-child { + margin-bottom: 40px; + } + } +} + +.@{modal-normal} { + width: fit-content; + min-width: 500px; +} +.@{modal-fixed} { + width: 500px; +} +.@{modal-full} { + .full-screen-modal(); +} + +.full-screen-modal() { + width: 100%; + height: 100%; + margin: 0; + + .@{modal-prefix-cls} { + &-content { + height: 100%; + max-height: none; + } + + &-body { + flex: 1; + max-width: 1320px; + } + + &-header, + &-footer { + flex-shrink: 0; + } + } +} + +.effect() { + animation-duration: 0.2s; + animation-fill-mode: both; +} diff --git a/src/modal/useModal/hookModal.tsx b/src/modal/useModal/hookModal.tsx new file mode 100644 index 0000000000..3129a3a824 --- /dev/null +++ b/src/modal/useModal/hookModal.tsx @@ -0,0 +1,26 @@ +import React, { forwardRef, useImperativeHandle, useState } from 'react'; +import ConfirmModal from '../ConfirmModal'; +import { IHookModalProps, THookModalRef, IModalStaticFuncConfig } from '../interface'; + +const defaultConfig = { okText: '确定', closeText: '取消' }; + +const HookModal: React.ForwardRefRenderFunction<THookModalRef, IHookModalProps> = ({ config, afterClose }, ref) => { + const [visible, setVisible] = useState(true); + const [mergedConfig, setMergedConfig] = useState({ ...defaultConfig, ...config }); + + const handleClose = () => setVisible(false); + + useImperativeHandle(ref, () => ({ + destroy: handleClose, + update: (newConfig: IModalStaticFuncConfig) => { + setMergedConfig((originMergedConfig) => ({ + ...originMergedConfig, + ...newConfig, + })); + }, + })); + + return <ConfirmModal visible={visible} close={handleClose} {...mergedConfig} afterClose={afterClose} />; +}; + +export default forwardRef(HookModal); diff --git a/src/modal/useModal/index.tsx b/src/modal/useModal/index.tsx index fc8a9b203d..3356319170 100644 --- a/src/modal/useModal/index.tsx +++ b/src/modal/useModal/index.tsx @@ -1,48 +1,37 @@ import React, { createRef } from 'react'; import usePatchElement from '../../utils/hooks/usePatchElement'; -import { withConfirm, withInfo, withSuccess, withWarn, withError } from '../callout'; -import HookModal from './HookModal'; -import { IUseModal, IModalStaticFuncConfig, THookModalRef, IModalStaticFuncReturn } from '../interface'; +import HookModal from './hookModal'; +import { IUseModal, IModalStaticFuncConfig, THookModalRef } from '../interface'; let modalId = 0; const useModal: IUseModal = () => { const [modalElements, patchModalElements] = usePatchElement(); - const getHookCalloutFnc = (withConfig: (config: IModalStaticFuncConfig) => IModalStaticFuncConfig) => - function hookCallout(config: IModalStaticFuncConfig): IModalStaticFuncReturn { - modalId += 1; - - const hookModalRef = createRef<THookModalRef>(); - - let handleClose: () => void; - const modal = ( - <HookModal - key={`hook-modal-${modalId}`} - ref={hookModalRef} - config={withConfig(config)} - afterClose={() => handleClose()} - /> - ); - handleClose = patchModalElements(modal); - - return { - destroy: () => { - hookModalRef.current?.destroy(); - }, - update: (newConfig: IModalStaticFuncConfig) => { - hookModalRef.current?.update(newConfig); - }, - }; + const getHookCalloutFnc = (config: IModalStaticFuncConfig) => { + modalId += 1; + + const hookModalRef = createRef<THookModalRef>(); + + let handleClose: () => void; + const modal = ( + <HookModal key={`hook-modal-${modalId}`} ref={hookModalRef} config={config} afterClose={() => handleClose()} /> + ); + handleClose = patchModalElements(modal); + + return { + destroy: () => { + hookModalRef.current?.destroy(); + }, + update: (newConfig: IModalStaticFuncConfig) => { + hookModalRef.current?.update(newConfig); + }, }; + }; return [ { - confirm: getHookCalloutFnc(withConfirm), - info: getHookCalloutFnc(withInfo), - success: getHookCalloutFnc(withSuccess), - warn: getHookCalloutFnc(withWarn), - error: getHookCalloutFnc(withError), + open: getHookCalloutFnc, }, <>{modalElements}</>, ]; diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts index 2e5a03836c..1f794151e3 100644 --- a/src/typings/index.d.ts +++ b/src/typings/index.d.ts @@ -1,5 +1,6 @@ declare module 'rc-notification'; declare module 'rc-upload'; +declare module 'react-modal'; declare module 'rc-util/lib/guid'; declare module '*.png'; declare module '*.svg'; diff --git a/yarn.lock b/yarn.lock index f1bd6d04e3..1617494991 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13779,7 +13779,7 @@ rc-animate@2.x: rc-util "^4.15.3" react-lifecycles-compat "^3.0.4" -rc-animate@3.x, rc-animate@^3.0.0, rc-animate@^3.1.0: +rc-animate@^3.0.0, rc-animate@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/rc-animate/-/rc-animate-3.1.0.tgz" dependencies: @@ -13808,12 +13808,15 @@ rc-checkbox@^2.2.0: babel-runtime "^6.23.0" classnames "2.x" -rc-dialog@^8.1.0: - version "8.1.0" - resolved "https://registry.npm.taobao.org/rc-dialog/download/rc-dialog-8.1.0.tgz?cache=0&sync_timestamp=1594264518864&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frc-dialog%2Fdownload%2Frc-dialog-8.1.0.tgz" +rc-dialog@^8.6.0: + version "8.6.0" + resolved "https://registry.nlark.com/rc-dialog/download/rc-dialog-8.6.0.tgz#3b228dac085de5eed8c6237f31162104687442e7" + integrity sha1-OyKNrAhd5e7YxiN/MRYhBGh0Quc= dependencies: - rc-animate "3.x" - rc-util "^5.0.1" + "@babel/runtime" "^7.10.1" + classnames "^2.2.6" + rc-motion "^2.3.0" + rc-util "^5.6.1" rc-drawer@^4.1.0: version "4.4.2" @@ -13866,6 +13869,15 @@ rc-motion@^2.0.0, rc-motion@^2.0.1, rc-motion@^2.2.0: classnames "^2.2.1" rc-util "^5.2.1" +rc-motion@^2.3.0: + version "2.4.4" + resolved "https://registry.nlark.com/rc-motion/download/rc-motion-2.4.4.tgz#e995d5fa24fc93065c24f714857cf2677d655bb0" + integrity sha1-6ZXV+iT8kwZcJPcUhXzyZ31lW7A= + dependencies: + "@babel/runtime" "^7.11.1" + classnames "^2.2.1" + rc-util "^5.2.1" + rc-notification@^4.5.4: version "4.5.4" resolved "https://registry.npmjs.org/rc-notification/-/rc-notification-4.5.4.tgz#1292e163003db4b9162c856a4630e5d0f1359356" @@ -14026,7 +14038,7 @@ rc-util@^5.0.0, rc-util@^5.0.1, rc-util@^5.0.5, rc-util@^5.0.6, rc-util@^5.0.7, react-is "^16.12.0" shallowequal "^1.1.0" -rc-util@^5.14.0, rc-util@^5.8.0: +rc-util@^5.14.0, rc-util@^5.6.1, rc-util@^5.8.0: version "5.14.0" resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.14.0.tgz#52c650e27570c2c47f7936c7d32eaec5212492a8" integrity sha512-2vy6/Z1BJUcwLjm/UEJb/htjUTQPigITUIemCcFEo1fQevAumc9sA32x2z5qyWoa9uhrXbiAjSDpPIUqyg65sA==