ホバーやタップで表示されるツールチップ。
PCではマウスホバー、スマートフォンではタップやロングプレスなど、デバイスに応じて適切なインタラクションが求められるこのUIコンポーネントは、画面端での位置調整、表示アニメーション、マルチデバイス対応など、考慮すべき要素は数多く存在します。
手続き型で書かれた基本的な実装は、これらの要件が追加されるたびに複雑化していきます。
この記事では、実際のツールチップ実装を例に、手続き型のコードをクラスベースの設計へとリファクタリングしていく過程を詳しく解説します。
デバイスに応じたイベント制御、位置計算の分離、アニメーション制御など、具体的な改善ポイントを通じて、より保守性の高いコードへの進化を目指します。
はじめに:ツールチップ実装の課題
ツールチップは、ユーザーに追加情報を提供する重要なUIコンポーネントです。しかし、見た目以上に実装時の考慮点が多く存在します。
実装時の主な課題
インタラクションの多様性
- PCでのマウスホバー
- スマートフォンでのタップやロングプレス
- キーボード操作(アクセシビリティ対応)
表示位置の制御
- 画面端での自動調整
- スクロール時の位置追従
- ウィンドウリサイズへの対応
パフォーマンスとUX
- 不要な再描画の防止
- スムーズなアニメーション
- 表示タイミングの制御(遅延表示)
典型的な手続き型の実装では、これらの要件が追加されるたびにコードは複雑化していきます。
例えば、以下のような機能追加の度に、コードの見通しが悪くなっていきます:
- 複数のツールチップの管理
- 表示位置の動的な計算
- デバイスごとの挙動の出し分け
- アニメーションの追加
次のセクションでは、まず基本的な手続き型の実装を見た上で、これらの課題をクラスベースの設計でどのように解決できるか、具体的に見ていきましょう。
手続き型による実装:シンプルなツールチップの基本
まずは、多くの開発者が最初に書くような手続き型のツールチップ実装を見てみましょう。
See the Pen B-tooltip_001 by R (@rkpg) on CodePen.
<div class="tooltip-trigger" data-tooltip="ツールチップの内容です">
ホバーしてください
</div>
<div class="tooltip" id="tooltip">
<!-- ツールチップの内容がJavaScriptで挿入されます -->
</div>
.tooltip {
position: absolute;
display: none;
background: #333;
color: white;
padding: 8px;
border-radius: 4px;
font-size: 14px;
z-index: 1000;
}
.tooltip::before {
content: '';
position: absolute;
border: 6px solid transparent;
border-bottom-color: #333;
top: -12px;
left: 50%;
transform: translateX(-50%);
}
.tooltip-trigger {
display: inline-block;
border-bottom: 1px dotted #666;
cursor: pointer;
}
const tooltip = document.getElementById('tooltip');
const triggers = document.querySelectorAll('.tooltip-trigger');
function showTooltip(event) {
const trigger = event.currentTarget;
const content = trigger.dataset.tooltip;
tooltip.textContent = content;
tooltip.style.display = 'block';
// 位置の計算
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
tooltip.style.left = `${triggerRect.left + (triggerRect.width - tooltipRect.width) / 2}px`;
tooltip.style.top = `${triggerRect.bottom + 10}px`;
}
function hideTooltip() {
tooltip.style.display = 'none';
}
// イベントリスナーの設定
triggers.forEach(trigger => {
trigger.addEventListener('mouseenter', showTooltip);
trigger.addEventListener('mouseleave', hideTooltip);
trigger.addEventListener('touchstart', showTooltip);
trigger.addEventListener('touchend', hideTooltip);
});
この実装の問題点
- 位置計算の制限
- 画面端での位置調整がない
- スクロール時の位置ずれ
- ウィンドウリサイズ時の再計算がない
- デバイス対応の課題
- スマートフォンでの操作性が不十分
- タッチデバイスでのイベント制御が不完全
- 機能拡張の難しさ
- アニメーションの追加が複雑
- 遅延表示の実装が困難
- 複数ツールチップの管理が煩雑
- 保守性の問題
- グローバルスコープの汚染
- イベントリスナーの管理が不透明
- コードの再利用が困難
次のセクションでは、これらの問題点をクラスベースの設計でどのように解決できるかを見ていきましょう。
クラスベースへの改善:柔軟で再利用可能なツールチップ実装
手続き型のコードをクラスベースに改善し、より柔軟で保守性の高い実装を目指します。
class Tooltip {
constructor(options = {}) {
this.options = {
delay: 200, // 表示までの遅延時間
offset: 8, // トリガーからの距離
animation: true, // アニメーション有効化
placement: 'bottom', // 表示位置(top, bottom, left, right)
...options
};
this.tooltip = this.createTooltipElement();
this.currentTrigger = null;
this.showTimeout = null;
this.handleResize = this.handleResize.bind(this);
this.handleScroll = this.handleScroll.bind(this);
}
init(triggerSelector) {
const triggers = document.querySelectorAll(triggerSelector);
triggers.forEach(trigger => {
trigger.addEventListener('mouseenter', (e) => this.show(e));
trigger.addEventListener('mouseleave', (e) => this.hide(e));
trigger.addEventListener('touchstart', (e) => this.handleTouch(e));
// アクセシビリティ対応
trigger.setAttribute('aria-describedby', 'tooltip');
});
// グローバルイベントの設定
window.addEventListener('resize', this.handleResize);
window.addEventListener('scroll', this.handleScroll);
}
createTooltipElement() {
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
return tooltip;
}
show(event) {
const trigger = event.currentTarget;
clearTimeout(this.showTimeout);
this.showTimeout = setTimeout(() => {
this.currentTrigger = trigger;
this.tooltip.textContent = trigger.dataset.tooltip;
this.tooltip.style.display = 'block';
if (this.options.animation) {
this.tooltip.classList.add('tooltip-fade-in');
}
this.updatePosition();
}, this.options.delay);
}
updatePosition() {
if (!this.currentTrigger) return;
const triggerRect = this.currentTrigger.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
const scrollTop = window.pageYOffset;
const scrollLeft = window.pageXOffset;
// 基本位置の計算
let top = triggerRect.bottom + this.options.offset + scrollTop;
let left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2 + scrollLeft;
// 画面端での調整
if (left < 0) left = this.options.offset;
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - this.options.offset;
}
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
}
hide() {
clearTimeout(this.showTimeout);
if (this.options.animation) {
this.tooltip.classList.remove('tooltip-fade-in');
this.tooltip.classList.add('tooltip-fade-out');
setTimeout(() => {
this.tooltip.style.display = 'none';
this.tooltip.classList.remove('tooltip-fade-out');
}, 200);
} else {
this.tooltip.style.display = 'none';
}
this.currentTrigger = null;
}
// タッチデバイス用のハンドラ
handleTouch(event) {
event.preventDefault();
if (this.tooltip.style.display === 'none') {
this.show(event);
} else {
this.hide();
}
}
handleResize() {
if (this.currentTrigger) {
this.updatePosition();
}
}
handleScroll() {
if (this.currentTrigger) {
this.updatePosition();
}
}
}
この改善版の特徴:
- 設定のカスタマイズ
- 遅延時間、オフセット、アニメーションなどをオプションで制御
- 表示位置の柔軟な設定
- 位置制御の改善
- 画面端での自動調整
- スクロールとリサイズへの対応
- より正確な位置計算
- デバイス対応の強化
- タッチデバイス用の専用ハンドラ
- イベント制御の最適化
使用例:
const tooltip = new Tooltip({
delay: 300,
animation: true,
offset: 12
});
tooltip.init('.tooltip-trigger');
次のセクションでは、この実装の詳細について解説していきましょう。
実装の詳細解説:クラス設計の重要なポイント
クラスベースの実装について、重要なポイントを詳しく解説していきます。
1. コンストラクタでの初期化と設定管理
constructor(options = {}) {
this.options = {
delay: 200,
offset: 8,
animation: true,
placement: 'bottom',
...options
};
this.tooltip = this.createTooltipElement();
this.currentTrigger = null;
this.showTimeout = null;
}
- オプションのデフォルト値を設定し、カスタマイズを容易に
- インスタンス変数で状態を明確に管理
- メモリリーク防止のためのタイムアウト管理
2. デバイス対応の改善
init(triggerSelector) {
const triggers = document.querySelectorAll(triggerSelector);
triggers.forEach(trigger => {
// PCの場合
if (window.matchMedia('(hover: hover)').matches) {
trigger.addEventListener('mouseenter', (e) => this.show(e));
trigger.addEventListener('mouseleave', (e) => this.hide(e));
} else {
// タッチデバイスの場合
trigger.addEventListener('click', (e) => this.handleTouch(e));
}
});
}
- デバイスの特性に応じたイベント制御
- hover可能デバイスの適切な判定
- タッチデバイスでのUX改善
3. 位置計算とリサイズ対応
updatePosition() {
if (!this.currentTrigger) return;
const triggerRect = this.currentTrigger.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
// スクロール位置の考慮
const scrollTop = window.pageYOffset;
const scrollLeft = window.pageXOffset;
// 基本位置の計算と画面端での調整
let left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2 + scrollLeft;
if (left < 0) left = this.options.offset;
}
- スクロール位置を考慮した正確な配置
- 画面端での自動調整
- リサイズ時の位置更新
クラスベースの設計により、これらの複雑な処理を整理された形で実装できています。次のセクションでは、全体のまとめと実践的な使用方法を見ていきましょう。
まとめ:ツールチップから学ぶクラス設計の利点
ツールチップの実装を通じて、クラスベースの設計がもたらす具体的なメリットを見てきました。
手続き型からクラスベースへの移行で得られる価値
1.コードの構造化と保守性
const tooltip = new Tooltip({
delay: 200,
animation: true
});
- オプション設定による柔軟なカスタマイズ
- 機能の追加・変更が容易
- テストが書きやすい構造
2.状態管理の明確化
- 現在のツールチップの状態を追跡
- タイミング制御の改善
- デバイスごとの適切な挙動管理
3.再利用性の向上
// 複数の異なる設定のツールチップを作成可能
const tooltipA = new Tooltip({ delay: 0 });
const tooltipB = new Tooltip({ animation: false });
- 複数のインスタンスを独立して管理
- 設定の使い回しが容易
設計改善のポイント
- イベント制御をデバイスに応じて最適化
- 位置計算ロジックの分離
- アニメーションの柔軟な制御
- メモリリークを防ぐ適切なリソース管理
このようなクラスベースの設計アプローチは、ツールチップに限らず他のUIコンポーネントの実装にも応用できます。
より保守性が高く、拡張性のあるコードを目指す際の参考にしていただければ幸いです。