アクセシビリティに考慮したツールチップを実装する
ツールチップとは、ある要素に対する補足情報を与える UI です。通常ある要素に対してマウスホバーまたはキーボードでフォーカスした時少しのディレイの後に、ユーザーの操作によらず自動的にポップアップして表示されます。このポップアップはユーザーの操作をブロッキングするものではありません。ユーザーがマウスのホバー外すかフォーカスが外れた場合にツールチップは非表示となります。
ツールチップとは、ある要素に対する補足情報を与える UI です。通常ある要素に対してマウスホバーまたはキーボードでフォーカスしたとき少しのディレイの後に、ユーザーの操作によらず自動的にポップアップして表示されます。このポップアップはユーザーの操作をブロッキングするものではありません。ユーザーがマウスのホバー外すかフォーカスが外れた場合にツールチップは非表示となります。
ツールチップに表示する説明はあくまで補足的なものであり、重要な情報を含んではいけません。ユーザーはツールチップの情報なしに操作を完了できる必要があります。
ツールチップは以下の要素から構成されます。
- tooltip ロール:マウスホバーや、キーボードでフォーカスした際に表示されるポップアップ。
- ツールチップをトリガーする要素。フォーカス可能である必要がある。
ツールチップの要件
ツールチップをアクセシブルにするためには、以下の実装をする必要があります。
ロール・ステート・プロパティ
- ツールチップのコンテナとなる要素に tooltip ロールを指定する
- ツールチップをトリガーする要素に aria-describedby でツールチップの内容を
キーボード操作
Escape
:ツールチップを閉じる
その他考慮事項
- ツールチップそのものはフォーカスを受け取らない。
- ツールチップはポップアップのように表示されるが、aria-haspopup プロパティにおけるポップアップとみなされない。そのため、ツールチップをトリガーする要素に
aria-haspopup
属性は与えてはならない。 - ツールチップはユーザーの操作によらずマウスホバーまたはフォーカスを受け取った時点で自動的に表示されるため、aria-expanded 属性はサポートされない
- ツールチップを使用して情報を隠す前に、常に見える説明を表示することを検討すること。
実装
それでは、前述した要件を満たしたツールチップの実装を考えてみます。ツールチップの親要素となる <Tooltip.Root>
、ツールチップのトリガー要素となる <Tooltip.Trigger>
、ツールチップ自体である <Toopltip.Content>
から構成します。
export const Tooltip = {
Root,
Trigger,
Content,
};
<Tooltip.Root>
トリガーとなる要素にマウスホバーやフォーカスしたときに状態を変更し、その状態を元にツールチップ自体の表示非表示をします。そのため、トリガー要素とツールチップ要素の共通の親要素が必要だと考えられます。これを <Tooltip.Root>
としてここから Context
を使用して状態を提供しようと思います。またトリガー要素とツールチップは aria-describedby
で紐付ける必要があるので、useId
で生成した ID を contentId
として渡します。
const DELAY_MS = 500;
type ToolTipContextProps = {
isOpen: boolean;
open: () => void;
close: () => void;
contentId: string;
};
const TooltipContext = React.createContext<ToolTipContextProps | null>(null);
const useTooltipContext = () => {
const context = React.useContext(TooltipContext);
if (context === null) {
throw new Error("useTooltipContext must be used within a TooltipProvider");
}
return context;
};
const Root = ({ children }: { children: React.ReactNode }) => {
const contentId = useId();
const [isOpen, setIsOpen] = useState(false);
const timerId = useRef<number | null>(null);
const open = useCallback(() => {
timerId.current = setTimeout(() => {
setIsOpen(true);
}, DELAY_MS);
}, []);
const close = useCallback(() => {
if (timerId.current) {
clearTimeout(timerId.current);
timerId.current = null;
}
setIsOpen(false);
}, []);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
close();
}
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
if (timerId.current) {
clearTimeout(timerId.current);
timerId.current = null;
}
};
}, [close]);
return (
<TooltipContext.Provider value={{ isOpen, open, close, contentId }}>
<div className="tooltip-root">{children}</div>
</TooltipContext.Provider>
);
};
open
関数でツールチップの表示状態を true
にします。ここではマウスオーバーやフォーカスした際にツールチップが表示されるまでディレイを設けたいので、setTimeout
で setIsOpen
を呼ぶタイミングを遅らせています。close
関数が呼ばれた際に setTimeout
がまだ解決していない場合、setIsOpen(false)
が呼ばれた後に setIsOpen(true)
が呼ばれてしまします。そのため、clearTimeout
で setTimeout
がキャンセルされるようにしています。
const [isOpen, setIsOpen] = useState(false);
const timerId = useRef<number | null>(null);
const open = useCallback(() => {
timerId.current = setTimeout(() => {
setIsOpen(true);
}, DELAY_MS);
}, []);
const close = useCallback(() => {
if (timerId.current) {
clearTimeout(timerId.current);
timerId.current = null;
}
setIsOpen(false);
}, []);
ツールチップの要件の 1 つに、Escape
キーを入力した際にツールチップが閉じることがあります。ここも忘れずに実装しておきましょう。keydown
イベントを購読して、event.key
が "Escape"
だった場合に close()
関数を呼び出します。
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
close();
}
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
if (timerId.current) {
clearTimeout(timerId.current);
timerId.current = null;
}
};
}, [close]);
最後に <TooltipProvider>
を渡して要素を返します。
return (
<TooltipContext.Provider value={{ isOpen, open, close, contentId }}>
<div className="tooltip-root">{children}</div>
</TooltipContext.Provider>
);
.tooltip-root
クラスは、ツールチップで position: absolute
を使用するために position: relative
を設定します。
.tooltip-root {
position: relative;
width: fit-content
}
<Tooltip.Trigger>
トリガー要素となる <Tooltip.Trigger>
です。この要素では onMouseEnter
,onFocus
でツールチップが開くように、onMouseLeave
,onBlur
でツールチップが閉じるように実装します。
const Trigger: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open, close, contentId } = useTooltipContext();
const clonedChildren = React.cloneElement(children as React.ReactElement, {
"aria-describedby": contentId,
});
return (
<div
onMouseEnter={() => open()}
onMouseLeave={() => close()}
onFocus={() => open()}
onBlur={() => close()}
>
{clonedChildren}
</div>
);
};
React.cloneElement
は children
に Props を渡すために使用しています。ここでは aria-describedby
を children
の Props として渡しています。 ツールチップのトリガーとなる要素そのものとツールチップを紐付けたいためです。
<Tooltip.Content>
<Tooltip.Content>
はツールチップ自体となる要素です。コンテナとなる要素には role="tooltip"
を与えます。またトリガー要素となる要素と紐付けるために id
として contentId
を設定しています。
簡単な実装として、isOpen
が true
である場合には .open
クラスを与えてツールチップを表示させます。
const Content: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isOpen, contentId } = useTooltipContext();
return (
<div
id={contentId}
role="tooltip"
className={`tooltip ${isOpen ? "open" : ""}`}
>
{children}
</div>
);
};
.tooltip
と .open
クラスのスタイルは以下のようになります。
.tooltip {
opacity: 0;
min-width: 200px;
position: absolute;
z-index: 1;
background-color: black;
top:50%;
transform:translateY(-50%);
left:100%;
margin-left:15px;
color: white;
padding: 5px;
border-radius: 5px;
}
.tooltip.open {
opacity: 1;
}
Tooltip.tsx
全体のコードは以下のようになります。
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import "./tooltip.css";
const DELAY_MS = 500;
type ToolTipContextProps = {
isOpen: boolean;
open: () => void;
close: () => void;
contentId: string;
};
const TooltipContext = React.createContext<ToolTipContextProps | null>(null);
const useTooltipContext = () => {
const context = React.useContext(TooltipContext);
if (context === null) {
throw new Error("useTooltipContext must be used within a TooltipProvider");
}
return context;
};
const Root = ({ children }: { children: React.ReactNode }) => {
const contentId = useId();
const [isOpen, setIsOpen] = useState(false);
const timerId = useRef<number | null>(null);
const open = useCallback(() => {
timerId.current = setTimeout(() => {
setIsOpen(true);
}, DELAY_MS);
}, []);
const close = useCallback(() => {
if (timerId.current) {
clearTimeout(timerId.current);
timerId.current = null;
}
setIsOpen(false);
}, []);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
close();
}
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
if (timerId.current) {
clearTimeout(timerId.current);
timerId.current = null;
}
};
}, [close]);
return (
<TooltipContext.Provider value={{ isOpen, open, close, contentId }}>
<div className="tooltip-root">{children}</div>
</TooltipContext.Provider>
);
};
const Trigger: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open, close, contentId } = useTooltipContext();
const clonedChildren = React.cloneElement(children as React.ReactElement, {
"aria-describedby": contentId,
});
return (
<div
onMouseEnter={() => open()}
onMouseLeave={() => close()}
onFocus={() => open()}
onBlur={() => close()}
>
{clonedChildren}
</div>
);
};
const Content: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isOpen, contentId } = useTooltipContext();
return (
<div
id={contentId}
role="tooltip"
className={`tooltip ${isOpen ? "open" : ""}`}
>
{children}
</div>
);
};
export const Tooltip = {
Root,
Trigger,
Content,
};
<Tooltip>
コンポーネントを使用する
<Tooltip>
コンポーネントを使用方法は以下のとおりです。必ず各要素を <Tooltip.Root>
で囲むようにして、トリガー要素となる要素を <Tooltip.Trigger>
で、ツールチップに表示したい内容を <Tooltip.Content>
でラップします。<Tooltip.Trigger>
はフォーカスしたときもツールチップを表示できるように <button>
や <input>
のようなフォーカス可能な要素である必要があるでしょう。
<Tooltip.Root>
<Tooltip.Trigger>
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>when you click the button, the count will increase by 1.</p>
</Tooltip.Content>
</Tooltip.Root>