キーボードで操作する写真ビューアーを作る

JavaScriptBeginner
オンラインで実践に進む

はじめに

Moonroom の実際の写真カードには、読みやすいキャプションとレスポンシブレイアウトが付きました。ただし詳細ビューアーは未接続です。各カードから対応する大きな写真を開き、コレクションを移動できるようにし、最初に使ったカードへ正確に戻れるようにするのが今回の作業です。別の画像で代用せず、本当に画像がないエラーを起こして復旧する練習も行います。

このラボは独自の完成したギャラリーとローカル写真から始まり、前の環境のファイルには依存しません。最初のラボで学んだカードとデータの関係、JavaScript 関数、DOM 選択、イベントの知識が必要です。新しい配列検索、ダイアログ、フォーカス、画像イベントの動作は、使用前に説明します。読む時間と練習時間の60分は、著者による見積もりです。

Web サイトのページを開いたり再読み込みしたりした後は、読み込みが終わってから写真のコントロールを操作してください。/home/labex/project/moonroom には WebIDE、Web サイトには Web 8080 を使います。編集するのは viewer.js です。photos.js の large パスを一時変更するのは、明示された失敗練習だけに限り、チェック前に戻してください。他の配布ファイル、メタデータ、元の画像は保持します。2つの Earthrise レコードは独立したままにし、全カード、サムネイル、キャプションを使える状態にします。同等の JavaScript 動作でも構いませんが、配布されたボタン ID、安定した写真の関連付け属性、可視の Close、Previous、Next コントロール、ネイティブダイアログのタイトルと説明の関連付けを保ってください。これらのキーボード練習は、完全なアクセシビリティ認証ではありません。

正しい写真を開いて元のカードへ戻る

このステップでは、既存のカードをネイティブのモーダルダイアログに接続し、キーボードフォーカスの確実な戻り先を用意します。

Web 8080 に切り替え、サイト内リンク Study collection を選び、Tab、Enter、Esc で写真を開いて閉じます。次に Current exhibition を選びます。3枚のカードとキャプションは既にあります。カード上で Enter を試してください。viewer.js にはコメントしかないため、まだ何も起こりません。これは想定した未完成の動作で、環境の故障ではありません。

WebIDE に切り替え、Explorer で moonroom を開き、index.html を読みます。dialog 要素はブラウザー組み込みのパネルです。aria-labelledby="photo-title" は可視の見出しを、aria-describedby="photo-caption" は現在の説明を指します。showModal() はモーダルとして開くため、開いている間はページの他の内容が操作できなくなります。基本的なモーダルの Tab 操作と Esc による終了はブラウザーが処理するので、独自のフォーカストラップを書く必要はありません。ダイアログ自体に tabindex を付けないでください。用意済みの Close ボタンには autofocus があり、開いた際には明確な終了手段へフォーカスが移ります。

viewer.js を開きます。const は DOM 要素への参照を保持します。変更する値には let を使います。currentIndex はゼロから始まる選択中の配列位置で、openingButton は実際にパネルを開いたボタンを記憶します。photos[currentIndex] はその写真の完全なレコードを取得します。showPhoto() は、タイトル、キャプション、代替テキスト、実際の大きな画像をまとめて更新します。

ギャラリーにはクリックリスナーを1つだけ置きます。ネイティブボタン上のキーボード Enter でも起動します。event.target は実際にクリックされた要素で、ボタン内の画像やタイトルの場合もあります。closest('button[data-photo-id]') は外側へたどり、対応する開くボタンを探します。if (!button) return はそのようなボタンからではないクリックを無視します。これがイベント委譲であり、1つのリスナーで既存の全カードボタンを処理します。

findIndex() は配列を検索し、ID がボタンの dataset.photoId と一致する位置を返します。コールバックは photo.id === button.dataset.photoId のとき true を返します。=== は値の型を変換せず比較します。一致するレコードがなければ findIndex() は -1 を返すため、ガード条件で停止し、間違った写真を開きません。重複するタイトル Earthrise ではなく、意図的に ID を比較しています。

ダイアログの close イベントは、Esc でも Close ボタンでも、閉じると実行されます。openingButton.focus() は記憶したカードへフォーカスを戻します。ここでの focus() は戻る動作を実装するものであり、手動テストでは引き続き本物のキーボードでカードへ到達し、起動する必要があります。

viewer.js の全内容を以下の完全なコードで置き換え、Ctrl+S または Cmd+S で保存します。

const viewer = document.querySelector('#viewer');
const largePhoto = document.querySelector('#large-photo');
const photoTitle = document.querySelector('#photo-title');
const photoCaption = document.querySelector('#photo-caption');
let currentIndex = 0;
let openingButton;

function showPhoto() {
  const photo = photos[currentIndex];
  photoTitle.textContent = photo.title;
  photoCaption.textContent = photo.caption;
  largePhoto.alt = photo.alt;
  largePhoto.src = photo.large;
}

document.querySelector('#gallery').addEventListener('click', function (event) {
  const button = event.target.closest('button[data-photo-id]');
  if (!button) return;
  currentIndex = photos.findIndex(function (photo) {
    return photo.id === button.dataset.photoId;
  });
  if (currentIndex === -1) return;
  openingButton = button;
  showPhoto();
  viewer.showModal();
});

document.querySelector('#close').addEventListener('click', function () {
  viewer.close();
});
viewer.addEventListener('close', function () {
  openingButton.focus();
});

Web 8080 に切り替え、必要なら Current exhibition を選び、ページを再読み込みします。Tab で各カードへ移動し、Enter で開きます。最初の Earthrise は Apollo 8 の写真とキャプション、最後は Apollo 11 を表示する必要があります。毎回 Esc を押し、同じカード上のフォーカスを確認します。写真を再び開き、Tab で Close へ移動して Enter を押し、もう1つの閉じ方も試してください。Previous と Next は次のステップまで接続されません。

パネルが開かなければ WebIDE に戻り、完全なコードと比較します。ファイルが viewer.js として保存され、index.html にスクリプト行が残り、data-photo-id の値が photos.js と一致していることを確認してください。タイプミスを直したら保存し、サイトを再読み込みします。間違った場面が表示されたら、現在のレコードの large、タイトル、キャプションを一緒に比較します。ID 検索の誤りを隠すために元データを変えてはいけません。3つの独立した Enter/Esc 経路がすべて動いてからチェックを実行します。

コレクション全体と端の動作を確認する

このステップでは、最初に開いたカードを見失わず、同じ詳細表示を更新します。

WebIDE に戻り、photos.js のゼロ始まりの配列位置を確認します。最初は 0、2番目は 1、最後は photos.length - 1 です。length はレコード数です。表示する位置は1から数える方が自然なので、currentIndex + 1 によって 1 / 3 のような人が読みやすい値にします。

disabled プロパティは真偽値を受け取ります。previous.disabled = currentIndex === 0 は最初の写真だけで Previous を無効にし、対応する Next の規則は最後だけで無効にします。この境界により配列の範囲外参照を防ぎます。クリックハンドラーもインデックス変更前に範囲を確認します。currentIndex -= 1 は1を引いて新しいインデックスを保存し、+= 1 は1を加えます。変更のたびに showPhoto() を呼び、画像、説明、位置を同期させます。

openingButton を変更するのはカードを開くときだけです。ダイアログ内で別の写真へ移動しても変更してはいけません。現在表示している写真が違っていても、閉じると閲覧を始めたカードへ戻ります。

viewer.js の全内容を次の完全な版で置き換えて保存します。開く・閉じる動作を保ち、範囲を制御したナビゲーションを加えます。

const viewer = document.querySelector('#viewer');
const largePhoto = document.querySelector('#large-photo');
const photoTitle = document.querySelector('#photo-title');
const photoCaption = document.querySelector('#photo-caption');
const previous = document.querySelector('#previous');
const next = document.querySelector('#next');
let currentIndex = 0;
let openingButton;

function showPhoto() {
  const photo = photos[currentIndex];
  photoTitle.textContent = photo.title;
  photoCaption.textContent = photo.caption;
  largePhoto.alt = photo.alt;
  largePhoto.src = photo.large;
  previous.disabled = currentIndex === 0;
  next.disabled = currentIndex === photos.length - 1;
  document.querySelector('#photo-position').textContent = (currentIndex + 1) + ' / ' + photos.length;
}

document.querySelector('#gallery').addEventListener('click', function (event) {
  const button = event.target.closest('button[data-photo-id]');
  if (!button) return;
  currentIndex = photos.findIndex(function (photo) {
    return photo.id === button.dataset.photoId;
  });
  if (currentIndex === -1) return;
  openingButton = button;
  showPhoto();
  viewer.showModal();
});

previous.addEventListener('click', function () {
  if (currentIndex > 0) {
    currentIndex -= 1;
    showPhoto();
  }
});
next.addEventListener('click', function () {
  if (currentIndex < photos.length - 1) {
    currentIndex += 1;
    showPhoto();
  }
});
document.querySelector('#close').addEventListener('click', function () {
  viewer.close();
});
viewer.addEventListener('close', function () {
  openingButton.focus();
});

Web 8080 に切り替え、Current exhibition を再読み込みします。キーボードで最初の Earthrise を開きます。Previous は無効で、Tab はそのボタンを飛ばします。Tab で Next へ移動して Enter を押します。縦長の足跡、そのタイトルとキャプション、2 / 3 が見えるはずです。最後の Earthrise まで進み、Apollo 11 の画像とキャプションが現れ、Next が無効になっていることを確認します。

Tab で Previous へ移動し、2枚の画像をたどって戻ります。利用可能なコントロール間を逆方向に移動したいときは、Shift を押しながら Tab を押します。これはキーボードフォーカスの移動であり、Previous を起動して写真を変更する操作とは別です。Esc を押し、最初に開いた先頭カードへフォーカスが戻ることを確認します。次に中央のカードを直接開き、別の写真へ移動してから閉じます。フォーカスは中央カードへ戻る必要があります。

先頭・末尾で境界のボタンが無効なのは正常です。使えそうに見えるのに何もしないコントロールは正常ではありません。タイトルだけ変わり写真が変わらない場合は WebIDE に戻り、どの移動でも同じ showPhoto() を呼ぶことを確認します。Esc がいつも最初のカードへ戻るなら、openingButton をカードを開くリスナー内で代入し、ナビゲーションハンドラーで上書きしていないか確認してください。必要なら完全なコードを復元し、保存して再読み込みしてからチェックします。

画像の欠落を正直に伝えて復旧できるようにする

このステップでは、本当に画像を読み込めない状況を処理し、訪問者の位置とコレクションの残りの使いやすさを保ちます。

画像の load イベントはファイルが正しく読み込まれたことを示し、error はパスの欠落などにより画像を読み込めなかったことを示します。これらはブラウザーの画像イベントであって、任意の HTTP 応答が有効な画像を含む証明ではありません。サーバーへの接続失敗ならページ全体が使えなくなる可能性がありますが、ここではページは動き、1枚の画像リクエストだけが失敗します。

WebIDE で viewer.js を開きます。この版は src を代入する前にイベントハンドラーを設定するため、高速なキャッシュ画像でもハンドラーの用意前に完了しません。読み込み中は古い写真を隠し、既存の role="status" 段落に Loading photo... を表示します。成功時は実際の画像を表示してメッセージを消し、失敗時は隠したまま読みやすい復旧案内を表示します。通常のダイアログ画像の規則が display: block なので、CSS 規則 dialog img[hidden] { display: none; } が必要です。これがないと、hidden 属性だけではその CSS 規則に上書きされてしまいます。

エラーハンドラーは src をサムネイルや別の写真へ変えません。そのような代用は失敗を隠し、現在のレコードと異なる内容を表示します。利用者は引き続きパネルを閉じたり、正常な写真へ移動したりできます。

viewer.js の全内容を、次の完全な最終版で置き換えて保存します。

const viewer = document.querySelector('#viewer');
const largePhoto = document.querySelector('#large-photo');
const photoTitle = document.querySelector('#photo-title');
const photoCaption = document.querySelector('#photo-caption');
const photoError = document.querySelector('#photo-error');
const previous = document.querySelector('#previous');
const next = document.querySelector('#next');
let currentIndex = 0;
let openingButton;

function showPhoto() {
  const photo = photos[currentIndex];
  photoTitle.textContent = photo.title;
  photoCaption.textContent = photo.caption;
  largePhoto.alt = photo.alt;
  photoError.textContent = 'Loading photo...';
  largePhoto.hidden = true;
  largePhoto.onload = function () {
    largePhoto.hidden = false;
    photoError.textContent = '';
  };
  largePhoto.onerror = function () {
    largePhoto.hidden = true;
    photoError.textContent = 'This photo could not be loaded. Close the viewer and check its large image path.';
  };
  largePhoto.src = photo.large;
  previous.disabled = currentIndex === 0;
  next.disabled = currentIndex === photos.length - 1;
  document.querySelector('#photo-position').textContent = (currentIndex + 1) + ' / ' + photos.length;
}

document.querySelector('#gallery').addEventListener('click', function (event) {
  const button = event.target.closest('button[data-photo-id]');
  if (!button) return;
  currentIndex = photos.findIndex(function (photo) {
    return photo.id === button.dataset.photoId;
  });
  if (currentIndex === -1) return;
  openingButton = button;
  showPhoto();
  viewer.showModal();
});

previous.addEventListener('click', function () {
  if (currentIndex > 0) {
    currentIndex -= 1;
    showPhoto();
  }
});
next.addEventListener('click', function () {
  if (currentIndex < photos.length - 1) {
    currentIndex += 1;
    showPhoto();
  }
});
document.querySelector('#close').addEventListener('click', function () {
  viewer.close();
});
viewer.addEventListener('close', function () {
  openingButton.focus();
});

ここから、自分のファイルで制御されたパス間違いを作り、復旧します。WebIDE で photos.js を開きます。id が footprint のレコードで、large の値だけを images/footprint.jpg から images/missing-photo.jpg へ変えます。サムネイル、ID、キャプション、代替テキストは変えず、保存してください。

Web 8080 に切り替え、Current exhibition を再読み込みし、Tab と Enter で A trace of a visitor を開きます。サムネイルは動作しますが、大きな画像のリクエストは失敗します。This photo could not be loaded. Close the viewer and check its large image path. という文字が見えるはずです。パネルは Earthrise や引き伸ばしたサムネイルを代わりに表示してはいけません。足跡のタイトルとキャプションは残り、Close、Previous、Next コントロールも利用可能なままです。

大きな写真が欠落していても、読みやすいエラーと使えるナビゲーションを残す。

失敗したパネルは足跡のタイトルとキャプションを保ち、代わりの画像は表示しません。Next は利用可能で、閲覧を続けられます。

Tab と Enter で Next を起動します。正常な Apollo 11 の画像が読み込まれ、エラーメッセージは消えるはずです。Esc を押し、最初にパネルを開いた足跡カードへフォーカスが戻ることを確認します。これはコレクション内の閲覧を復旧したのであり、誤ったファイルパスを直したわけではありません。

WebIDE に戻り、footprint レコードの large を正確に images/footprint.jpg に戻して保存します。Web 8080 に切り替え、Current exhibition を再読み込みし、復元後のデータをブラウザーに読ませます。足跡をもう一度開いてください。実際の縦長画像がデコードされ、エラーメッセージなしで現れる必要があります。ブラウザーの再読み込みはページデータを読み直すだけで、保存した作業をリセットしたり環境を作り直したりしません。

パスを復元すると、実際の縦長写真が再び読み込まれる。

パスを戻して再読み込みすると、縦長の足跡が歪まずにビューアーに表示され、エラーメッセージは消えます。

最後に3枚のカード、前後両方向の端の動作、2つの閉じ方を再確認します。サイトパネルを狭くし、写真全体と折り返した見出しが、ページを横スクロールせずダイアログ内へ収まることを確認してください。元のサムネイル、メタデータ、画像、クレジットをすべて保持します。最終チェックでは配布データを元へ戻す必要があります。失敗処理のテストはファイルの使い捨てコピーだけで行い、実際の展示の画像は削除しません。

まとめ

安定した ID を使い、ネイティブダイアログで各写真の実際の内容を表示しました。端を制御した前後ナビゲーションを加え、Esc と Close の戻り先を元のカードに保ち、別の写真で代用せず本当の画像欠落リクエストを処理しました。ギャラリーは局所的な失敗を正直に伝えながら、カード配置、キャプション、他の写真を使える状態に保ちます。