两周一个小组件之Dialog组件

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

前言

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

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

DOM元素

在我们常见的 Dialog 组件中,其 DOM 结构主要包含三个角色 wrappercontentmask

它们的 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;
 }
复制代码

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

使用 react-domAPI 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可见时,自动 focusDialog 上。
  2. Dialog 隐藏时,焦点自动回到页面的上一个 focus 元素上。
  3. 阻止通过 TAB 键切换到 Dialog 之外的元素。

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

实现功能点2则需要我们在实现功能1之前,先保存上一个 focus 的元素。 利用 document.activeElement ,可以轻松获取当前页面的 active 元素。先在切换至 Dialog 之前保存起来,直到 Dialogviable 为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 的动画特性,animationtransition

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);
    }
  }
复制代码

实现向某个位置的方向变化,首先需要获取点击事件的位置 positionx 值和 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);
  }
}
复制代码

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

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

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

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

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

总结

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

Guess you like

Origin juejin.im/post/7031493060806574094