gold

アクセシビリティに考慮したツールチップを実装する

ツールチップとは、ある要素に対する補足情報を与える UI です。通常ある要素に対してマウスホバーまたはキーボードでフォーカスした時少しのディレイの後に、ユーザーの操作によらず自動的にポップアップして表示されます。このポップアップはユーザーの操作をブロッキングするものではありません。ユーザーがマウスのホバー外すかフォーカスが外れた場合にツールチップは非表示となります。

ツールチップとは、ある要素に対する補足情報を与える UI です。通常ある要素に対してマウスホバーまたはキーボードでフォーカスしたとき少しのディレイの後に、ユーザーの操作によらず自動的にポップアップして表示されます。このポップアップはユーザーの操作をブロッキングするものではありません。ユーザーがマウスのホバー外すかフォーカスが外れた場合にツールチップは非表示となります。

ツールチップに表示する説明はあくまで補足的なものであり、重要な情報を含んではいけません。ユーザーはツールチップの情報なしに操作を完了できる必要があります。

ツールチップは以下の要素から構成されます。

  • tooltip ロール:マウスホバーや、キーボードでフォーカスした際に表示されるポップアップ。
  • ツールチップをトリガーする要素。フォーカス可能である必要がある。

ツールチップの要件

ツールチップをアクセシブルにするためには、以下の実装をする必要があります。

ロール・ステート・プロパティ

  • ツールチップのコンテナとなる要素に tooltip ロールを指定する
  • ツールチップをトリガーする要素に aria-describedby でツールチップの内容を

キーボード操作

  • Escape:ツールチップを閉じる

その他考慮事項

  • ツールチップそのものはフォーカスを受け取らない。
  • ツールチップはポップアップのように表示されるが、aria-haspopup プロパティにおけるポップアップとみなされない。そのため、ツールチップをトリガーする要素に aria-haspopup 属性は与えてはならない。
  • ツールチップはユーザーの操作によらずマウスホバーまたはフォーカスを受け取った時点で自動的に表示されるため、aria-expanded 属性はサポートされない
  • ツールチップを使用して情報を隠す前に、常に見える説明を表示することを検討すること。

実装

それでは、前述した要件を満たしたツールチップの実装を考えてみます。ツールチップの親要素となる <Tooltip.Root>、ツールチップのトリガー要素となる <Tooltip.Trigger>、ツールチップ自体である <Toopltip.Content> から構成します。

Tooltip.tsx
export const Tooltip = {
  Root,
  Trigger,
  Content,
};

<Tooltip.Root>

トリガーとなる要素にマウスホバーやフォーカスしたときに状態を変更し、その状態を元にツールチップ自体の表示非表示をします。そのため、トリガー要素とツールチップ要素の共通の親要素が必要だと考えられます。これを <Tooltip.Root> としてここから Context を使用して状態を提供しようと思います。またトリガー要素とツールチップは aria-describedby で紐付ける必要があるので、useId で生成した ID を contentId として渡します。

Tooltip.tsx
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 にします。ここではマウスオーバーやフォーカスした際にツールチップが表示されるまでディレイを設けたいので、setTimeoutsetIsOpen を呼ぶタイミングを遅らせています。close 関数が呼ばれた際に setTimeout がまだ解決していない場合、setIsOpen(false) が呼ばれた後に setIsOpen(true) が呼ばれてしまします。そのため、clearTimeoutsetTimeout がキャンセルされるようにしています。

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() 関数を呼び出します。

Tooltip.tsx
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> を渡して要素を返します。

Tooltip.tsx
return (
  <TooltipContext.Provider value={{ isOpen, open, close, contentId }}>
    <div className="tooltip-root">{children}</div>
  </TooltipContext.Provider>
);

.tooltip-root クラスは、ツールチップで position: absolute を使用するために position: relative を設定します。

tooltip.css
.tooltip-root {
  position: relative;
  width: fit-content
}

<Tooltip.Trigger>

トリガー要素となる <Tooltip.Trigger> です。この要素では onMouseEnter,onFocus でツールチップが開くように、onMouseLeave,onBlur でツールチップが閉じるように実装します。

Tooltip.tsx
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.cloneElementchildren に Props を渡すために使用しています。ここでは aria-describedbychildren の Props として渡しています。 ツールチップのトリガーとなる要素そのものとツールチップを紐付けたいためです。

<Tooltip.Content>

<Tooltip.Content> はツールチップ自体となる要素です。コンテナとなる要素には role="tooltip" を与えます。またトリガー要素となる要素と紐付けるために id として contentId を設定しています。

簡単な実装として、isOpentrue である場合には .open クラスを与えてツールチップを表示させます。

Toottip.tsx
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.css
.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 全体のコードは以下のようになります。

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>

ツールチップを操作している様子。tab キーでフォーカスした時にツールチップが表示され、Escape キーを入力した時にツールチップが非表示となっている。その後、マウスホバーするとツールチップが表示され、マウスオーバーするとツールチップが非表示となっている。

参考


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事