import * as React from "react" import PropTypes from "prop-types" import getScrollBarSize from "rc-util/lib/getScrollBarSize" import classNames from "classnames" import CSSMotion from "rc-motion" import omit from "../_util/omit" import Portal from "../portal" import Button, { IButtonProps } from "../button" import Icon from "../icon" import "./style" const prefix = "adui-dialog" interface IStepProps { /** * 取消按钮的 props,参考 Button Props */ cancelProps?: Partial /** * 取消按钮的 文字 */ cancelText?: string /** * 子节点 */ children?: React.ReactNode /** * 确认按钮的 props,参考 Button Props */ confirmProps?: Partial /** * 确认按钮的 文字 */ confirmText?: string /** * 点击取消按钮、右上角关闭时的 handler */ onCancel?: (() => void) | null /** * 点击确认按钮时的 handler */ onConfirm?: (() => void) | null } export interface IDialogProps extends IStepProps { [key: string]: any /** * 缩放对话框范围 */ autoScaleRange?: [number, number] /** * 关闭后(transition 结束后)的 handler */ afterClose?: (() => void) | null /** * 设置 body className */ bodyClassName?: string /** * 设置 body style */ bodyStyle?: React.CSSProperties /** * dialog 打开和关闭时都会更改 document.body.style。可以以此 prop 控制是否修改。使用场景如:浮层内弹出 dialog */ changeBodyStyle?: boolean /** * 附加类名,注意 Dialog 所有的属性会附加到内部的内容元素上,而不是包含 mask 的最外层元素 */ className?: string /** * 指定当前的 step,仅在使用 steps prop 时有作用 */ currentStep?: null | number /** * 指定默认的起始 step,仅在使用 steps prop 时有作用 */ defaultCurrentStep?: null | number /** * 内部驱动:是否默认显示 */ defaultVisible?: null | boolean /** * 是否每次 visible: false 都销毁 dom */ destroyAfterClose?: boolean /** * 是否支持 ESC 关闭 */ escapeKeyClosable?: boolean /** * 完全自定义 footer 的内容,如果想要改变 footer 父级的样式,使用 footerStyle Prop */ footerElement?: React.ReactNode /** * 设置 footer className */ footerClassName?: string /** * 设置 footer style */ footerStyle?: React.CSSProperties /** * 指定弹出层的父级,默认为 document.body,类似于 Tooltip 的 getPopupContainer */ getContainer?: () => HTMLElement /** * header 标题下的内容 */ headerContent?: React.ReactNode /** * 完全自定义 header 的内容 */ headerElement?: React.ReactNode /** * 设置 header className */ headerClassName?: string /** * 设置 header style */ headerStyle?: React.CSSProperties /** * 点击 mask 是否触发 cancel 事件 */ maskClosable?: boolean /** * 设置 mask 的 CSS Motion name */ maskMotionName?: string /** * 设置 dialog 的 CSS Motion name */ motionName?: string /** * 指定步骤,用以快捷上一步、下一步的需求。 */ steps?: Array /** * 附加样式,注意 Dialog 所有的属性会附加到内部的内容元素上,而不是包含 mask 的最外层元素 */ style?: React.CSSProperties /** * 对话框标题 */ title?: null | React.ReactNode /** * confirm 为取消、确认按钮;inform 为只有确认按钮 */ type?: "confirm" | "inform" /** * 外部控制:是否显示 */ visible?: null | boolean /** * 设置 z-index 层级,默认为 var(--z-index-dialog) */ zIndex?: React.ReactNode } export interface IDialogState { visible?: boolean currentStep: number hasEverOpened?: boolean } /** * 对话框用于全局性的反馈,通常分为“确认操作”与“告知信息”。 * 确认操作用于询问用户“操作是否继续进行?”,如大部分的二次确认反馈。告知信息则用于知会用户某个及时反馈,如信息提交成功、失败等。 */ class Dialog extends React.Component { public static propTypes = { /** * 缩放对话框范围 */ autoScaleRange: PropTypes.array, /** * 关闭后(transition 结束后)的 handler */ afterClose: PropTypes.func, /** * 设置 body className */ bodyClassName: PropTypes.string, /** * 设置 body style */ bodyStyle: PropTypes.object, /** * 取消按钮的 props,参考 Button Props */ cancelProps: PropTypes.object, /** * 取消按钮的 文字 */ cancelText: PropTypes.node, /** * dialog 打开和关闭时都会更改 document.body.style。可以以此 prop 控制是否修改。使用场景如:浮层内弹出 dialog */ changeBodyStyle: PropTypes.bool, /** * 子节点 */ children: PropTypes.node, /** * 附加类名,注意 Dialog 所有的属性会附加到内部的内容元素上,而不是包含 mask 的最外层元素 */ className: PropTypes.string, /** * 确认按钮的 props,参考 Button Props */ confirmProps: PropTypes.object, /** * 确认按钮的 文字 */ confirmText: PropTypes.node, /** * 指定当前的 step,仅在使用 steps prop 时有作用 */ currentStep: PropTypes.number, /** * 指定默认的起始 step,仅在使用 steps prop 时有作用 */ defaultCurrentStep: PropTypes.number, /** * 内部驱动:是否默认显示 */ defaultVisible: PropTypes.bool, /** * 是否每次 visible: false 都销毁 dom */ destroyAfterClose: PropTypes.bool, /** * 是否支持 ESC 关闭 */ escapeKeyClosable: PropTypes.bool, /** * 完全自定义 footer 的内容,如果想要改变 footer 父级的样式,使用 footerStyle Prop */ footerElement: PropTypes.node, /** * 设置 footer className */ footerClassName: PropTypes.string, /** * 设置 footer style */ footerStyle: PropTypes.object, /** * 指定弹出层的父级,默认为 document.body,类似于 Tooltip 的 getPopupContainer */ getContainer: PropTypes.func, /** * header 标题下的内容 */ headerContent: PropTypes.node, /** * 完全自定义 header 的内容 */ headerElement: PropTypes.node, /** * 设置 header className */ headerClassName: PropTypes.string, /** * 设置 header style */ headerStyle: PropTypes.object, /** * 点击 mask 是否触发 cancel 事件 */ maskClosable: PropTypes.bool, /** * 设置 mask 的 CSS Motion name */ maskMotionName: PropTypes.string, /** * 设置 dialog 的 CSS Motion name */ motionName: PropTypes.string, /** * 点击取消按钮、右上角关闭时的 handler */ onCancel: PropTypes.func, /** * 点击确认按钮时的 handler */ onConfirm: PropTypes.func, /** * 附加样式,注意 Dialog 所有的属性会附加到内部的内容元素上,而不是包含 mask 的最外层元素 */ style: PropTypes.object, /** * 对话框标题 */ title: PropTypes.node, /** * confirm 为取消、确认按钮;inform 为只有确认按钮 */ type: PropTypes.oneOf(["confirm", "inform"]), /** * 外部控制:是否显示 */ visible: PropTypes.bool, /** * 设置 z-index 层级,默认为 var(--z-index-dialog) */ zIndex: PropTypes.any, } public static defaultProps: IDialogProps = { autoScaleRange: [0.92, 1], afterClose: null, bodyClassName: "", bodyStyle: {}, cancelProps: {}, cancelText: "取消", changeBodyStyle: true, children: null, className: undefined, confirmProps: {}, confirmText: "确认", currentStep: null, defaultCurrentStep: null, defaultVisible: null, destroyAfterClose: false, escapeKeyClosable: true, footerElement: undefined, footerClassName: "", footerStyle: {}, getContainer: undefined, headerContent: null, headerClassName: "", headerElement: undefined, headerStyle: {}, maskClosable: false, maskMotionName: "", motionName: "", onCancel: null, onConfirm: null, style: {}, title: null, type: "confirm", visible: null, zIndex: "var(--z-index-dialog)", } public static info: any public static success: any public static warning: any public static danger: any public static confirm: any public static inform: any public static getDerivedStateFromProps = ( { currentStep, visible }: IDialogProps, { hasEverOpened }: IDialogState ) => { const newState: Partial = {} if (currentStep !== null) { newState.currentStep = currentStep } if (visible !== null) { newState.visible = visible if (!hasEverOpened && visible) { newState.hasEverOpened = true } } return Object.keys(newState).length > 0 ? newState : null } /** * 保存节点挂载的对象 */ public container: HTMLDivElement | null /** * 保存页面是否 overflow */ public bodyIsOverflowing: boolean /** * 保存当前浏览器的滚动条宽度 */ public scrollbarWidth: number public dialog: HTMLDivElement public wrapper: HTMLDivElement public isUnmounted: boolean = false constructor(props: IDialogProps) { super(props) const { currentStep, defaultCurrentStep, visible, defaultVisible } = props const visibleState = visible !== null ? !!visible : !!defaultVisible const currentStepState = currentStep !== null ? currentStep : defaultCurrentStep this.state = { visible: visibleState, currentStep: currentStepState || 0, hasEverOpened: visibleState, } } public saveDialog = (node: HTMLDivElement) => { this.dialog = node } public resetScrollbarPadding = () => { const { changeBodyStyle } = this.props if (changeBodyStyle) { document.body.style.overflow = "" document.body.style.paddingRight = "" } } public setScrollbarPadding = () => { const { changeBodyStyle } = this.props if (changeBodyStyle) { const { innerWidth } = window this.bodyIsOverflowing = document.body.clientWidth < innerWidth if (this.bodyIsOverflowing && !document.body.style.paddingRight) { this.scrollbarWidth = getScrollBarSize() if (this.scrollbarWidth !== undefined) { document.body.style.paddingRight = `${this.scrollbarWidth}px` } } document.body.style.overflow = "hidden" } } public getCancelButton = () => { const { cancelProps, cancelText, steps, type } = this.props const { currentStep } = this.state const defaultText = currentStep === 0 ? "取消" : "上一步" const generateButton = (props?: IButtonProps, text = defaultText) => ( ) if (steps && steps.length) { const props = steps[currentStep].cancelProps const text = steps[currentStep].cancelText return generateButton(props, text) } if (type === "inform") { return null } return generateButton(cancelProps, cancelText) } public getConfirmButton = () => { const { confirmProps, confirmText, steps } = this.props const { currentStep } = this.state const generateButton = (props?: IButtonProps, text?: string) => ( ) if (steps && steps.length) { const props = steps[currentStep].confirmProps const text = steps[currentStep].confirmText || (currentStep === steps.length - 1 ? "完成" : "下一步") return generateButton(props, text) } return generateButton(confirmProps, confirmText || "确认") } public handleKeyDown = (e: React.KeyboardEvent) => { const { escapeKeyClosable } = this.props if (escapeKeyClosable && e.key === "Escape") { e.stopPropagation() this.handleCancel() } } public handleCancel = (type?: any) => { const { onCancel, steps, visible } = this.props const { currentStep } = this.state if (type === "close" || !steps || !steps.length) { if (visible === null) { this.setState({ visible: false }) } if (onCancel) { onCancel() } } else { const { onCancel: onStepCancel } = steps[currentStep] if (onStepCancel) { onStepCancel() } if (currentStep === 0) { if (visible === null) { this.setState({ visible: false }) } } else { this.setState({ currentStep: currentStep - 1 }) } } } public handleConfirm = () => { const { onConfirm, steps, visible } = this.props const { currentStep } = this.state if (steps && steps.length) { const { onConfirm: onStepConfirm } = steps[currentStep] if (onStepConfirm) { onStepConfirm() } if (currentStep === steps.length - 1) { if (visible === null) { this.setState({ visible: false }) } } else { this.setState({ currentStep: currentStep + 1 }) } } else { if (visible === null) { this.setState({ visible: false }) } if (onConfirm) { onConfirm() } } } public onLeave = () => { const { afterClose, destroyAfterClose } = this.props if (this.wrapper) { this.wrapper.style.display = "none" } this.resetScrollbarPadding() if (afterClose) { afterClose() } if (destroyAfterClose && !this.isUnmounted) { this.setState({ hasEverOpened: false }) } } public handleEnter = () => { const { escapeKeyClosable } = this.props if (this.wrapper) { if (escapeKeyClosable) { this.wrapper.focus() } } } public getComponent = (options?: { visible: boolean }) => { const { bodyClassName, bodyStyle, children, className, destroyAfterClose, footerClassName, footerElement, footerStyle, headerClassName, headerContent, headerElement, headerStyle, maskClosable, maskMotionName, motionName, steps, style, title, zIndex: zIndexProp, ...otherProps } = this.props const zIndex = zIndexProp as React.CSSProperties["zIndex"] const restProps = omit(otherProps, [ "autoScaleRange", "afterClose", "cancelProps", "cancelText", "changeBodyStyle", "confirmProps", "confirmText", "currentStep", "defaultCurrentStep", "defaultVisible", "escapeKeyClosable", "getContainer", "onCancel", "onConfirm", "type", "visible", ]) const classSet = classNames(className, `${prefix}-inner`) const { currentStep } = this.state let { visible } = this.state if (options && Object.keys(options).includes("visible")) { ;({ visible } = options) } return (
{ if (wrapper) { this.wrapper = wrapper } }} style={{ display: visible ? "flex" : "", zIndex, }} tabIndex={0} role="none" onKeyDown={this.handleKeyDown} > {({ className: cls }, ref) => (
{ if (maskClosable) { this.handleCancel("close") } }} /> )} {/** * https://stackoverflow.com/a/33455342 * 1. 最外层 margin: auto 非常重要。 * Dialog 的宽度是自适应的,因此使用了 display: flex 处理居中,这使得子元素具有包裹性,因此宽度才能由内容所决定; * margin: auto; 是为了完善 flexbox overflow 的样式。 * 2. zIndex: 1 是为了覆盖在 mask 上。 */} {({ className: cls }, ref) => (
{headerElement === null ? null : headerElement || (
{[
{title}
,
{headerContent && (
{headerContent}
)}
, this.handleCancel("close")} />, ]}
)}
{steps && steps.length ? steps[currentStep].children : children}
{footerElement === null ? null : (
{footerElement || [ this.getCancelButton(), this.getConfirmButton(), ]}
)}
)}
) } /** * didmount 由 portal 的 onChildrenMount,因为 didmount 时 wrapper 可能并不在 dom */ public handleChildrenMount = () => { const { visible } = this.state if (visible && this.wrapper) { this.setScrollbarPadding() } } public handleResize = () => { const { autoScaleRange = [0.92, 1] } = this.props const { visible } = this.state if (!this.wrapper || !visible) { return } const innerEl = this.dialog.querySelector( `.${prefix}-inner` ) as HTMLDivElement if (!innerEl) { return } const { clientWidth, clientHeight } = innerEl const windowWidth = window.innerWidth const windowHeight = window.innerHeight // 计算需要的空间(包括边距) const requiredWidth = clientWidth + 72 const requiredHeight = clientHeight + 72 // 计算宽度和高度的缩放比例 const widthScale = windowWidth / requiredWidth const heightScale = windowHeight / requiredHeight // 取较小的缩放比例,确保内容完全可见 const scale = Math.min(widthScale, heightScale) // 限制在指定范围内 const clampedScale = Math.min( Math.max(scale, autoScaleRange[0]), autoScaleRange[1] ) // @ts-ignore innerEl.style.zoom = `${clampedScale}` } public componentDidUpdate = ( _: IDialogProps, { visible: visiblePrev }: IDialogState ) => { const { visible } = this.state if (visible !== visiblePrev && visible && this.wrapper) { this.setScrollbarPadding() } setTimeout(() => { this.handleResize() }, 0) window.addEventListener("resize", this.handleResize) } public componentWillUnmount = () => { const { visible } = this.state this.isUnmounted = true if (visible) { this.resetScrollbarPadding() } window.removeEventListener("resize", this.handleResize) } public render() { const { getContainer } = this.props const { hasEverOpened } = this.state if (!hasEverOpened) { return null } return ( {this.getComponent()} ) } } export default Dialog