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):
按照上面所述,我改造過的 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 ,因此我舉幾個計算的例子,其他依此類推:
Y 軸方向,因為我們在操作 Tooltip 的位置也是以左上角的點為座標原點,所以我們的 Tooltip 位置公式如下:
(Tooltip 高度) + (Tooltip 與 children 的間距)
其中 Tooltip 高度就用 transform: translateY(-100%)
來計算即可,間距就跟設計師討論要多少,這邊我先自己隨便抓個感覺。
X 軸方向,因為是 chidren 跟 Tooltip 對齊,所以就不用動。
Y 軸方向是往下移動一個 children 的高度加上間距,因此公式如下:
(children 高度) + (Tooltip 與 children 的間距)
X 軸方向是往右一個 children 的寬度,之後再扣回來一個 Tooltip 寬度,這樣 Tooltip 的右邊緣才能切齊 children 的右邊緣,其中 Tooltip 的寬度一樣是用 transform: translateX(-100%)
來做調整:
(children 寬度) - (Tooltip 寬度)
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
設定 top
、right
、left
、bottom
、translate(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/