コードブロックにアクセシブルなコピーボタンを追加する方法

この記事では、a-blog cms のブロックエディターで生成されるコードブロックに、アクセシビリティに配慮したコピーボタンを追加する実装方法をご紹介します。

ブロックエディターのHTML構造を理解する

a-blog cms のブロックエディターは Tiptapを使用しており、コードブロックは以下のようなシンプルなHTMLとして出力されます。

<div class="column-block-editor">
  <pre>// ここにコードが入ります</pre>
</div>

HTML構造の解説

  • <pre> タグ:コードブロックの本体
  • <div class="column-block-editor">:ラップしている div タグ

このラップ用の div タグは、system/_layouts/unit.html@section(block-editor) で定義されており、任意で追加できます。標準でこのクラスが付与されている理由は以下の通りです。

  • ブロックエディター内に限定したスタイル制御が容易
  • ユニット毎に要素を明確に分けられる

アクセシビリティに配慮した実装方法

完全なコード

以下が、アクセシビリティに配慮したコピーボタンを追加するためのJavaScriptコードです。

const preElements = document.querySelectorAll('.column-block-editor pre');

if (preElements.length === 0) return;

function addCopyButton(codeBlock) {
  // 既にラップされている場合はスキップ
  if (codeBlock.parentElement.classList.contains('code-block-wrapper')) {
    return;
  }

  // コピーボタンを作成
  const button = document.createElement('button');
  button.type = 'button';
  button.className = 'code-copy-button';
  button.textContent = 'コピー';
  button.setAttribute('aria-label', 'コードをコピー');

  // ライブリージョンを作成(スクリーンリーダーへの通知用)
  const liveRegion = document.createElement('span');
  liveRegion.className = 'visually-hidden';
  liveRegion.setAttribute('role', 'status');
  liveRegion.setAttribute('aria-live', 'polite');
  liveRegion.setAttribute('aria-atomic', 'true');

  // ボタンのクリックイベント
  button.addEventListener('click', async () => {
    try {
      const code = codeBlock.textContent;
      await navigator.clipboard.writeText(code);

      // コピー成功のフィードバック
      button.textContent = 'コピーしました!';
      button.setAttribute('aria-label', 'コードをコピーしました');
      liveRegion.textContent = 'コードをクリップボードにコピーしました';

      // 2秒後に元に戻す
      setTimeout(() => {
        button.textContent = 'コピー';
        button.setAttribute('aria-label', 'コードをコピー');
        liveRegion.textContent = '';
      }, 2000);
    } catch (error) {
      button.textContent = 'エラー';
      button.setAttribute('aria-label', 'コピーに失敗しました');
      liveRegion.textContent = 'コピーに失敗しました。もう一度お試しください';

      setTimeout(() => {
        button.textContent = 'コピー';
        button.setAttribute('aria-label', 'コードをコピー');
        liveRegion.textContent = '';
      }, 2000);
    }
  });

  // pre要素をラップするコンテナを作成
  const wrapper = document.createElement('div');
  wrapper.className = 'code-block-wrapper';
  const parent = codeBlock.parentNode;
  parent.insertBefore(wrapper, codeBlock);
  wrapper.appendChild(codeBlock);
  wrapper.appendChild(button);
  wrapper.appendChild(liveRegion);
}

// 各 pre 要素に対してコピーボタンを追加
preElements.forEach(codeBlock => {
  addCopyButton(codeBlock);
});

コードの処理フロー

1. コードブロックの検出

const preElements = document.querySelectorAll('.column-block-editor pre');
if (preElements.length === 0) return;

ページ内のすべてのコードブロック(pre タグ)を取得します。コードブロックが存在しない場合は処理を終了します。

2. 重複チェック

if (codeBlock.parentElement.classList.contains('code-block-wrapper')) {
  return;
}

既にコピーボタンが追加されている場合はスキップします。これにより、同じコードブロックに複数のボタンが追加されるのを防ぎます。

3. コピーボタンの作成

const button = document.createElement('button');
button.type = 'button';
button.className = 'code-copy-button';
button.textContent = 'コピー';
button.setAttribute('aria-label', 'コードをコピー');

アクセシビリティのポイント

  • type="button": フォーム内で使用された場合の意図しない送信を防止
  • aria-label: スクリーンリーダーがボタンの目的を明確に読み上げられるようにする
  • textContent: 視覚的なラベルを提供

4. ライブリージョンの作成

const liveRegion = document.createElement('span');
liveRegion.className = 'visually-hidden';
liveRegion.setAttribute('role', 'status');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');

ライブリージョンとは

ライブリージョンは、ページの内容が動的に変更されたときに、その変更をスクリーンリーダーユーザーに自動的に通知するための仕組みです。

各属性の役割:

  • role="status":ステータスメッセージであることを示す
  • aria-live="polite":現在の読み上げを中断せず、終了後に通知する
  • aria-live="assertive"と異なる点:緊急性が低いメッセージに適している
  • aria-atomic="true":内容全体を読み上げる(部分的ではなく)

なぜボタンに aria-live を付けないのか

ボタンに直接 aria-live を設定することも技術的には可能ですが、以下の理由から推奨されません。

  • ボタンは「操作する要素」であり、「状態を通知する要素」ではない
  • フォーカスがボタンにある状態では通知が不安定になる場合がある
  • 役割を分離することで、より確実でメンテナンスしやすいコードになる

5. クリップボードへのコピー機能

button.addEventListener('click', async () => {
  try {
    const code = codeBlock.textContent;
    await navigator.clipboard.writeText(code);

    // 視覚的フィードバック
    button.textContent = 'コピーしました!';
    button.setAttribute('aria-label', 'コードをコピーしました');

    // スクリーンリーダーへの通知
    liveRegion.textContent = 'コードをクリップボードにコピーしました';

    // 2秒後に元に戻す
    setTimeout(() => {
      button.textContent = 'コピー';
      button.setAttribute('aria-label', 'コードをコピー');
      liveRegion.textContent = '';
    }, 2000);
  } catch (error) {
    button.textContent = 'エラー';
    button.setAttribute('aria-label', 'コピーに失敗しました');
    liveRegion.textContent = 'コピーに失敗しました。もう一度お試しください';

    setTimeout(() => {
      button.textContent = 'コピー';
      button.setAttribute('aria-label', 'コードをコピー');
      liveRegion.textContent = '';
    }, 2000);
  }
});

アクセシビリティのポイント

  1. 視覚的フィードバックbutton.textContent の変更で、視覚ユーザーに結果を通知
  2. aria-label の動的更新:スクリーンリーダーユーザーがボタンに再フォーカスした際に、正確な状態を伝える
  3. ライブリージョンによる通知:フォーカス位置に関わらず、操作結果をスクリーンリーダーユーザーに通知
  4. 状態のリセット:2秒後に元の状態に戻すことで、再試行が可能

try-catch を使用する理由

navigator.clipboard.writeText() は以下の状況で失敗する可能性があります。

  • HTTPS でない環境(http ページ)
  • ユーザーがクリップボードへのアクセスを拒否している
  • 一部の古いブラウザでの非対応
  • iframe内での実行制限

try-catch により、エラーが発生してもアプリケーション全体がクラッシュせず、ユーザーに適切なフィードバックを提供できます。

6. DOM構造の再構築

const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
const parent = codeBlock.parentNode;
parent.insertBefore(wrapper, codeBlock);
wrapper.appendChild(codeBlock);
wrapper.appendChild(button);
wrapper.appendChild(liveRegion);

コードブロック、ボタン、ライブリージョンを包含する wrapper div を作成し、以下のような構造に変更します。

変更前

<div class="column-block-editor">
  <pre>...</pre>
</div>

変更後

<div class="column-block-editor">
  <div class="code-block-wrapper">
    <pre>...</pre>
    <button type="button" class="code-copy-button" aria-label="コードをコピー">
      コピー
    </button>
    <span class="visually-hidden" role="status" aria-live="polite" aria-atomic="true">
    </span>
  </div>
</div>

これにより、コードブロックとボタン、ライブリージョンを一つのコンテナで管理でき、CSS でのスタイリングが容易になります。

スタイリング

基本的なCSS

コピーボタンを右上に配置し、ライブリージョンを視覚的に非表示にする CSS です。

.code-block-wrapper {
  position: relative;
}

.code-copy-button {
  position: absolute;
  top: 8px;
  right: 8px;
  padding: 4px 12px;
  background-color: #f0f0f0;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.2s;
}

.code-copy-button:hover {
  background-color: #e0e0e0;
}

.code-copy-button:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

/* スクリーンリーダー専用: 視覚的に非表示だが読み上げ可能 */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.code-copy-button:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

/* マウスクリック時はアウトラインを非表示 */
.code-copy-button:focus:not(:focus-visible) {
  outline: none;
}

重要な注意点

ライブリージョンを視覚的に非表示にする際、display: nonevisibility: hidden を使用すると、スクリーンリーダーからも隠れてしまいます。.visually-hidden クラスを使用することで、視覚的にのみ非表示にし、スクリーンリーダーからはアクセス可能な状態を保ちます。

アクセシビリティのチェックリスト

この実装が満たしているアクセシビリティ要件:

  • キーボード操作:ボタンは Tab キーでフォーカス可能、Enter/Space キーで操作可能
  • スクリーンリーダー対応aria-label とライブリージョンで適切な情報提供
  • 視覚的フィードバック:ボタンのテキスト変更で操作結果を表示
  • 音声フィードバック:ライブリージョンでスクリーンリーダーに通知
  • エラーハンドリング:失敗時にも適切なフィードバック
  • フォーカスインジケーター:キーボード操作時に視覚的なフォーカス表示
  • セマンティックHTML:適切な要素と属性の使用

まとめ

このスクリプトを使用することで、a-blog cms のブロックエディターで生成されるすべてのコードブロックに、アクセシビリティに配慮したコピーボタンを自動的に追加できます。

主要なアクセシビリティ機能

  1. ボタンの明示的なタイプ指定type="button" でフォーム送信を防止
  2. aria-label:スクリーンリーダーユーザーにボタンの目的を伝える
  3. ライブリージョン:動的な変更を自動的に通知
  4. 視覚的フィードバック:すべてのユーザーに操作結果を提供
  5. 適切なエラーハンドリング:失敗時も明確なフィードバック

これにより、アクセシビリティが考慮できた快適に使用できる実装となっています。


シェアする

Webにまつわる お困りごとをご相談ください。

こんなお手伝いができます

Webコンサルタントとしてのお手伝い/UIデザインのご相談/デジタルメディアの総合プロデュース/パンフレット・DMなどのDTP、ロゴ制作などのビジュアルデザイン