【Day12】数据展示元件 - Tooltip

元件介绍

Tooltip 是一个文字弹出提醒元件,当 active 状态时,会显示对该子元件描述的文字。

参考设计 & 属性分析

位置

相对於被包覆的子元件,Tooltip 可设定其出现的位置共有 12 种,分别为子元件的:

  • 上左、上、上右
  • 左上、左、左下
  • 右上、右、右下

MUI 以及 Antd 的 props 同样都是 placement。

颜色

MUI 透过 withStyles 可以客制化 Tooltip 的背景颜色、文字颜色、字体大小、编筐样式...等等属性,在外观上面是蛮有弹性的。

const LightTooltip = withStyles((theme) => ({
  tooltip: {
    backgroundColor: theme.palette.common.white,
    color: 'rgba(0, 0, 0, 0.87)',
    boxShadow: theme.shadows[1],
    fontSize: 11,
  },
}))(Tooltip);

Antd 则直接提供一个 color 的 props 属性来改变其背景颜色。跟 Antd 其他的元件一样,除了可以直接传入色票之外,也有其提供预设的保留字来改变颜色。

<Tooltip color="#108ee9">
  {children}
</Tooltip>

手动控制是否显示

预设的 Tooltip 是 hover 上去会弹出提醒文字,但其实也提供能透过参数传入来控制弹出时机的 props,在 MUI 是使用 open 这个 props;在 Antd 则是使用 visible 这个 props,都是用 boolean 来控制。

提示文字

title 显示是 Tooltip 的内容 props,在 MUI 及 Antd 中,title 的型别不只是 string ,甚至也可以是 ReactNode,因此能够直接传入一个 jsx,让我们在内容上支援比较大的变化。

arrow

在 Antd 的 Tooltip 中,会自带一个箭头锚点,指向其 children,而 MUI 的箭头锚点并不是预设就会出现,而是需要将 arrow 这个 props 设为 true 才会出现。

那到底要怎麽实现 Tooltip 这种的箭头锚点呢?我们打开检视 Html 原始码来看一下 MUI 及 Antd 的实作方式,两者的实作方式其实差不多,都是将一个矩形旋转 45 度,让他只露出一个角角在外面,这样就能够看起来像是一个箭头锚点了!

MUI 的箭头锚点

Antd 的箭头锚点

程序结构

另外一个值得注意的事是,我们观察一下 Tooltip 的 Html 的结构,我们可以发现不管是 MUI 或是 Antd ,他都不会直接把 Tooltip 放在 <div id="root">...</div> 这个 React 根节点下面的子节点,而是会把他 render 在 body tag 下面,正好跟 React 的根节点 div id="root" /> 是同一层。

明明我们在 React 程序里面,Tooltip 的元件跟其 children 元件就是写在旁边而已,所以我们原本想像 Tooltip 实作的程序结构会是长这样:

<body>
  <div id="root">
    <TooltipWrapper>
      {children}
      <TooltipBody />
    </TooltipWrapper>
  </div>
</body>

但没想到实际上发现 MUI 及 Antd 却把它做成像类似下面这样:

<body>
  <div id="root">
    {children}
  </div>
  <TooltipWrapper>
    <TooltipBody />
  </TooltipWrapper>
</body>

这样的手法我们在 React 里面叫做 Portal ,官网上是这麽描述的:

Portal 提供一个优秀方法来让 children 可以 render 到 parent component DOM 树以外的 DOM 节点。

到底为什麽要把简单的事情搞得这麽复杂呢?而且居然 MUI 及 Antd 都一起做了一样的事,但我们仔细想一想,其实就能够体会他们的用心良苦。

我们思考看看,当我们在实作 Tooltip 的时候,由於 Tooltip 是一个弹出的提醒元件,我们也不希望这个提醒元件弹出的时候,去挤压到其他周围的元件,因此我们通常会把 Tooltip 的 css position 属性设为 absolute,意思有点像是说,我们把目标元件跟 Tooltip 放置在不同的图层,因此既然 Tooltip 跟其他人是在不同的图层,那他当然不会去挤压到其他的元件。

但是当 Tooltip 被放置在不同的图层时,就会延伸出另一个问题,到底哪个图层在上面,哪个在下面?特别是当我们整个专案的 DOM 变得非常的庞大和复杂的时候,概念上有可能在一个页面上会有多个图层,所以我们很容易会发生我们希望出现在上面图层的元件,却被盖在下面,因此这时大家通常的做法会是透过 z-index 来调整图层的上下关系。可是当一个画面复杂的程度到我们难以去分辨谁在上面谁在下面的时候,就算把 z-index 调到 9999,也无法让 Tooltip 所在的图层往上提升而不被盖住。因为决定哪个图层在上面,并不是单纯的比较 z-index 谁比较大的这种比大小的关系,而是会需要了解相关的堆叠环境(Stacking Context)。

参考:https://ithelp.ithome.com.tw/articles/10217945

因此,为了避免这些常常困扰大家的问题,乾脆就把 Tooltip 元件 Portal 到外面去,藉此来简化我们的堆叠环境。

介面设计

属性 说明 类型 默认值
placement 出现位置 top, left, right, bottom, topLeft, topRight, bottomLeft, bottomRight, leftTop, leftBottom, rightTop, rightBottom top
themeColor 颜色 primary, secondary, 色票 primary
content 提示文字 element, string
children 需要弹出提示字的子元件 element, string
showArrow 是否显示箭头锚点 boolean false

元件实作

Portal 元件

为了让 Tooltip 可以 render 到 parent component DOM,我们先来准备一个 Portal 元件,我希望未来用如下的方式就能够做到 Portal:

<Portal customRootId="tooltip-root">
  {/*...想要被 render 到外面的元件...*/}
</Portal>

我希望可以传入一个 custom root id 来当作 Portal 根节点的 id,这样我可以用 id 来决定我们要把元件 Portal 到外面的哪一个根节点下面,若不给定 customRootId,则会给他一个 default 的 id。

<body>
  <div id="root">...</div>
  <div id="tooltip-root">
    {...}
  </div>
</body>

这个元件最核心的东西也是 ReactDOM.createPortal(child, container) 而已,只是我们把它做一些小加工。

简单说明一下 ReactDOM.createPortal(child, container):

  • 第一个参数(child)是任何可 render 的 React child,例如 element、string 或者 fragment。
  • 第二个参数(container)则是一个 DOM element。

按照上面所述,我改造过的 Portal 小元件如下,主要的逻辑是我会先找找看我想要 render 的 Portal container 存不存在,若不从在就创一个,若存在就存取既有的,避免有两个根节点有同样的 id:

const Portal = ({ children, customRootId }) => {
  let portalRoot;
  const rootId = customRootId || 'portal-root';

  if (document.getElementById(rootId)) {
    portalRoot = document.getElementById(rootId);
  } else {
    const divDOM = document.createElement('div');
    divDOM.id = rootId;
    document.body.appendChild(divDOM);
    portalRoot = divDOM;
  }

  return ReactDOM.createPortal(
    children,
    portalRoot,
  );
};

Placement 出现位置

搭配使用 Portal 元件,我们来看一下 Tooltip 的结构长相:

<>
  <span>{children}</span>
  <Portal>
    <TooltipWrapper>
      {content}
    </TooltipWrapper>
  </Portal>
</>

因为 Tooltip 已经被 Portal 到 parent component 去了,所以如果要让 Tooltip 出现在我们希望的子元件旁边,就没有办法用平常的方法来定位。

我使用的定位方式是透过 useRef 来取得 children 相对於整个视窗的位置,然後再让 Tooltip 能够根据这个位置做一些 Placement 的变化。

由於我希望在视窗改变的时候,若位置有改变也会跟着调整,因此这边使用了监听的函式,在视窗 resize 的时候更新 children 的位置。

const handleOnResize = () => {
  setChildrenSize({
    width: childrenRef.current.offsetWidth,
    height: childrenRef.current.offsetHeight,
  });
  setPosition({
    top: childrenRef.current.getBoundingClientRect().top,
    left: childrenRef.current.getBoundingClientRect().left,
  });
};

useEffect(() => {
  handleOnResize();
  window.addEventListener('resize', handleOnResize);
  return () => {
    window.removeEventListener('resize', handleOnResize);
  };
}, []);

拿到 children position 以及 children size 之後,我们就能够做一些位置上的变化啦!先展示一下辛苦的成果:

为了做到像上面这样位置上的变化,会需要一些数学的计算。
首先我们要知道我们取得的 children position 是 children 元件左上角的那个点,是元素「相对於视窗」的座标:


https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect

[Shubo 的程序教学笔记] Element.getBoundingClientRect()
https://shubo.io/get-bounding-client-rect/

因为我们总共有 12 种 Placement ,因此我举几个计算的例子,其他依此类推:

  1. Top Left

Y 轴方向,因为我们在操作 Tooltip 的位置也是以左上角的点为座标原点,所以我们的 Tooltip 位置公式如下:

(Tooltip 高度) + (Tooltip 与 children 的间距)

其中 Tooltip 高度就用 transform: translateY(-100%) 来计算即可,间距就跟设计师讨论要多少,这边我先自己随便抓个感觉。

X 轴方向,因为是 chidren 跟 Tooltip 对齐,所以就不用动。

  1. Bottom Right

Y 轴方向是往下移动一个 children 的高度加上间距,因此公式如下:

(children 高度) + (Tooltip 与 children 的间距)

X 轴方向是往右一个 children 的宽度,之後再扣回来一个 Tooltip 宽度,这样 Tooltip 的右边缘才能切齐 children 的右边缘,其中 Tooltip 的宽度一样是用 transform: translateX(-100%) 来做调整:

(children 宽度) - (Tooltip 宽度)

  1. Top

Top 的部分,因为 X 轴的部分跟前面第一题是一样的,就不重复说明,所以直接说明 X 轴的算法。

X 轴方向是从座标原点往右「半个」children 宽度,然後再往左「半个」Tooltip 宽度,一样 Tooltip 的宽度都是用 translateX 来处理,这样就能够让 Tooltip 与 children 在 X 轴方向置中对齐了:

(children 宽度 / 2) - (Tooltip 宽度 / 2)

其他 Placement 就可以根据上述例子依此类推,就够顺利完成 12 种 Placement 了。

Show Arrow 是否显示箭头样式

Show Arrow 实作的秘密我们已经在文章前面分析过了,主要的原理是透过一个矩型旋转,让他露出一个角角就可以了。
我们实作的结构如下:

<>
  <span>{children}</span>
  <Portal>
    <TooltipWrapper>
      {content}
      {showArrow && (
        <div className="tooltip__arrow">
          <div className="tooltip__arrow-content" />
        </div>
      )}
    </TooltipWrapper>
  </Portal>
</>

箭头元件的设计我是给他一个父层包覆子层的结构,外面父层 tooltip__arrow 我会把它设为 position: absolute;,主要是用来作定位的用途,因此跟上面 Placement 一样,会根据不同的方位来做定位。

子层 tooltip__arrow-content 是决定箭头的形状,我的例子是用 8 x 8 的方形,然後将他旋转 45 度;当然我们的箭头颜色需要跟 Tooltip body 的背景颜色是一样的,才不会露出马脚。

.tooltip__arrow-content {
  width: 8px;
  height: 8px;
  transform: rotate(45deg);
  background: ${(props) => props.$color};
}

这边我把箭头换个明显的红色,给大家看一下露出马脚的样子,其实自己在实作的时候,会故意把它变成明显的颜色,因为这样在微调位置的时候比较方便 debug,微调位置跟前面是一样的套路,就是根据长宽来算数学,定位方式是在父层 tooltip__arrow 设定 toprightleftbottomtranslate(XPos, YPos),调到你觉得适合的位置就可以了,详细的部分我写在 code 里面分享给大家。

其实我这个作法是比较偷懒的做法,因为我没有刻意处理箭头跟 Tooltip 重叠的地方,因为我觉得 Tooltip 会有一个 Padding 的宽度,所以我就没有特别处理。

但是我们观察 Antd 的 Arrow 还蛮高招的,但比我目前的方法麻烦一些,我故意把它关键的 element 设为红色给大家看

我用红色框框来标示他看不见的元件 ant-tooltip-arrow,依上图来看这是他结构的父层,而 ant-tooltip-arrow-content 是箭头的本体,如下图示意,当 ant-tooltip-arrow-content 旋转之後,超出红色框框 ant-tooltip-arrow 的地方就用 overflow: hidden; 来隐藏,这样的话就能够画出一个三角形角角,而把他跟 Tooltip 结合之後,就不会有我上面那种重叠的问题了。

显示与消失

我这个 Tooltip 只有做到 mouseover 和 mouseleave 会显示跟隐藏,没有做其他事件的触发,例如 click 事件等等,若有需要,可以再自行追加。

我的做法也很简单,就是直接给他这两个滑鼠事件,mouseover 的时候就把 state 设为 显示,mouseleave 的时候就把 state 设为隐藏,就是这麽直白:

const [isVisible, setIsVisible] = useState(false);

<span
  ref={childrenRef}
  onMouseOver={() => setIsVisible(true)}
  onMouseLeave={() => setIsVisible(false)}
>
  {children}
</span>

显示和隐藏的部分,我就给他一个小动画

const TooltipWrapper = styled.div`
  {/* ...省略其他 css */}
  animation: ${(props) => (props.$isVisible ? fadeIn : fadeOut)} .3s ease-in-out forwards;
`;

动画的部分我也先简单处理,用 styled-components 提供的 keyframes 来做,就是改变他的 opacity ,让他有点淡入淡出的效果就好,如果有需要很炫炮的动画,可以再自行追加:

const fadeIn = keyframes`
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
`;

const fadeOut = keyframes`
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
`;

以上就是我们简易的手刻 Tooltip 了啦!其实东西有点多,但这些关键步骤我们往後还会有需多元件会用到一样的手法,所以之後还可以再透过别的元件多熟悉。


Tooltip 元件原始码:
Source code

Portal 元件原始码:
Source code

Storybook:
Tooltip


参考

React 官网 - Portal
https://zh-hant.reactjs.org/docs/portals.html

MDS Web Docs - Element.getBoundingClientRect()
https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect

[Shubo 的程序教学笔记] Element.getBoundingClientRect()
https://shubo.io/get-bounding-client-rect/


<<:  Day10 滚动式修正与涌浪规划

>>:  DAY25 MongoDB 自订角色与使用者

介面隔离原则 Interface Segregation Principles

最後,我们来到了 SOLID 当中的介面隔离原则。这里我们先举先前提到过的 BaseballPlay...

[想试试看JavaScript ] 函式运算式

函式 函式运算式 Function expression 函式运算式中文也叫函式表达式 上一篇了解到...

Nutrition Helper Part 2

流程图 运动 go! 由使用者传送位置讯息给 Line,Line 搭配之前提到过的开放地图,搜寻附近...

24 | 【进阶教学】什麽是 WordPress 区块组合套件外挂?

随着 WordPres 的区块功能不停地强化,市场上出现单个功能的区块 (Block),当然也有组...

[DAY 28] _看门狗简介_视窗看门狗(2)

昨天主要介绍了视窗看门狗和独立看门狗的差别,今天来看这如何计算,这计算方式再参考手册里面有举例说明,...