一个小组件之Dialog组件

1,213 阅读8分钟

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战。

前言

今天将会继续我们两周一个小组件的专题文章分享。本章要介绍的组件是 Dialog , 相信各位 coder 在日常的开发中都接触过这类的组件。许多主流的组件库提供了使用方便的 Dialog 组件,所以在这篇文章中我们将会介绍如何写一个简易的基于 react 的 Dialog 组件。

本篇文章将会从构成一个 Dialog 组件的三个主要部分展开介绍,它们分别是 DOM 元素、事件处理、动画效果。

DOM元素

在我们常见的 Dialog 组件中,其 DOM 结构主要包含三个角色 wrapper、 content 、mask;

它们的 html 结构如下:

        <!--mask-->
        <Mask prefixCls={this.prefixCls} visible={mask && visible} />
        <!--wrapper-->
        <div
          ref={this.wrapperRef}
          role="dialog"
         >
        <!--content-->
          <Content
             ...
          >
            {children}
          </Content>
        </div>

Dom元素示意图.jpg

mask

mask 层作为作为 Dialog 遮罩层,提供一个半透明的元素覆盖网页的可视区域,其主要作用只是提供更好的视觉效果,实际上该层元素在整个 Dialog 层级最低,并不存在任何实际作用(因此 mask 往往是 可选的)。 实现一个 mask 可以通过设置 css 简单完成;通过设置为 position: fixed 使其固定在视窗上;

&-mask {
  position: fixed;
  top: 0;
  right: 0;
  left: 0;
  bottom: 0;
  background-color: rgb(55, 55, 55);
  background-color: rgba(55, 55, 55, 0.6);
  height: 100%;
  filter: alpha(opacity=50);
  z-index: 1050;    
 }

wrapper

wrapper 层往往是被忽略的一层,但是实现一个完整的 Dialog 却是至关重要的。它是承载 Dialog 内容主体 content 的元素,并且在后面将要介绍的事件机制中也发挥着作用。它同样采用 fixed 定位,并且跟 mask 保持相同的 z-index, 但遵循 html 流,wrapper 层实际在 mask 层之上。

  &-wrapper {
    position: fixed;
    overflow: auto;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 1050;
    -webkit-overflow-scrolling: touch;
    outline: 0;
  }

content

content 层是 Dialog 组件的核心,负责渲染Dialog内具体的内容,并且也是后续事件机制和动画效果的主体。content 层基础样式设置比较简单:

.@{prefixCls} {
  position: relative;
  width: auto;
  margin: 10px;
 }

当 html 和 css 准备就绪后,需要把他们插入页面中,作为一个 Dialog 组件,往往是存在于 document 的外层,作为 body 标签的子元素。

使用 react-dom 的 API ReactDOM.createPortal 只需要简单的几行代码就可以实现将节点渲染到指定的 DOM 节点。

ReactDOM.createPortal(dialogDom, this.el);

不过在这之前我们需要先完成在 body 中创建一个子元素,将 className 设置为 dialog-root

class Dialog<P> extends React.Component<IDialog> {
  ...

  constructor(props: IDialog) {
    super(props);
    this.el = document.createElement('div');
    this.el.className = 'dialog-root';
  }

  componentDidMount() {
    document.body.appendChild(this.el);
  }

  componentWillUnmount() {
    document.body.removeChild(this.el);
  }

  ...
  
}

1637075751026.jpg 这样就完成了实现一个 Dialog 所需的 DOM 元素。

Dialog 居中展示

如果需要 dialog 居中的效果,只需要几行简单的 css 样式:

设置 wrapper 层 text-align: center ,并结合伪元素,应用 vertical-align: middle,使 content 层居中:

    &-wrapper--center {
      text-align: center;
      &::before {
        display: inline-block;
        width: 0;
        height: 100%;
        vertical-align: middle;
        content: '';
      }
    }
    
   &-wrapper-center .@{prefixCls} {
    top: 0;
    display: inline-block;
    text-align: left;
    vertical-align: middle;
  }

center.jpg

事件处理

事件处理是构成 Dialog 组件的核心,我们可以将这些事件根据它的作用大致分为两类:焦点管理和交互事件。

焦点管理

当在页面上呈现出 Dialog, 往往意味着我们页面的 focus 元素发生变化,所以在整个 Dialog 相关的焦点管理中,我们希望实现三个功能:

  1. 当 Dialog可见时,自动 focus 到 Dialog 上。
  2. 当 Dialog 隐藏时,焦点自动回到页面的上一个 focus 元素上。
  3. 阻止通过 TAB 键切换到 Dialog 之外的元素。

实现功能点1只需要在 Dialog 的 visible 属性变化为 true 时,调用 DOM 元素的 focus API 。

实现功能点2则需要我们在实现功能1之前,先保存上一个 focus 的元素。 利用 document.activeElement ,可以轻松获取当前页面的 active 元素。先在切换至 Dialog 之前保存起来,直到 Dialog 的 viable 为false,利用focus API 重新聚焦。

实现功能点3则需要借助两个空白占位元素,位于 content 层内,真实渲染内容的前后。

  const emptyStyle = { width: 0, height: 0, overflow: 'hidden', outline: 'none' };
  ...
  <div tabIndex={0} ref={emptyStartRef} style={emptyStyle} aria-hidden="true" />
   {dialogContent}
  <div tabIndex={0} ref={emptyEndRef} style={emptyStyle} aria-hidden="true" />

有了这样两个元素,实现控制 TAB 键切换就会很简单。只需要当按下 TAB 键将元素聚焦到其中一个元素时,通过 focus API 将聚焦元素转移至另外一个。这样就可以使无论怎么按 TAB 键,焦点元素只会在两个占位元素之间切换。

 const { activeElement } = document;

 if (activeElement === emptyEndRef.current) {
        emptyStartRef.current.focus();
  } else if (activeElement === emptyStartRef.current) {
        emptyEndRef.current.focus();
 }

实现以上三个功能的完整代码:

监听visible变化,切换焦点(功能1和功能2):

  const onVisibleChanged = (newVisible: boolean) => {
    if (newVisible) {
        lastOutSideActiveElementRef.current = document.activeElement as HTMLElement;
        contentRef.current?.focus();
    } else {
      if (lastOutSideActiveElementRef.current) {
        lastOutSideActiveElementRef.current.focus({ preventScroll: true });
        lastOutSideActiveElementRef.current = null;
      }
    }
  };

监听 wapper 层的键盘 TAB 键,控制焦点(功能3):

  function onWrapperKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
    // // keep focus inside dialog
    if (visible) {
    // TAB健 的 keyCode === 9
    if (e.keyCode === 9) {
       const { activeElement } = document;
       if (activeElement === emptyEndRef.current) {
        emptyStartRef.current.focus();
       } else if (activeElement === emptyStartRef.current) {
        emptyEndRef.current.focus();
       }
    }
    」
  }

交互事件

我们主要在 Dialog 上定义两个交互事件:

  1. 鼠标点击 Dialog 外的区域触发关闭。
  2. 键盘按键 ESC 触发关闭。

功能1需要监听 wrapper 层的点击事件,并执行 onDialogClose()。不过需要注意的一点是,content 层位于 wrapper 层内,点击 content 层也会触发 wrapper 层的点击事件。这显然不符合我们的预期,因此需要额外处理,屏蔽 content 层点击事件的影响:

    onWrapperClick = (e) => {
      if (wrapperRef.current === e.target) {
        onDialogClose(e);
      }
    };

功能2监听 wrapper 层的 按键事件,判断是否为 ESC :

  function onWrapperKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
    // ESC 键的keyCode === 27
    if (e.keyCode === 27) {
      e.stopPropagation();
      onDialogClose(e);
      return;
    }
  }

这样就把 Dialog 中所需的事件处理完成了。

动画效果

动画效果是构成 Dialog 的灵魂,附加动画效果的 Dialog 能够在视觉呈现得更加完美,我们主要实现两个动画特效:

  1. mask 层增加淡入淡出动画。
  2. content 层增加放大缩小动画。

实现动画效果需要用到 css3 的动画特性,animation 或 transition。

mask 淡入淡出

mask 的动画设置,主要是在 mask 出现和隐藏时,增加透明度 opacity从0到1和从1到0的过渡:

   &-fade-enter&-fade-enter-active,&-fade-appear&-fade-appear-active  {
      animation-name: rcDialogFadeIn;
      animation-play-state: running;
    }
  
    &-fade-leave&-fade-leave-active {
      animation-name: rcDialogFadeOut;
      animation-play-state: running;
    }
  
    @keyframes maskFadeIn {
      0% {
        opacity: 0;
      }
      100% {
        opacity: 1;
      }
    }
  
    @keyframes maskFadeOut {
      0% {
        opacity: 1;
      }
      100% {
        opacity: 0;
      }
    }

content 放大缩小

content 层的动画相对复杂些,我们希望呈现的效果是在 Dialog 出现时,从点击触发 Dialog 的位置逐渐放大到最终位置; Dialog 隐藏时,又从最终位置逐渐缩小到初始位置。如下图:

Nov-17-2021 18-28-07.gif

content 层的放大缩小的实现,只需要简单的控制动画属性,通过 transform: scale(0,0) 到 transform: scale(1,1) 实现按比例从0到1,从1到0的放大缩小:

  @keyframes dialogZoomIn {
    0% {
      opacity: 0;
      transform: scale(0, 0);
    }
    100% {
      opacity: 1;
      transform: scale(1, 1);
    }
  }
  @keyframes dialogZoomOut {
    0% {

      transform: scale(1, 1);
    }
    100% {
      opacity: 0;
      transform: scale(0, 0);
    }
  }

而实现向某个位置的方向变化,首先需要获取点击事件的位置 position 的 x 值和 y 值。我们通过定义一个点击事件监听的类,获取到每次点击事件的位置,并设置鼠标位置信息的有效时间,默认为100ms(这样能够兼容非点击事件触发 Dialog 的情景,只保留放大缩小的效果)。

// 点击事件监听类
export class MonitorClickEvent {
  constructor(state: IState) {
    this.init();
    // 设置鼠标位置信息的有效时间,默认为100ms
    this.time = state.time || 100;
  }
  private time: number;
  public mousePosition: { x: number; y: number } | null;
  // 监听点击事件,获取点击事件的位置
  getClickPosition = (e: MouseEvent) => {
     // 设置点击事件的位置,超过有效时间后置为 null
    this.mousePosition = {
      x: e.pageX,
      y: e.pageY,
    };
    setTimeout(() => {
      this.mousePosition = null;
    }, this.time);
  };

  init() {
    document.documentElement.addEventListener('click', this.getClickPosition, true);
  }
}

获取到点击事件的位置后,我们需要结合 css3 的 transform-origin 属性,该属性可以控制元素变形的原点。

通过点击事件位置和 content层本身的位置 top 和 left,可以计算出两者的偏移量,并将偏移量赋值给transform-origin 属性。 这样我们的放大缩小的动画,就会向相应的位置偏移,呈现出我们想要的动画效果。

contentStyle.transformOrigin = 
`${mousePosition.x - elementOffset.left}px 
${mousePosition.y - elementOffset.top}px`

以上便完成了 Dialog 所需的全部动画效果。

在真正实现过程中,为了使控制动画更加方便,还封装了 CSSTrasition 组件应用在其中。在之后的文章中会详细的介绍该组件,对文中使用的 css 动画也会进行更加深入详细的介绍。

总结

通过以上对构成 Dialog 组件的三个主要成分:DOM元素、事件处理、动画效果的详细介绍,以及贴出的部分主要代码,希望对你理解 Dialog 的实现原理以及动手自制简易的 Dialog 组件有所帮助,后续会有更多精彩的文章呈现,敬请期待吧!