diff --git a/package.json b/package.json index ceef0e4af..a565fe279 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "loadsh": "^0.0.4", "monaco-editor": "^0.21.2", "rc-collapse": "^2.0.1", + "rc-dialog": "^8.4.5", "rc-tree": "^3.10.0", "react": "^16.13.1", "react-dnd": "^9.3.4", diff --git a/src/components/button/index.tsx b/src/components/button/index.tsx index e8b166bda..d352eefc3 100644 --- a/src/components/button/index.tsx +++ b/src/components/button/index.tsx @@ -4,9 +4,7 @@ import { classNames, prefixClaName } from 'mo/common/className'; type BtnSizeType = 'normal' | 'large'; export interface IButton extends React.ComponentProps<'a'> { - /** - * Default size is normal - */ + disabled?: boolean; size?: BtnSizeType; } @@ -14,11 +12,12 @@ export const defaultButtonClassName = 'btn'; export function Button(props: React.PropsWithChildren) { const { className, children, size = 'normal', ...others } = props; - + const disabled = props.disabled ? 'disabled' : null; const claNames = classNames( prefixClaName(defaultButtonClassName), size, - className + className, + disabled ); return ( diff --git a/src/components/button/style.scss b/src/components/button/style.scss index 2e28aaf8a..4213bbd46 100644 --- a/src/components/button/style.scss +++ b/src/components/button/style.scss @@ -22,6 +22,10 @@ $btn: 'btn'; padding: 8px; } + &.disabled { + cursor: not-allowed; + } + &:hover { opacity: 0.9; } diff --git a/src/components/dialog/ActionButton.tsx b/src/components/dialog/ActionButton.tsx new file mode 100644 index 000000000..96de02313 --- /dev/null +++ b/src/components/dialog/ActionButton.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { Button, IButton } from 'mo/components/button'; + +export interface ActionButtonProps { + actionFn?: (...args: any[]) => any | PromiseLike; + closeModal: Function; + autoFocus?: boolean; + buttonProps?: IButton; +} + +const ActionButton: React.FC = props => { + const clickedRef = React.useRef(false); + const ref = React.useRef(); + + React.useEffect(() => { + let timeoutId: number; + if (props.autoFocus) { + const $this = ref.current as HTMLInputElement; + timeoutId = setTimeout(() => $this.focus()); + } + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, []); + + const handlePromiseOnOk = (returnValueOfOnOk?: PromiseLike) => { + const { closeModal } = props; + if (!returnValueOfOnOk || !returnValueOfOnOk.then) { + return; + } + returnValueOfOnOk.then( + (...args: any[]) => { + // It's unnecessary to set loading=false, for the Modal will be unmounted after close. + closeModal(...args); + }, + (e: Error) => { + // eslint-disable-next-line no-console + console.error(e); + clickedRef.current = false; + }, + ); + }; + + const onClick = () => { + const { actionFn, closeModal } = props; + if (clickedRef.current) { + return; + } + clickedRef.current = true; + if (!actionFn) { + closeModal(); + return; + } + let returnValueOfOnOk; + if (actionFn.length) { + returnValueOfOnOk = actionFn(closeModal); + clickedRef.current = false; + } else { + returnValueOfOnOk = actionFn(); + if (!returnValueOfOnOk) { + closeModal(); + return; + } + } + handlePromiseOnOk(returnValueOfOnOk); + }; + + const { children, buttonProps } = props; + return ( + + ); +}; + +export default ActionButton; \ No newline at end of file diff --git a/src/components/dialog/ConfirmDialog.tsx b/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 000000000..cbf4b8681 --- /dev/null +++ b/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import Dialog, { IModalFuncProps } from './Modal'; +import ActionButton from './ActionButton'; + +interface ConfirmDialogProps extends IModalFuncProps { + afterClose?: () => void; + close: (...args: any[]) => void; + autoFocusButton?: null | 'ok' | 'cancel'; +} + +const ConfirmDialog = (props: ConfirmDialogProps) => { + const { + icon, + onCancel, + onOk, + close, + zIndex, + afterClose, + visible, + keyboard, + centered, + getContainer, + maskStyle, + okText, + okButtonProps, + cancelText, + cancelButtonProps, + prefixCls, + bodyStyle, + closable = false, + closeIcon, + modalRender, + focusTriggerAfterClose, + } = props; + const contentPrefixCls = `${prefixCls}-confirm`; + // 默认为 true,保持向下兼容 + const okCancel = 'okCancel' in props ? props.okCancel! : true; + const width = props.width || 416; + const style = props.style || {}; + const mask = props.mask === undefined ? true : props.mask; + // 默认为 false,保持旧版默认行为 + const maskClosable = props.maskClosable === undefined ? false : props.maskClosable; + const autoFocusButton = props.autoFocusButton === null ? false : props.autoFocusButton || 'ok'; + const transitionName = props.transitionName || 'zoom'; + const maskTransitionName = props.maskTransitionName || 'fade'; + + const classString = classNames( + contentPrefixCls, + `${contentPrefixCls}-${props.type}`, + props.className, + ); + + const cancelButton = okCancel && ( + + {cancelText} + + ); + + return ( + close({ triggerCancel: true })} + visible={visible} + title="" + transitionName={transitionName} + footer="" + maskTransitionName={maskTransitionName} + mask={mask} + maskClosable={maskClosable} + maskStyle={maskStyle} + style={style} + width={width} + zIndex={zIndex} + afterClose={afterClose} + keyboard={keyboard} + centered={centered} + getContainer={getContainer} + closable={closable} + closeIcon={closeIcon} + modalRender={modalRender} + focusTriggerAfterClose={focusTriggerAfterClose} + > +
+
+ {icon} + {props.title === undefined ? null : ( + {props.title} + )} +
{props.content}
+
+
+ {cancelButton} + + {okText} + +
+
+
+ ); +}; + +export default ConfirmDialog; \ No newline at end of file diff --git a/src/components/dialog/Modal.tsx b/src/components/dialog/Modal.tsx new file mode 100644 index 000000000..ac36c13c2 --- /dev/null +++ b/src/components/dialog/Modal.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import Dialog from 'rc-dialog'; +import { IDialogPropTypes } from 'rc-dialog/lib/IDialogPropTypes'; + +import { classNames, prefixClaName } from 'mo/common/className'; + +import { Button, IButton } from 'mo/components/button'; + +let mousePosition + +const getClickPosition = (e: MouseEvent) => { + mousePosition = { + x: e.pageX, + y: e.pageY, + }; + setTimeout(() => { + mousePosition = null; + }, 100); +}; + +// 只有点击事件支持从鼠标位置动画展开 +if (typeof window !== 'undefined' && window.document?.documentElement) { + document.documentElement.addEventListener('click', getClickPosition, true); +} + +export const destroyFns: Array<() => void> = []; + +export interface IModalProps extends IDialogPropTypes { + /** 点击确定回调 */ + onOk?: (e: React.MouseEvent) => void; + /** 点击模态框右上角叉、取消按钮、Props.maskClosable 值为 true 时的遮罩层或键盘按下 Esc 时的回调 */ + onCancel?: (e: React.SyntheticEvent) => void; + /** 垂直居中 */ + centered?: boolean; + /** 确认按钮文字 */ + okText?: React.ReactNode; + /** 取消按钮文字 */ + cancelText?: React.ReactNode; + okButtonProps?: IButton; + cancelButtonProps?: IButton; +} + +export interface IModalFuncProps extends IDialogPropTypes { + content?: React.ReactNode; + onOk?: (...args: any[]) => any; + onCancel?: (...args: any[]) => any; + okButtonProps?: IButton; + cancelButtonProps?: IButton; + centered?: boolean; + okText?: React.ReactNode; + cancelText?: React.ReactNode; + icon?: React.ReactNode; + okCancel?: boolean; + type?: string; + autoFocusButton?: null | 'ok' | 'cancel'; +} + +const Modal: React.FC = (props) => { + const handleCancel = (e: React.SyntheticEvent) => { + const { onCancel } = props; + onCancel?.(e); + }; + + const handleOk = (e: React.MouseEvent) => { + const { onOk } = props; + onOk?.(e); + }; + + const renderFooter = () => { + const { okText, cancelText } = props; + return ( + <> + + + + ); + }; + + const { + footer, + visible, + wrapClassName, + centered, + getContainer, + closeIcon, + focusTriggerAfterClose = true, + ...restProps + } = props; + + const prefixCls = prefixClaName('modal'); + const defaultFooter = renderFooter; + + const closeIconToRender = ( + {closeIcon} + ); + + const wrapClassNameExtended = classNames(wrapClassName, { + [`${prefixCls}-centered`]: !!centered, + }); + return ( + + ); +}; + +Modal.defaultProps = { + width: 520, + transitionName: 'zoom', + maskTransitionName: 'fade', + visible: false, +}; + +export default Modal; diff --git a/src/components/dialog/confirm.tsx b/src/components/dialog/confirm.tsx new file mode 100644 index 000000000..78cecd732 --- /dev/null +++ b/src/components/dialog/confirm.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import { Icon } from 'mo/components/icon'; +import { IModalFuncProps, destroyFns } from './Modal'; +import ConfirmDialog from './ConfirmDialog'; + +export type ModalFunc = ( + props: IModalFuncProps, +) => { + destroy: () => void; +}; + +export interface ModalStaticFunctions { + warn: ModalFunc; + warning: ModalFunc; + confirm: ModalFunc; +} + +export default function confirm(config: IModalFuncProps) { + 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.onCancel && triggerCancel) { + config.onCancel(...args); + } + for (let i = 0; i < destroyFns.length; i++) { + 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 }: any) { + ReactDOM.render( + , + div, + ); + } + + function close(...args: any[]) { + currentConfig = { + ...currentConfig, + visible: false, + afterClose: destroy.bind(this, ...args), + }; + render(currentConfig); + } + + render(currentConfig); + + destroyFns.push(close); + + return { + destroy: close, + }; +} + +export function withWarn(props: IModalFuncProps): IModalFuncProps { + return { + type: 'warning', + icon: , + okCancel: false, + ...props, + }; +} diff --git a/src/components/dialog/index.tsx b/src/components/dialog/index.tsx index e69de29bb..befbfd3d7 100644 --- a/src/components/dialog/index.tsx +++ b/src/components/dialog/index.tsx @@ -0,0 +1,33 @@ +import OriginModal, { IModalFuncProps, destroyFns } from './Modal'; +import confirm, { + withWarn, + ModalStaticFunctions, +} from './confirm'; + +export { ActionButtonProps } from './ActionButton'; +export { IModalProps, IModalFuncProps } from './Modal'; + +function modalWarn(props: IModalFuncProps) { + return confirm(withWarn(props)); +} + +type ModalType = typeof OriginModal & + ModalStaticFunctions & { destroyAll: () => void}; + +const Modal = OriginModal as ModalType; + +Modal.warning = modalWarn; + +Modal.warn = modalWarn; + + +Modal.destroyAll = function destroyAllFn() { + while (destroyFns.length) { + const close = destroyFns.pop(); + if (close) { + close(); + } + } +}; + +export default Modal; \ No newline at end of file diff --git a/stories/components/17-Dialog.stories.tsx b/stories/components/17-Dialog.stories.tsx new file mode 100644 index 000000000..732b06f68 --- /dev/null +++ b/stories/components/17-Dialog.stories.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { useState } from 'react'; +import Dialog from 'mo/components/dialog'; +import { Button } from 'mo/components/button'; +import { storiesOf } from '@storybook/react'; +import { withKnobs } from '@storybook/addon-knobs'; +const stories = storiesOf('Dialog', module); +stories.addDecorator(withKnobs); + +stories.add('Basic Usage', () => { + const [isModalVisible, setIsModalVisible] = useState(false); + + const showModal = () => { + setIsModalVisible(true); + }; + + const handleOk = () => { + setIsModalVisible(false); + }; + + const handleCancel = () => { + setIsModalVisible(false); + }; + + return ( + <> +

简述

+

当需要一个简洁的确认框询问用户时,可以使用 Modal.confirm() 等语法糖方法

+
+

使用示例 1 - 基本使用

+
+ + +

Some contents...

+

Some contents...

+

Some contents...

+
+
+
+ + ; +}); diff --git a/yarn.lock b/yarn.lock index 715a5a274..bbc271c8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10874,7 +10874,17 @@ rc-collapse@^2.0.1: rc-util "^5.2.1" shallowequal "^1.1.0" -rc-motion@^2.0.1: +rc-dialog@^8.4.5: + version "8.4.5" + resolved "https://registry.npm.taobao.org/rc-dialog/download/rc-dialog-8.4.5.tgz?cache=0&sync_timestamp=1607323383644&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frc-dialog%2Fdownload%2Frc-dialog-8.4.5.tgz#6791de01578dfdba8ff6959ddd2ed18bf322d052" + integrity sha1-Z5HeAVeN/bqP9pWd3S7Ri/Mi0FI= + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.6" + rc-motion "^2.3.0" + rc-util "^5.0.1" + +rc-motion@^2.0.1, rc-motion@^2.3.0: version "2.4.1" resolved "http://registry.npm.dtstack.com/rc-motion/-/rc-motion-2.4.1.tgz#323f47c8635e6b2bc0cba2dfad25fc415b58e1dc" integrity sha1-Mj9HyGNeayvAy6LfrSX8QVtY4dw= @@ -10923,6 +10933,14 @@ rc-util@^5.0.0, rc-util@^5.0.7, rc-util@^5.2.1: react-is "^16.12.0" shallowequal "^1.1.0" +rc-util@^5.0.1: + version "5.5.1" + resolved "https://registry.npm.taobao.org/rc-util/download/rc-util-5.5.1.tgz?cache=0&sync_timestamp=1607394295584&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frc-util%2Fdownload%2Frc-util-5.5.1.tgz#8c75a115c09bd9b80ce17eb3457c9b221df6f526" + integrity sha1-jHWhFcCb2bgM4X6zRXybIh329SY= + dependencies: + react-is "^16.12.0" + shallowequal "^1.1.0" + rc-virtual-list@^3.0.1: version "3.2.2" resolved "http://registry.npm.dtstack.com/rc-virtual-list/-/rc-virtual-list-3.2.2.tgz#95f8f0c4238e081f4a998354492632eed6d71924"