比ant更丰富Modal组件功能实现示例详解

目录
  • 有哪些比ant更丰富的功能
  • render部分
  • 渲染黑色蒙层
  • 渲染弹框主体
  • 设置body overflow:hiiden

有哪些比ant更丰富的功能

普通的modal组件如下:

我们写的modal额外支持,后面没有蒙版,并且Modal框能够拖拽

还支持渲染在文档流里,上面的都是fixed布局,我们这个正常渲染到文档下面:

render部分

    <RenderDialog
      {...restState}
      visible={visible}
      prefixCls={prefixCls}
      header={renderHeader}
      attach={attach}
      closeBtn={renderCloseIcon()}
      classPrefix={classPrefix}
      onClose={onClose}
      onConfirm={onConfirm}
      footer={footer === true ? defaultFooter() : footer}
      ref={dialogDom}
    />

大家记住这个RenderDialog,接下来都是上面传参的解释:

resetState: 是对象,一堆属性的集合,哪些属性呢,我们往下看

// 其实默认参数写到这里并不科学,因为react有个静态属性defaultProps属性支持合并props
  const [state, setState] = useSetState<DialogProps>({
    width: 520, // 默认宽度是520
    visible: false, // 默认visible是false
    zIndex: 2500, // 默认zIndex 2500
    placement: 'center', // 默认渲染到屏幕中间
    mode: 'modal', // 默认的模式是modal是ant那种渲染结果,其他模式我们下面谈
    showOverlay: true, // 是否展示透明黑色蒙版
    destroyOnClose: false, // 关闭弹窗的时候是否销毁里面的内容
    draggable: false, // 是否能拖拽modal
    preventScrollThrough: true, // 防止滚动穿透
    ...props,
  });

restState在下面,除了state上某些属性。

 const {
   visible, // 控制对话框是否显示
   attach, // 对话框挂载的节点,默认挂在组件本身的位置。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
   closeBtn, // 关闭按钮,可以自定义。值为 true 显示默认关闭按钮,值为 false 不显示关闭按钮。值类型为 string 则直接显示值,// 底部操作栏,默认会有“确认”和“取消”两个按钮。值为 true 显示默认操作按钮,值为 false 不显示任何内容,值类型为 Function 表示自定义底部内容
   footer = true, // 如果“取消”按钮存在,则点击“取消”按钮时触发,同时触发关闭事件
   onCancel = noop, // 如果“确认”按钮存在,则点击“确认”按钮时触发,或者键盘按下回车键时触发
   onConfirm = noop, // 如果“确认”按钮存在,则点击“确认”按钮时触发,或者键盘按下回车键时触发
   cancelBtn = cancelText, // 取消按钮,可自定义。值为 null 则不显示取消按钮。值类型为字符串,则表示自定义按钮文本,值类型为 Object 则表示透传 Button 组件属性。
   confirmBtn = confirmText, // 确认按钮。值为 null 则不显示确认按钮。值类型为字符串,则表示自定义按钮文本,值类型为 Object 则表示透传 Button 组件属性。
   onClose = noop, // 关闭事件,点击取消按钮、点击关闭按钮、点击蒙层、按下 ESC 等场景下触发
   ...restState
 } = state;

说了这么多,我们接着看RenderDialog组件上传入的属性。

prefixCls不讲了,是css属性前缀,一个字符串,接着看header属性被包装为renderHeader

const renderHeader = useMemo(() => {
    if (!state.header) return null;
    const iconMap = {
      info: <InfoCircleFilledIcon className={`${classPrefix}-is-info`} />,
      warning: <InfoCircleFilledIcon className={`${classPrefix}-is-warning`} />,
      error: <InfoCircleFilledIcon className={`${classPrefix}-is-error`} />,
      success: <CheckCircleFilledIcon className={`${classPrefix}-is-success`} />,
    };
    return (
      <div className={`${prefixCls}__header-content`}>
        {iconMap[state.theme]}
        {state.header}
      </div>
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state.header, state.theme, prefixCls, classPrefix]);

其实就是在header的文字前面多了一个icon,比如成功的弹窗如下:

接着看closeBtn属性

  const renderCloseIcon = () => {
    if (closeBtn === false) return null;
    if (closeBtn === true) return <CloseIcon style={{ verticalAlign: 'unset' }} />;
    return closeBtn || <CloseIcon style={{ verticalAlign: 'unset' }} />;
  };

这个是右上角关闭按钮的Icon,很简单,如果是false,什么都不许安然,如果是undefined或者true渲染这个icon。

好了,我们把整个代码放到下面,有代码注释,没写注释的是上面咋们已经讲过的内容,接着就要进入RenderDialog这个组件内部了。

import 的部分省略了
// 渲染 footer的button方法
const renderDialogButton = (btn: TdDialogProps['cancelBtn'], defaultProps: ButtonProps) => {
  let result = null;
  if (isString(btn)) {
    result = <Button {...defaultProps}>{btn}</Button>;
  }  else if (isFunction(btn)) {
    result = btn();
  }
  return result;
};
const Dialog = forwardRef((props: DialogProps, ref: React.Ref<DialogInstance>) => {
  // 这部分忽略就好,用来获取全局配置的css前缀字符串
  const { classPrefix } = useConfig();
  // 这个也忽略,获取icon组件的
  const { CloseIcon, InfoCircleFilledIcon, CheckCircleFilledIcon } = useGlobalIcon({
    CloseIcon: TdCloseIcon,
    InfoCircleFilledIcon: TdInfoCircleFilledIcon,
    CheckCircleFilledIcon: TdCheckCircleFilledIcon,
  });
  // 用来引用dialog弹框的dom
  const dialogDom = useRef<HTMLDivElement>();
  const [state, setState] = useSetState<DialogProps>({
    width: 520,
    visible: false,
    zIndex: 2500,
    placement: 'center',
    mode: 'modal',
    showOverlay: true,
    destroyOnClose: false,
    draggable: false,
    preventScrollThrough: true,
    ...props,
  });
 // 国际化有关的
  const [local, t] = useLocaleReceiver('dialog');
  const confirmText = t(local.confirm);
  const cancelText = t(local.cancel);
  const {
    visible,
    attach,
    closeBtn,
    footer = true,
    onCancel = noop,
    onConfirm = noop,
    cancelBtn = cancelText,
    confirmBtn = confirmText,
    onClose = noop,
    ...restState
  } = state;
  useEffect(() => {
     setState((prevState) => ({
        ...prevState,
        ...props,
      }));
  }, [props, setState, isPlugin]);
  const prefixCls = `${classPrefix}-dialog`;
  const renderCloseIcon = () => {
    if (closeBtn === false) return null;
    if (closeBtn === true) return <CloseIcon style={{ verticalAlign: 'unset' }} />;
    return closeBtn || <CloseIcon style={{ verticalAlign: 'unset' }} />;
  };
 // 这里把一些外部方法暴露给调用者,只需要传入ref就可以获取
  React.useImperativeHandle(ref, () => ({
    show() {
      setState({ visible: true });
    },
    hide() {
      setState({ visible: false });
    },
    destroy() {
      setState({ visible: false, destroyOnClose: true });
    },
    update(newOptions) {
      setState((prevState) => ({
        ...prevState,
        ...(newOptions as DialogProps),
      }));
    },
  }));
  const renderHeader = useMemo(() => {
    if (!state.header) return null;
    const iconMap = {
      info: <InfoCircleFilledIcon className={`${classPrefix}-is-info`} />,
      warning: <InfoCircleFilledIcon className={`${classPrefix}-is-warning`} />,
      error: <InfoCircleFilledIcon className={`${classPrefix}-is-error`} />,
      success: <CheckCircleFilledIcon className={`${classPrefix}-is-success`} />,
    };
    return (
      <div className={`${prefixCls}__header-content`}>
        {iconMap[state.theme]}
        {state.header}
      </div>
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state.header, state.theme, prefixCls, classPrefix]);
// 渲染footer的时候,点击取消按钮会用到
  const handleCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
    onCancel({ e });
    onClose({ e, trigger: 'cancel' });
  };
// 渲染footer的时候,点击确认按钮会用到
  const handleConfirm = (e: React.MouseEvent<HTMLButtonElement>) => {
    onConfirm({ e });
  };
  const defaultFooter = () => {
    const renderCancelBtn = renderDialogButton(cancelBtn, { variant: 'outline' });
    const renderConfirmBtn = renderDialogButton(confirmBtn, { theme: 'primary' });
    return (
      <>
        {renderCancelBtn &&
          React.cloneElement(renderCancelBtn, {
            onClick: handleCancel,
            ...renderCancelBtn.props,
          })}
        {renderConfirmBtn &&
          React.cloneElement(renderConfirmBtn, {
            onClick: handleConfirm,
            ...renderConfirmBtn.props,
          })}
      </>
    );
  };
  return (
    <RenderDialog
      {...restState}
      visible={visible}
      prefixCls={prefixCls}
      header={renderHeader}
      attach={attach}
      closeBtn={renderCloseIcon()}
      classPrefix={classPrefix}
      onClose={onClose}
      onConfirm={onConfirm}
      footer={footer === true ? defaultFooter() : footer}
      ref={dialogDom}
    />
  );
});
Dialog.displayName = 'Dialog';
Dialog.defaultProps = dialogDefaultProps;
export default Dialog;

接着,我们要渲染的部分其实很简单,包括

  • 背后的黑色蒙层
  • 弹框
    • 弹框的标题
    • 弹框的内容区域
    • 弹框的footer
  • 还需要弹框动画,比如zoom或者fade

渲染黑色蒙层

代码如下,很简单

  const renderMask = () => {
    let maskElement;
    if (showOverlay) {
      maskElement = (
        <CSSTransition
          in={visible}
          appear
          timeout={transitionTime}
          classNames={`${prefixCls}-fade`}
          mountOnEnter
          unmountOnExit
          nodeRef={maskRef}
        >
          <div ref={maskRef} className={`${prefixCls}__mask`} />
        </CSSTransition>
      );
    }
    return maskElement;
  };

首先介绍一下CSSTransition,这是react-transition-group动画库的一个组件,用来帮助我们实现css动画的。 其中一些属性说明如下:

  • in: ture就是开始动画,false就是停止动画
  • appear:boolean,为 false 时当 CSSTransition 控件加载完毕后不执行动画,为 true 时控件加载完毕则立即执行动画。如果要组件初次渲染就有动画,则需要设成 true
  • timeout 动画时间
  • classNames:动画的类名,比如classNames:'demo',会自动在进入动画的时候帮你把类名改为 demo-enter-active, demo-enter-done, 在退出动画同样会有类名的改变。
  • mountOnEnter:一进来的时候不显示dom元素
  • unmountOnExit:boolean,为 true 时组件将移除处于隐藏状态的元素,为 false 时组件保持动画结束时的状态而不移除元素。一般要设成 true
  • nodeRef,获取蒙层的ref

蒙层主要靠css实现,我们看下css

  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  background: var(--td-mask-active);
  pointer-events: auto;

渲染弹框主体

也非常简单啊,我们把注释写在下面的代码里了,其中有一个需要小小注意的功能就是拖拽功能

// 渲染Dialog主体
  const renderDialog = () => {
    const dest: any = {};
    // 把width变为有px结尾的字符串
    if (props.width !== undefined) {
      dest.width = GetCSSValue(props.width);
    }
    // normal 场景下,需要设置 zindex 为auto 避免出现多个 dialog,normal 出现在最上层
    if (props.mode === 'normal') {
      dest.zIndex = 'auto';
    }
    // 获取footer
    const footer = props.footer ? <div className={`${prefixCls}__footer`}>{props.footer}</div> : null;
    // 获取header
    const { header } = props;
    // 获取Dialog body
    const body = <div className={`${prefixCls}__body`}>{props.body || props.children}</div>;
    // 关闭按钮,可以自定义。值为 true 显示默认关闭按钮,值为 false 不显示关闭按钮。值类型为 string 则直接显示值,如:“关闭”。
    const closer = closeBtn && (
      <span onClick={handleCloseBtnClick} className={`${prefixCls}__close`}>
        {closeBtn}
      </span>
    );
    const validWindow = typeof window === 'object';
    // 获取屏幕高度
    const screenHeight = validWindow ? window.innerHeight || document.documentElement.clientHeight : undefined;
    // 获取屏幕宽度
    const screenWidth = validWindow ? window.innerWidth || document.documentElement.clientWidth : undefined;
    // 设置style
    const style = { ...dest, ...props.style };
    let dialogOffset = { x: 0, y: 0 };
    // 拖拽代码实现部分
    const onDialogMove = (e: MouseEvent) => {
      // offsetWidth是指元素的宽 + padding + border的总和
      const { style, offsetWidth, offsetHeight } = dialog.current;
      // diffX是指弹框部分距离body左边部分
      let diffX = e.clientX - dialogOffset.x;
      let diffY = e.clientY - dialogOffset.y;
      // 拖拽上左边界限制
      if (diffX < 0) diffX = 0;
      if (diffY < 0) diffY = 0;
      // 右边的限制
      if (screenWidth - offsetWidth - diffX < 0) diffX = screenWidth - offsetWidth;
      // 下边的限制
      if (screenHeight - offsetHeight - diffY < 0) diffY = screenHeight - offsetHeight;
      style.position = 'absolute';
      style.left = `${diffX}px`;
      style.top = `${diffY}px`;
    };
    const onDialogMoveEnd = () => {
      // 恢复指针样式为默认,并且注销mousemove, mouseup事件
      dialog.current.style.cursor = 'default';
      document.removeEventListener('mousemove', onDialogMove);
      document.removeEventListener('mouseup', onDialogMoveEnd);
    };
    // 拖拽开始,对应mouseDown事件
    const onDialogMoveStart = (e: React.MouseEvent<HTMLDivElement>) => {
      contentClickRef.current = true;
      // 阻止事件冒泡, mode === 'modeless才能拖拽
      if (canDraggable && e.currentTarget === e.target) {
        const { offsetLeft, offsetTop, offsetHeight, offsetWidth } = dialog.current;
        // 如果弹出框超出屏幕范围 不能进行拖拽
        if (offsetWidth > screenWidth || offsetHeight > screenHeight) return;
        // 拖拽样式设置为move
        dialog.current.style.cursor = 'move';
        // 计算鼠标 e.clientX是鼠标在屏幕的坐标,offsetLeft是Dialog主体跟body的距离
        // 所以e.clientX - offsetLeft就是鼠标在是Dialog主体上的横坐标
        const diffX = e.clientX - offsetLeft;
        const diffY = e.clientY - offsetTop;
        dialogOffset = {
          x: diffX,
          y: diffY,
        };
        // 此时把mousemove和mouseup事件也绑定一下,其实不建议绑定在这里直接操作dom
        document.addEventListener('mousemove', onDialogMove);
        document.addEventListener('mouseup', onDialogMoveEnd);
      }
    };
    // 顶部定位实现
    const positionStyle: any = {};
    if (props.top) {
      const topValue = GetCSSValue(props.top);
      positionStyle.paddingTop = topValue;
    }
    // 此处获取定位方式 top 优先级较高 存在时 默认使用 top 定位
    const positionClass = classnames(
      `${prefixCls}__position`,
      { [`${prefixCls}--top`]: !!props.top },
      `${props.placement && !props.top ? `${prefixCls}--${props.placement}` : ''}`,
    );
    // 然后就是用css去渲染header body和footer
    const dialogElement = (
      <div className={isNormal ? '' : `${prefixCls}__wrap`}>
        <div className={isNormal ? '' : positionClass} style={positionStyle} onClick={onMaskClick} ref={dialogPosition}>
          <div
            ref={dialog}
            style={style}
            className={classnames(`${prefixCls}`, `${prefixCls}--default`)}
            onMouseDown={onDialogMoveStart}
          >
            <div className={classnames(`${prefixCls}__header`)}>
              {header}
              {closer}
            </div>
            {body}
            {footer}
          </div>
        </div>
      </div>
    );
    return (
      <CSSTransition
        in={props.visible}
        appear
        mountOnEnter
        unmountOnExit={destroyOnClose}
        timeout={transitionTime}
        classNames={`${prefixCls}-zoom`}
        onEntered={props.onOpened}
        onExited={onAnimateLeave}
        nodeRef={dialog}
      >
        {dialogElement}
      </CSSTransition>
    );
  };

我们这里贴一下css部分:

header:

.t-dialog__header {
    color: var(--td-text-color-primary);
    font: var(--td-font-title-medium);
    font-weight: 600;
    display: flex;
    align-items: flex-start;
    word-break: break-word;
}

这里注意下:word-wrap:break-word

它会把整个单词看成一个整体,如果该行末端宽度不够显示整个单词,它会自动把整个单词放到下一行,而不会把单词截断掉的。

body

.t-dialog__body {
  padding: 16px 0;
  color: var(--td-text-color-secondary);
  font: var(--td-font-body-medium);
  overflow: auto;
  word-break: break-word;
}

footer

  width: 100%;
  text-align: right;
  padding: 16px 0 0 0;

好了,我们结合一下弹框和蒙层,看下render函数

const render = () => {
    // 。。。省略css部分
    // 如果不是 modal 模式 默认没有 mask 也就没有相关点击 mask 事件
    const dialog = (
      <div ref={wrap} className={wrapClass} style={wrapStyle} onKeyDown={handleKeyDown} tabIndex={0}>
        {mode === 'modal' && renderMask()}
        {dialogBody} // 这里就是我们上面讲的renderDialog
      </div>
    );
    return dialog;
  };

设置body overflow:hiiden

为啥要设置body overflow:hiiden这个属性呢,你打开modal弹窗的时候,如果此时body还有滚动条,那么你滚动鼠标滚轮还可以向下滑动,但是一般情况下,我们打开弹框,是希望用户目标锁定在当前交互,此时最好不要允许用户滚动界面。

当然你也可以允许用户滚动,我们用一个preventScrollThrough参数控制。

先记住当前body的css样式,以及body的overflow的值,代码如下

  useLayoutEffect(() => {
    bodyOverflow.current = document.body.style.overflow;
    bodyCssTextRef.current = document.body.style.cssText;
  }, []);
 const isModal = mode === 'modal';
useLayoutEffect(() => {
      // 只有modal数量小于1的时候才重置样式,因为可能出现多个弹框,那么关闭一个弹框就出现滚动条明显不对
      if (isModal) {
      const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
      if (openDialogDom.length < 1) {
        document.body.style.cssText = bodyCssTextRef.current;
      }
    // 组件销毁后重置 body 样式
    return () => {
      if (isModal) {
        // 此处只能查询 mode 模式的 dialog 个数 因为 modeless 会点击透传 normal 是正常文档流
        const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
        if (openDialogDom.length < 1) {
          document.body.style.cssText = bodyCssTextRef.current;
          document.body.style.overflow = bodyOverflow.current;
        }
      }
    };
  }, [preventScrollThrough, attach, visible, mode, isModal, showInAttachedElement, prefixCls]);

上面的代码还有一个问题,就是我们需要preventScrollThrough这个参数去控制是否可以body滚动页面,这个也是算比ant更丰富的功能。

const isModal = mode === 'modal';
 useLayoutEffect(() => {
    // 处于显示态
    if (visible) {
      // isModal表示是否是普通弹框,就是带黑色蒙层的
      // bodyOverflow.current 引用的是body的overflow属性
      // preventScrollThrough是代表是否可以滚动body
      // !showInAttachedElement表示不挂载到其他dom上
      if (isModal && bodyOverflow.current !== 'hidden' && preventScrollThrough && !showInAttachedElement) {
        // 求出滚动条的宽度
        const scrollWidth = window.innerWidth - document.body.offsetWidth;
        // 减少回流
        if (bodyCssTextRef.current === '') {
          let bodyCssText = 'overflow: hidden;';
          if (scrollWidth > 0) {
            bodyCssText += `position: relative;width: calc(100% - ${scrollWidth}px);`;
          }
          document.body.style.cssText = bodyCssText;
        } else {
          if (scrollWidth > 0) {
            document.body.style.width = `calc(100% - ${scrollWidth}px)`;
            document.body.style.position = 'relative';
          }
          document.body.style.overflow = 'hidden';
        }
      }
      // 刚进页面就focus到弹框组件上
      if (wrap.current) {
        wrap.current.focus();
      }
    } else if (isModal) {
      const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
      if (openDialogDom.length < 1) {
        document.body.style.cssText = bodyCssTextRef.current;
      }
    }
    // 组件销毁后重置 body 样式
    return () => {
      if (isModal) {
        // 此处只能查询 mode 模式的 dialog 个数 因为 modeless 会点击透传 normal 是正常文档流
        const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
        if (openDialogDom.length < 1) {
          document.body.style.cssText = bodyCssTextRef.current;
          document.body.style.overflow = bodyOverflow.current;
        }
      } else {
        document.body.style.cssText = bodyCssTextRef.current;
        document.body.style.overflow = bodyOverflow.current;
      }
    };
  }, [preventScrollThrough, attach, visible, mode, isModal, showInAttachedElement, prefixCls]);

其实还有一个逻辑,是把弹窗渲染到任意dom里,需要一个Portal组件,我们这里就不说了,后续将Popup或者叫trigger组件的时候我们讲吧。一篇文档内容太多不好消化。

好了,主逻辑已经写完了,很简单吧!

接下来看下完整代码,没有注释的部分是上面已经讲过的

//省去了import
// 把css的数字转为有px结尾的字符串,,这里其实应该写到一个utils文件夹里,不应该跟主代码混在一起
function GetCSSValue(v: string | number) {
  return Number.isNaN(Number(v)) ? v : `${Number(v)}px`;
}
// 动画执行时间,这里其实应该写到一个constants文件里,不应该跟主代码混在一起
const transitionTime = 300;
const RenderDialog = forwardRef((props: RenderDialogProps, ref: React.Ref<HTMLDivElement>) => {
  // 这里不用看,跟国际化有关
  const [local] = useLocaleReceiver('dialog');
  const {
    prefixCls,
    attach, // 对话框挂载的节点,默认挂在组件本身的位置。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
    visible, // 控制对话框是否显示
    mode, // 对话框类型,有三种:模态对话框、非模态对话框和普通对话框。弹出「模态对话框」时,只能操作对话框里面的内容,不能操作其他内容。弹出「非模态对话框」时,则可以操作页面内所有内容。「普通对话框」是指没有脱离文档流的对话框,可以在这个基础上开发更多的插件
    zIndex, // 对话框层级,Web 侧样式默认为 2500,移动端和小程序样式默认为 1500
    showOverlay, // 是否显示遮罩层
    onEscKeydown = noop,// 按下 ESC 时触发事件
    onClosed = noop, // 对话框消失动画效果结束后触发
    onClose = noop, // 关闭事件,点击取消按钮、点击关闭按钮、点击蒙层、按下 ESC 等场景下触发
    onCloseBtnClick = noop, // 点击右上角关闭按钮时触发
    onOverlayClick = noop, // 如果蒙层存在,点击蒙层时触发
    onConfirm = noop, // 如果“确认”按钮存在,则点击“确认”按钮时触发,或者键盘按下回车键时触发
    preventScrollThrough, // 防止滚动穿透
    closeBtn, // 关闭按钮,可以自定义。值为 true 显示默认关闭按钮,值为 false 不显示关闭按钮。值类型为 string 则直接显示值,如:“关闭”。值类型为 TNode,则表示呈现自定义按钮示例
    closeOnEscKeydown, // 按下 ESC 时是否触发对话框关闭事件
    confirmOnEnter, // 是否在按下回车键时,触发确认事件
    closeOnOverlayClick, // 点击蒙层时是否触发关闭事件
    destroyOnClose, // 是否在关闭弹框的时候销毁子元素
    showInAttachedElement, // 仅在挂载元素中显示抽屉,默认在浏览器可视区域显示。父元素需要有定位属性,如:position: relative
  } = props;
  const wrap = useRef<HTMLDivElement>(); // 挂载到包裹弹框的dom上,包裹了好几层。。。
  const dialog = useRef<HTMLDivElement>(); // 引用弹窗dom
  const dialogPosition = useRef<HTMLDivElement>(); // 包裹弹窗,用于定位的dom引用
  const maskRef = useRef<HTMLDivElement>(); // 蒙层的dom引用
  const bodyOverflow = useRef<string>();
  const bodyCssTextRef = useRef<string>();
  const contentClickRef = useRef(false);
  const isModal = mode === 'modal';
  const isNormal = mode === 'normal';
  const canDraggable = props.draggable && mode === 'modeless';
  const dialogOpenClass = `${prefixCls}__${mode}`;
  useLayoutEffect(() => {
    bodyOverflow.current = document.body.style.overflow;
    bodyCssTextRef.current = document.body.style.cssText;
  }, []);
  useLayoutEffect(() => {
    if (visible) {
      if (isModal && bodyOverflow.current !== 'hidden' && preventScrollThrough && !showInAttachedElement) {
        const scrollWidth = window.innerWidth - document.body.offsetWidth;
        // 减少回流
        if (bodyCssTextRef.current === '') {
          let bodyCssText = 'overflow: hidden;';
          if (scrollWidth > 0) {
            bodyCssText += `position: relative;width: calc(100% - ${scrollWidth}px);`;
          }
          document.body.style.cssText = bodyCssText;
        } else {
          if (scrollWidth > 0) {
            document.body.style.width = `calc(100% - ${scrollWidth}px)`;
            document.body.style.position = 'relative';
          }
          document.body.style.overflow = 'hidden';
        }
      }
      if (wrap.current) {
        wrap.current.focus();
      }
    } else if (isModal) {
      const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
      if (openDialogDom.length < 1) {
        document.body.style.cssText = bodyCssTextRef.current;
      }
    }
    // 组件销毁后重置 body 样式
    return () => {
      if (isModal) {
        // 此处只能查询 mode 模式的 dialog 个数 因为 modeless 会点击透传 normal 是正常文档流
        const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
        if (openDialogDom.length < 1) {
          document.body.style.cssText = bodyCssTextRef.current;
          document.body.style.overflow = bodyOverflow.current;
        }
      } else {
        document.body.style.cssText = bodyCssTextRef.current;
        document.body.style.overflow = bodyOverflow.current;
      }
    };
  }, [preventScrollThrough, attach, visible, mode, isModal, showInAttachedElement, prefixCls]);
  const onAnimateLeave = () => {
    if (wrap.current) {
      wrap.current.style.display = 'none';
    }
    if (isModal && preventScrollThrough) {
      // 还原 body 的滚动条
      const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
      if (isModal && openDialogDom.length < 1) {
        document.body.style.overflow = bodyOverflow.current;
      }
    }
    if (!isModal) {
      // 关闭弹窗 清空拖拽设置的相关 css
      const { style } = dialog.current;
      style.position = 'relative';
      style.left = 'unset';
      style.top = 'unset';
    }
    onClosed && onClosed();
  };
  const onMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
    if (showOverlay && (closeOnOverlayClick ?? local.closeOnOverlayClick)) {
      // 判断点击事件初次点击是否为内容区域
      if (contentClickRef.current) {
        contentClickRef.current = false;
      } else if (e.target === dialogPosition.current) {
        onOverlayClick({ e });
        onClose({ e, trigger: 'overlay' });
      }
    }
  };
  const handleCloseBtnClick = (e: React.MouseEvent<HTMLDivElement>) => {
    onCloseBtnClick({ e });
    onClose({ e, trigger: 'close-btn' });
  };
  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
    if (e.key === 'Escape') {
      e.stopPropagation();
      onEscKeydown({ e });
      if (closeOnEscKeydown ?? local.closeOnEscKeydown) {
        onClose({ e, trigger: 'esc' });
      }
    } else if (e.key === 'Enter' || e.key === 'NumpadEnter') {
      // 回车键触发点击确认事件
      e.stopPropagation();
      if (confirmOnEnter) {
        onConfirm({ e });
      }
    }
  };
  // 渲染Dialog主体
  const renderDialog = () => {
    const dest: any = {};
    // 把width变为有px结尾的字符串
    if (props.width !== undefined) {
      dest.width = GetCSSValue(props.width);
    }
    // normal 场景下,需要设置 zindex 为auto 避免出现多个 dialog,normal 出现在最上层
    if (props.mode === 'normal') {
      dest.zIndex = 'auto';
    }
    // 获取footer
    const footer = props.footer ? <div className={`${prefixCls}__footer`}>{props.footer}</div> : null;
    // 获取header
    const { header } = props;
    // 获取Dialog body
    const body = <div className={`${prefixCls}__body`}>{props.body || props.children}</div>;
    // 关闭按钮,可以自定义。值为 true 显示默认关闭按钮,值为 false 不显示关闭按钮。值类型为 string 则直接显示值,如:“关闭”。
    const closer = closeBtn && (
      <span onClick={handleCloseBtnClick} className={`${prefixCls}__close`}>
        {closeBtn}
      </span>
    );
    const validWindow = typeof window === 'object';
    // 获取屏幕高度
    const screenHeight = validWindow ? window.innerHeight || document.documentElement.clientHeight : undefined;
    // 获取屏幕宽度
    const screenWidth = validWindow ? window.innerWidth || document.documentElement.clientWidth : undefined;
    // 设置style
    const style = { ...dest, ...props.style };
    let dialogOffset = { x: 0, y: 0 };
    // 拖拽代码实现部分
    const onDialogMove = (e: MouseEvent) => {
      // offsetWidth是指元素的宽 + padding + border的总和
      const { style, offsetWidth, offsetHeight } = dialog.current;
      // diffX是指弹框部分距离body左边部分
      let diffX = e.clientX - dialogOffset.x;
      let diffY = e.clientY - dialogOffset.y;
      // 拖拽上左边界限制
      if (diffX < 0) diffX = 0;
      if (diffY < 0) diffY = 0;
      // 右边的限制
      if (screenWidth - offsetWidth - diffX < 0) diffX = screenWidth - offsetWidth;
      // 下边的限制
      if (screenHeight - offsetHeight - diffY < 0) diffY = screenHeight - offsetHeight;
      style.position = 'absolute';
      style.left = `${diffX}px`;
      style.top = `${diffY}px`;
    };
    const onDialogMoveEnd = () => {
      // 恢复指针样式为默认,并且注销mousemove, mouseup事件
      dialog.current.style.cursor = 'default';
      document.removeEventListener('mousemove', onDialogMove);
      document.removeEventListener('mouseup', onDialogMoveEnd);
    };
    // 拖拽开始,对应mouseDown事件
    const onDialogMoveStart = (e: React.MouseEvent<HTMLDivElement>) => {
      contentClickRef.current = true;
      // 阻止事件冒泡, mode === 'modeless才能拖拽
      if (canDraggable && e.currentTarget === e.target) {
        const { offsetLeft, offsetTop, offsetHeight, offsetWidth } = dialog.current;
        // 如果弹出框超出屏幕范围 不能进行拖拽
        if (offsetWidth > screenWidth || offsetHeight > screenHeight) return;
        // 拖拽样式设置为move
        dialog.current.style.cursor = 'move';
        // 计算鼠标 e.clientX是鼠标在屏幕的坐标,offsetLeft是Dialog主体跟body的距离
        // 所以e.clientX - offsetLeft就是鼠标在是Dialog主体上的横坐标
        const diffX = e.clientX - offsetLeft;
        const diffY = e.clientY - offsetTop;
        dialogOffset = {
          x: diffX,
          y: diffY,
        };
        // 此时把mousemove和mouseup事件也绑定一下,其实不建议绑定在这里直接操作dom
        document.addEventListener('mousemove', onDialogMove);
        document.addEventListener('mouseup', onDialogMoveEnd);
      }
    };
    // 顶部定位实现
    const positionStyle: any = {};
    if (props.top) {
      const topValue = GetCSSValue(props.top);
      positionStyle.paddingTop = topValue;
    }
    // 此处获取定位方式 top 优先级较高 存在时 默认使用 top 定位
    const positionClass = classnames(
      `${prefixCls}__position`,
      { [`${prefixCls}--top`]: !!props.top },
      `${props.placement && !props.top ? `${prefixCls}--${props.placement}` : ''}`,
    );
    const dialogElement = (
      <div className={isNormal ? '' : `${prefixCls}__wrap`}>
        <div className={isNormal ? '' : positionClass} style={positionStyle} onClick={onMaskClick} ref={dialogPosition}>
          <div
            ref={dialog}
            style={style}
            className={classnames(`${prefixCls}`, `${prefixCls}--default`)}
            onMouseDown={onDialogMoveStart}
          >
            <div className={classnames(`${prefixCls}__header`)}>
              {header}
              {closer}
            </div>
            {body}
            {footer}
          </div>
        </div>
      </div>
    );
    return (
      <CSSTransition
        in={props.visible}
        appear
        mountOnEnter
        unmountOnExit={destroyOnClose}
        timeout={transitionTime}
        classNames={`${prefixCls}-zoom`}
        onEntered={props.onOpened}
        onExited={onAnimateLeave}
        nodeRef={dialog}
      >
        {dialogElement}
      </CSSTransition>
    );
  };
  const renderMask = () => {
    let maskElement;
    if (showOverlay) {
      maskElement = (
        <CSSTransition
          in={visible}
          appear
          timeout={transitionTime}
          classNames={`${prefixCls}-fade`}
          mountOnEnter
          unmountOnExit
          nodeRef={maskRef}
        >
          <div ref={maskRef} className={`${prefixCls}__mask`} />
        </CSSTransition>
      );
    }
    return maskElement;
  };
  const render = () => {
    const style: CSSProperties = {};
    if (visible) {
      style.display = 'block';
    }
    const wrapStyle = {
      ...style,
      zIndex,
    };
    const dialogBody = renderDialog();
    const wrapClass = classnames(
      props.className,
      `${prefixCls}__ctx`,
      !isNormal ? `${prefixCls}__ctx--fixed` : '',
      visible ? dialogOpenClass : '',
      isModal && showInAttachedElement ? `${prefixCls}__ctx--absolute` : '',
      props.mode === 'modeless' ? `${prefixCls}__ctx--modeless` : '',
    );
    // 如果不是 modal 模式 默认没有 mask 也就没有相关点击 mask 事件
    const dialog = (
      <div ref={wrap} className={wrapClass} style={wrapStyle} onKeyDown={handleKeyDown} tabIndex={0}>
        {mode === 'modal' && renderMask()}
        {dialogBody}
      </div>
    );
    let dom = null;
    if (visible || wrap.current) {
      // normal 模式 attach 无效
      if (attach === '' || isNormal) {
        dom = dialog;
      } else {
        dom = (
          <CSSTransition
            in={visible}
            appear
            timeout={transitionTime}
            mountOnEnter
            unmountOnExit={destroyOnClose}
            nodeRef={portalRef}
          >
            <Portal attach={attach} ref={portalRef}>
              {dialog}
            </Portal>
          </CSSTransition>
        );
      }
    }
    return dom;
  };
  return render();
});
RenderDialog.defaultProps = dialogDefaultProps;
export default RenderDialog;

结束,react组件库继续搞起!

以上就是比ant更丰富Modal组件功能实现示例详解的详细内容,更多关于ant Modal组件功能的资料请关注我们其它相关文章!

(0)

相关推荐

  • 解决Ant Design Modal内嵌Form表单initialValue值不动态更新问题

    场景描述: 如下图所示,点击减免天数会出现一个弹窗, 输入天数后点击确定,保存这个值, 但是我在点第二行的减免天数的时候初始应该是空的, 可是现在显示的是第一行输入的值: <Modal title="减免天数" visible={that.state.visible} onOk={that.handleOk.bind(that)} onCancel={that.handleCancel} > <Form horizontal form={form}> <F

  • React弹窗使用方式NiceModal重新思考

    目录 一些有趣的真实场景 案例一:全局弹窗 案例二:存在分支以及依赖关系的弹窗 不那么通用的解决方案 可能是最好的弹窗实践 创建弹窗 使用弹窗 简单实现思路 推荐阅读 一些有趣的真实场景 在开始进入正题之前,先看看一些与弹窗有关的有趣场景

  • react中使用antd及immutable示例详解

    目录 一.react中使用antd组件库 二.Immutable 2.1 深拷贝和浅拷贝的关系 2.2 immutable优化性能方式 2.3 immutable的Map使用 2.4 immutable的List使用 2.5 实际场景formJS 三.redux中使用immutable 一.react中使用antd组件库 运行命令create-react-app antd-react创建新项目: 运行命令npm i antd安装: 使用: import React from 'react' im

  • React实现antdM的级联菜单实例

    目录 效果图 需求分析 body head 项目结构 实现body部分 实现head部分 整合head与body 效果图 需求分析 级联菜单分为两部分:head与body. body 包含两部分:已选项列表,候选菜单 已选项列表 body展示当前菜单的所有option,可上下滚动. body中选一个option后,会在head的已选列表中进行展示,并且body将显示下一级的菜单. 选中的option,背景色和字体需要改变,以示区分. 候选菜单 依次显示每个选中的option,当所有option的

  • react最流行的生态替代antdpro搭建轻量级后台管理

    目录 前言 项目初始化 数据请求 + mock 配置 axios 配置 react-query mock 路由权限配置 路由文件 main.tsx App.tsx 页面编写 login 页面 BasicLayout 动态菜单栏 封装页面通用面包屑 总结 前言 你是否经历过公司的产品和 ui 要求左侧菜单栏要改成设计图上的样子? 苦恼 antd-pro 强绑定的 pro-layout 菜单栏不能自定义?你可以使用 umi,但是就要根据它的约定来开发,捆绑全家桶等等.手把手教你搭一个轻量级的后台模版

  • Modal.confirm是否违反了React模式分析

    目录 引言 什么是“React模式”? 引言 如何评价 Ant Design 这个项目(一个设计语言)? 这是一篇临时起意的文章,我参与了一点上图中的讨论,正好有点时间,索性拉篇文章专门聊聊. 首先说结论:我不认为Modal.confirm以及类似的API是anti-pattern,尽管antd作者 @偏右悄悄地 也说Modal.confirm“并不符合React哲学”. 什么是“React模式”? 很多人都见过这个公式—— view = f(data) React确实就是这么运作的. 然而Re

  • 比ant更丰富Modal组件功能实现示例详解

    目录 有哪些比ant更丰富的功能 render部分 渲染黑色蒙层 渲染弹框主体 设置body overflow:hiiden 有哪些比ant更丰富的功能 普通的modal组件如下: 我们写的modal额外支持,后面没有蒙版,并且Modal框能够拖拽 还支持渲染在文档流里,上面的都是fixed布局,我们这个正常渲染到文档下面: render部分 <RenderDialog {...restState} visible={visible} prefixCls={prefixCls} header={

  • React元素与组件的区别示例详解

    目录 从问题出发 元素与组件 元素 组件 问题如何解决 自定义内容 第一种实现方式 第二种实现方式 第三种实现方式 从问题出发 我被问过这样一个问题: 想要实现一个 useTitle 方法,具体使用示例如下: function Header() { const [Title, changeTitle] = useTitle(); return ( <div onClick={() => changeTitle('new title')}> <Title /> </div

  • 微前端之Web组件自定义元素示例详解

    目录 我们知道的 Web组件使用 名称规范 组件传参数并可以写模板包括js和css Shadow Dom 影子节点 类中的构造函数和钩子函数 getter/setter属性和属性反射 扩展原生 HTML 我们知道的 第一:我们熟知的HTML标签有 a, p, div, section, ul, li, h2, article, head, body, strong, video, audio 等等 第二:我们知道,a标签是链接,p标签是段落,div是块级,h2是字体,strong 是粗体,vid

  • Java实现图片裁剪功能的示例详解

    目录 前言 Maven依赖 代码 验证一下 前言 本文提供将图片按照自定义尺寸进行裁剪的Java工具类,一如既往的实用主义. Maven依赖 <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>30.1.1-jre</version> </dependency> <dependen

  • Ajax实现上传图像功能的示例详解

    最终效果展示 xhr发起请求 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="widt

  • go语言定时器Timer及Ticker的功能使用示例详解

    目录 定时器1-"*/5 * * * * *" 设置说明 定时器2-Timer-Ticker Timer-只执行一次 Ticker-循环执行 Timer延时功能 停止和重置定时器 定时器Ticker使用 定时器1-"*/5 * * * * *" package main import ( "fmt" "github.com/robfig/cron" ) //主函数 func main() { cron2 := cron.New

  • Vue实现高德坐标转GPS坐标功能的示例详解

    首先介绍一下常见的几种地图的坐标类型: WGS-84:这是一个国际标准,也就是GPS坐标(Google Earth.或者GPS模块采集的都是这个类型). GCJ-02:中国坐标偏移标准,像是Google Map.高德.腾讯地图都是采用这种坐标展示. BD-09:百度坐标偏移标准,百度地图专用的便宜标准. 所以说这篇博文主要是实现GCJ-02坐标转换成WGS-84坐标. 什么时候会用到需要解决坐标转换的问题呢?起因是一个demo,它使用GPS模块采集经纬度数据,然后使用高德地图进行转换,是的,高德

  • SQL实现Excel的10个常用功能的示例详解

    目录 01. 关联公式:Vlookup 02. 对比两列差异 03. 去除重复值 04. 缺失值处理 05. 多条件筛选 06. 模糊筛选数据 07. 分类汇总 08. 条件计算 09. 删除数据间的空格 10. 合并与排序列 SQL笔试题原题 某数据服务公司 某手游公司的SQL笔试题(原题) 某互联网金融公司SQL笔试题(原题) SQL,数据分析岗的必备技能,你可以不懂Python,R,不懂可视化,不懂机器学习.但SQL,你必须懂.要不然领导让你跑个数据来汇......,哦不,你不懂SQL都无

  • svgicon组件使用方法示例详解

    目录 场景 编写SvgIcon组件 组件文件结构 icons文件结构 vue.config.js配置 最终效果 场景 最近在研发产品的过程中,ued切了很多svg的图片:咱们在使用过程中除了背景图再就是使用<img :src="url"/>进行使用. 在你进行公共组件编写的时候,使用图片路径这种方式编写完组件发布之后:在再项目中引入已发布的组件,在你运行代码的时候图片路径会附带上当前运行的域名,导致图片显示不出来. 那么怎么解决这种问题呢? 和UED进行沟通让他们把这种sv

  • Python实现邮件发送功能的示例详解

    想实现发送邮件需要经过以下几步: 1.登录邮件服务器 2.构造符合邮件协议规则要求的邮件内容 3.发送 Python对SMTP支持有smtplib和email两个模块,email负责构造邮件,smtplib负责发送邮件,它对smtp协议进行了简单的封装. 这里我们用qq邮箱为例,并且自己是可以给自己发邮件的. 在开始前我们先做准备工作: 登录qq邮箱,后点击“设置” 点击“账户” 确保前两项已开启,然后点击生成授权码. 因为我们网页登录时的密码是不可以用来python上使用:qq为了安全,我们平

随机推荐