ツールチップのリファクタリング実践 – 手続き型コードをクラス設計で改善する

ホバーやタップで表示されるツールチップ。

PCではマウスホバー、スマートフォンではタップやロングプレスなど、デバイスに応じて適切なインタラクションが求められるこのUIコンポーネントは、画面端での位置調整、表示アニメーション、マルチデバイス対応など、考慮すべき要素は数多く存在します。

手続き型で書かれた基本的な実装は、これらの要件が追加されるたびに複雑化していきます。

この記事では、実際のツールチップ実装を例に、手続き型のコードをクラスベースの設計へとリファクタリングしていく過程を詳しく解説します。

デバイスに応じたイベント制御、位置計算の分離、アニメーション制御など、具体的な改善ポイントを通じて、より保守性の高いコードへの進化を目指します。

はじめに:ツールチップ実装の課題

ツールチップは、ユーザーに追加情報を提供する重要なUIコンポーネントです。しかし、見た目以上に実装時の考慮点が多く存在します。

実装時の主な課題

インタラクションの多様性

  • PCでのマウスホバー
  • スマートフォンでのタップやロングプレス
  • キーボード操作(アクセシビリティ対応)

表示位置の制御

  • 画面端での自動調整
  • スクロール時の位置追従
  • ウィンドウリサイズへの対応

パフォーマンスとUX

  • 不要な再描画の防止
  • スムーズなアニメーション
  • 表示タイミングの制御(遅延表示)

典型的な手続き型の実装では、これらの要件が追加されるたびにコードは複雑化していきます。

例えば、以下のような機能追加の度に、コードの見通しが悪くなっていきます:

  • 複数のツールチップの管理
  • 表示位置の動的な計算
  • デバイスごとの挙動の出し分け
  • アニメーションの追加

次のセクションでは、まず基本的な手続き型の実装を見た上で、これらの課題をクラスベースの設計でどのように解決できるか、具体的に見ていきましょう。

手続き型による実装:シンプルなツールチップの基本

まずは、多くの開発者が最初に書くような手続き型のツールチップ実装を見てみましょう。

See the Pen B-tooltip_001 by R (@rkpg) on CodePen.

HTML
<div class="tooltip-trigger" data-tooltip="ツールチップの内容です">
  ホバーしてください
</div>

<div class="tooltip" id="tooltip">
  <!-- ツールチップの内容がJavaScriptで挿入されます -->
</div>
CSS
.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;
}
JS
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);
});

この実装の問題点

  1. 位置計算の制限
    • 画面端での位置調整がない
    • スクロール時の位置ずれ
    • ウィンドウリサイズ時の再計算がない
  2. デバイス対応の課題
    • スマートフォンでの操作性が不十分
    • タッチデバイスでのイベント制御が不完全
  3. 機能拡張の難しさ
    • アニメーションの追加が複雑
    • 遅延表示の実装が困難
    • 複数ツールチップの管理が煩雑
  4. 保守性の問題
    • グローバルスコープの汚染
    • イベントリスナーの管理が不透明
    • コードの再利用が困難

次のセクションでは、これらの問題点をクラスベースの設計でどのように解決できるかを見ていきましょう。

クラスベースへの改善:柔軟で再利用可能なツールチップ実装

手続き型のコードをクラスベースに改善し、より柔軟で保守性の高い実装を目指します。

JS
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();
    }
  }
}

この改善版の特徴:

  1. 設定のカスタマイズ
    • 遅延時間、オフセット、アニメーションなどをオプションで制御
    • 表示位置の柔軟な設定
  2. 位置制御の改善
    • 画面端での自動調整
    • スクロールとリサイズへの対応
    • より正確な位置計算
  3. デバイス対応の強化
    • タッチデバイス用の専用ハンドラ
    • イベント制御の最適化

使用例:

JS
const tooltip = new Tooltip({
  delay: 300,
  animation: true,
  offset: 12
});

tooltip.init('.tooltip-trigger');

次のセクションでは、この実装の詳細について解説していきましょう。

実装の詳細解説:クラス設計の重要なポイント

クラスベースの実装について、重要なポイントを詳しく解説していきます。

1. コンストラクタでの初期化と設定管理

JS
constructor(options = {}) {
  this.options = {
    delay: 200,
    offset: 8,
    animation: true,
    placement: 'bottom',
    ...options
  };

  this.tooltip = this.createTooltipElement();
  this.currentTrigger = null;
  this.showTimeout = null;
}
  • オプションのデフォルト値を設定し、カスタマイズを容易に
  • インスタンス変数で状態を明確に管理
  • メモリリーク防止のためのタイムアウト管理

2. デバイス対応の改善

JS
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. 位置計算とリサイズ対応

JS
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.コードの構造化と保守性

JS
const tooltip = new Tooltip({
  delay: 200,
  animation: true
});
  • オプション設定による柔軟なカスタマイズ
  • 機能の追加・変更が容易
  • テストが書きやすい構造

2.状態管理の明確化

  • 現在のツールチップの状態を追跡
  • タイミング制御の改善
  • デバイスごとの適切な挙動管理

3.再利用性の向上

JS
// 複数の異なる設定のツールチップを作成可能
const tooltipA = new Tooltip({ delay: 0 });
const tooltipB = new Tooltip({ animation: false });
  • 複数のインスタンスを独立して管理
  • 設定の使い回しが容易

設計改善のポイント

  • イベント制御をデバイスに応じて最適化
  • 位置計算ロジックの分離
  • アニメーションの柔軟な制御
  • メモリリークを防ぐ適切なリソース管理

このようなクラスベースの設計アプローチは、ツールチップに限らず他のUIコンポーネントの実装にも応用できます。

より保守性が高く、拡張性のあるコードを目指す際の参考にしていただければ幸いです。