はじめに
Moonroom は、アポロ計画の実際の写真を展示する小さな写真展です。コレクションは用意されていますが、現在の展示には写真カードがありません。まず意味の伝わるカードを1枚作り、データからコレクション全体へ広げ、狭い画面でも広い画面でも読みやすいギャラリーにします。独立した Study collection で、作業を始める前に目指す閲覧体験を確認できます。
HTML の基本構造、CSS のセレクターとレイアウト、JavaScript のオブジェクト、配列、DOM イベントの知識が必要です。このラボでは、figure、figcaption、ネイティブボタン、写真の安定した ID、forEach、dataset、使用するレスポンシブ規則を説明します。この最初のラボでは詳細ビューアーを用意済みで、次のラボでその動作を自分で作ります。所要時間の50分は、読む時間と通常の編集ミスへの対処を含む著者の見積もりです。
Web サイトのページを開いたり再読み込みしたりした後は、読み込みが終わってから写真のコントロールを操作してください。/home/labex/project/moonroom の編集には WebIDE、Web サイトの操作には Web 8080 を使います。このラボで編集するのは index.html、gallery.js、styles.css だけです。photos.js、viewer.js、images/、credits.html は保持してください。配布された画像ファイルとメタデータは、元の ID と対応したままにします。Web サイトのラベルはすべて英語のままです。この実践的なキーボード確認は、完全なアクセシビリティ認証を意味するものではありません。
写真とその意味を結び付ける
このステップでは、動作する展示をキーボードで体験してから、自分の展示に最初のカードを作ります。
Web 8080 に切り替え、ページのリンク Study collection を選びます。これは別に用意された見本であり、その変更は自分の作業範囲ではありません。最初の Earthrise の写真ボタンに輪郭が見えるまで Tab を押し、Enter を押してください。詳細表示には月の地平線の上に地球が見えます。Esc を押すと、同じカードにフォーカスが戻ります。もう1枚の Earthrise は写真もキャプションも違うことに注目してください。タイトルはラベルであり、一意の識別情報ではありません。
ページのリンク Current exhibition を選び、自分のページへ戻ります。このリンクは Web サイト内を移動するもので、LabEx のタブを切り替える操作ではありません。最初の自分のページには見出しとクレジットだけがあり、カードはありません。この未完成の状態を変更していきます。
WebIDE に切り替えます。Explorer で /home/labex/project の下にある moonroom フォルダーを開いてください。Explorer が非表示なら View → Explorer を選びます。photos.js を開きます。photos 配列には写真1枚につき1個のオブジェクトがあり、関連する次の値をまとめています。
| フィールド | 意味 |
|---|---|
id |
earthrise-eight のような安定した識別情報。2枚のタイトルが同じでも構いません。 |
thumbnail |
カードで使う、実際のローカル小サイズ画像。 |
large |
ビューアーで使う、対応する大サイズ画像。 |
alt |
画像を見ることができない人に内容を伝える、意味のある説明文。 |
title と caption |
画面上の名前と背景説明。 |
このファイルは編集しないでください。最初のレコードのサムネイルは images/earthrise-eight-thumb.jpg、大きな画像は images/earthrise-eight.jpg です。相対画像パスは HTML ページを基準に解決され、エディターの現在のフォルダーは基準ではありません。大文字と小文字、.jpg の拡張子を正確に指定する必要があります。
figure は画像とその figcaption をまとめます。ボタンにすると、写真を開く操作がネイティブなキーボード操作になります。Tab でフォーカスを受け取り、Enter で起動できます。マウスのクリックハンドラーだけを付けた div には、この動作は自動では備わりません。type="button" は意図するボタンの動作を明示します。表示されたタイトルとキャプションは、マウスを重ねなくても読めます。aria-label はボタンに短いアクセシブルな名前 Open Earthrise を付け、alt は画像の内容を説明します。同じ名前の写真が2枚あるため、aria-describedby も使い、キャプション固有の id を介してボタンとキャプションを関連付けます。キャプションは Apollo 8、Apollo 11 などの区別できる文脈を加えます。アクセシブルな名前はコントロールを識別し、関連付けた説明は文脈を補います。これにより、画像を見ずに操作する人にも、同名の2つのコントロールが区別できます。プレースホルダーや背景画像では、この画像説明は提供できません。
data-photo-card 属性はカード全体のコンテナーを識別し、data-photo-id は開くためのボタンを識別します。どちらもレコードの安定した ID を使います。この小さな関連付け用属性を維持することで、データ、カード、ビューアーが同じ写真を参照できます。画面に表示する文字列ではありません。カードに追加のラッパーがあっても構いませんが、ボタンとキャプションは、対応する data-photo-card を持つ最も近いコンテナーに属する必要があります。
index.html を開き、Ctrl+A または Cmd+A で全体を選択して、以下の完全な HTML で置き換えます。script 行は残してください。defer は HTML の解析後に、記載順にスクリプトを実行します。photos.js はデータを提供し、gallery.js は次のステップでコレクションを生成し、用意済みの viewer.js はこのラボの詳細表示を担当します。dialog のマークアップは先ほど試したパネルです。動作の説明と実装は次のラボで行います。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Moonroom — A little farther from home</title>
<link rel="stylesheet" href="styles.css">
<script src="photos.js" defer></script>
<script src="gallery.js" defer></script>
<script src="viewer.js" defer></script>
</head>
<body>
<main>
<header>
<nav aria-label="Exhibitions"><a href="/">Current exhibition</a> · <a href="/example/">Study collection</a></nav>
<p class="eyebrow">Moonroom / Photographic studies</p>
<h1>A little farther from home.</h1>
<p class="intro">Three moments from the Apollo archive. Take a closer look.</p>
</header>
<section id="gallery" class="photo-grid" aria-label="Photographs">
<figure data-photo-card="earthrise-eight">
<button type="button" class="photo-button" data-photo-id="earthrise-eight" aria-label="Open Earthrise" aria-describedby="caption-earthrise-eight">
<img src="images/earthrise-eight-thumb.jpg" alt="Blue and white Earth above the grey lunar horizon">
<span class="photo-name">Earthrise</span>
</button>
<figcaption id="caption-earthrise-eight">Apollo 8 · Earth above the Moon, December 1968.</figcaption>
</figure>
</section>
<footer>Photographs: NASA. An independent educational exhibition. <a href="credits.html">Image credits</a></footer>
</main>
<dialog id="viewer" aria-labelledby="photo-title" aria-describedby="photo-caption">
<button id="close" type="button" autofocus>Close</button>
<h2 id="photo-title"></h2>
<img id="large-photo" alt="">
<p id="photo-error" role="status"></p>
<p id="photo-caption"></p>
<nav aria-label="Photo navigation">
<button id="previous" type="button">Previous</button>
<p id="photo-position"></p>
<button id="next" type="button">Next</button>
</nav>
</dialog>
</body>
</html>
Ctrl+S または Cmd+S で保存します。Web 8080 に切り替え、必要なら Current exhibition を選び、その Web ページを再読み込みします。Earthrise のカード1枚、実際の画像、Apollo 8 のキャプションが表示されるはずです。まだ簡素なレイアウトですが、後でスタイルを付けます。Tab でカードへ移動し、Enter で開き、Esc で戻ってください。大きな画像は、同じ場面をより詳しく見せるはずです。
画像が壊れていたりページが空白だったりしたら、WebIDE に戻ります。ファイル名が index.html、保存先が moonroom の中になっており、src、引用符、終了タグが見本と一致することを確認します。保存後に Current exhibition を開き直して再読み込みしてください。環境をリセットしたり、別の画像で誤ったパスを隠したりしてはいけません。必要なら完全な HTML を再びコピーし、ファイル全体を復元できます。
実際のカードが見えてから、このステップのチェックを実行します。チェック対象は現在の自分の展示であり、独立した見本ではありません。
識別情報を保ったまますべてのレコードを描画する
このステップでは、手書きした1枚のカードを、完全な配列から生成するカードに置き換えます。
WebIDE にとどまり、比較のために photos.js をもう一度開きます。最初と3番目のレコードはどちらも Earthrise ですが、ID、画像パス、キャプションが違います。タイトルで写真を検索すると、この違いが失われます。完成したギャラリーには、配布された3つの ID ごとに、表示される独立した開くボタンがちょうど1個ずつ必要です。それぞれ対応するサムネイル、タイトル、代替テキスト、キャプションを表示し、元の順序とデータを保持してください。
次に gallery.js を開きます。document.querySelector('#gallery') は id が gallery の要素を選択します。replaceChildren() はその要素を空にして、仮の手書きカードを削除し、二重表示を防ぎます。photos.forEach(function (photo) { ... }) は配列の各レコードについて、順番に1回ずつ内側の関数を呼び出します。photo は現在のオブジェクトです。ループと同じ繰り返し処理の考え方で、各レコードを関数へ渡します。
document.createElement() はメモリー内に実際の HTML 要素を作ります。textContent への代入は、内容を HTML として解釈せず文字列として入れます。append() は準備した要素を指定順に挿入します。ブラウザーから見ると、最初に作った figure、ボタン、画像、キャプションと同じ関係になります。
dataset は data- 属性を読み書きします。JavaScript はキャメルケースを使います。figure.dataset.photoCard は data-photo-card に書き込み、button.dataset.photoId は data-photo-id に書き込みます。どちらも photo.id を受け取り、表示タイトルには photo.title を使います。各キャプションにも caption- と photo.id をつなげた固有の id を設定し、ボタンの aria-describedby がその ID を参照します。表示されるキャプションはボタンの外に置いたまま、ボタンに関連付けます。setAttribute() は aria-label などの属性を設定します。+ 演算子は文字列 Open とタイトルをつなげます。文字列テンプレート構文は使いません。
gallery.js の全内容を次の完全なコードで置き換えて保存します。
const gallery = document.querySelector('#gallery');
gallery.replaceChildren();
photos.forEach(function (photo) {
const figure = document.createElement('figure');
figure.dataset.photoCard = photo.id;
const button = document.createElement('button');
button.type = 'button';
button.className = 'photo-button';
button.dataset.photoId = photo.id;
button.setAttribute('aria-label', 'Open ' + photo.title);
const image = document.createElement('img');
image.src = photo.thumbnail;
image.alt = photo.alt;
const title = document.createElement('span');
title.className = 'photo-name';
title.textContent = photo.title;
const caption = document.createElement('figcaption');
caption.id = 'caption-' + photo.id;
button.setAttribute('aria-describedby', caption.id);
caption.textContent = photo.caption;
button.append(image, title);
figure.append(button, caption);
gallery.append(figure);
});
Web 8080 に切り替え、Current exhibition を再読み込みします。Earthrise、A trace of a visitor、2枚目の Earthrise がこの順序で見えるはずです。キャプションはそれぞれのボタンの下に表示されます。足跡は縦長の写真で、Earthrise は正方形です。この後、ギャラリーのサムネイルは統一したフレームに収めますが、詳細表示では異なる比率を維持します。
Tab と Enter で、それぞれのカードを個別に開いてください。最初の Earthrise のキャプションは Apollo 8、最後は Apollo 11 です。毎回 Esc を押し、使用したカードへフォーカスが戻ることを確認します。最後のカードから開いた場合も、最初ではなく最後のカードへ戻る必要があります。
1枚しか表示されない場合、スクリプトが未保存、構文エラーがある、保存先が違う、といった可能性があります。WebIDE に戻って gallery.js を完全なコードと比較し、forEach の末尾の }); を確認してください。レコードが同じに見えるように photos.js を変更してはいけません。保存してサイトを再読み込みし、カードを再比較してからチェックを実行します。
画面に合わせて配置しキーボードフォーカスを示す
このステップでは、展示全体を狭い画面でも読みやすくし、フォーカスのあるカードを見つけやすくします。
WebIDE に戻り、styles.css を開きます。レスポンシブレイアウトは、固定のページ幅を強制する代わりに、利用できる空間へ合わせます。このスタイルシートは、CSS の基礎で学んだ flexbox を使います。display: flex はカードを横に並べ、flex-wrap: wrap は折り返しを許可し、gap はカード間に余白を作ります。flex: 1 1 260px は figure を拡大・縮小可能にし、260ピクセルを基準の幅にします。min-width: 0 は長い内容をカード内で縮められるようにし、ページ全体が横へ広がるのを防ぎます。
カード画像は width: 100% と aspect-ratio: 4 / 3 で統一したフレームを使います。object-fit: cover は表示上の端を切り取ってサムネイルのフレームを満たすもので、ファイルは変更しません。対照的に、詳細画像は object-fit: contain、max-width、max-height を使い、画像全体を元の比率で表示します。vw と vh はビューポートの幅と高さの100分の1です。min() はサイズに上限を付け、clamp() は見出しのサイズを最小値から最大値の間で変化させます。overflow-wrap: anywhere は非常に長いタイトルを折り返し、ページが横へ広がるのを防ぎます。
:focus-visible はキーボードフォーカスを表示すべきコントロールを選択します。高コントラストの輪郭と outline-offset により、配置を動かさず現在のボタンを示せます。別の明確な表示を用意せずにブラウザーの輪郭を消してはいけません。このプロジェクトでは、2ピクセル以上の明確なフォーカス輪郭、2ピクセル以上の明確な可視境界線、またはキーボードフォーカスを受けたときに現れる可視の影を受け付けます。これらの観察可能な動作を維持すれば、色やレイアウトは変更できます。
後半の規則は、用意済みのビューアーを保持します。::backdrop はモーダルダイアログの背後を装飾し、:disabled は端のボタンが使えないことを見た目で区別します。通常のビューアー画像が display: block でも、[hidden] は失敗した画像を実際に隠す必要があります。動きを減らすメディアクエリーは、ブラウザーの動きに関する設定を尊重します。この展示にはアニメーションのトランジションはありません。
styles.css の全内容を、次の完全なスタイルシートで置き換えて保存します。
* { box-sizing: border-box; }
body { margin: 0; background: #f5f1e8; color: #252c28; font: 17px/1.6 system-ui, sans-serif; }
main { max-width: 1160px; margin: auto; padding: 40px 24px; }
header { max-width: 760px; margin-bottom: 40px; }
.eyebrow { color: #6e593f; font-size: 12px; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 12px 0; font: clamp(2.5rem, 6vw, 5rem)/1.08 Georgia, serif; }
.intro { max-width: 40rem; color: #5c655e; }
.photo-grid { display: flex; flex-wrap: wrap; gap: 28px; }
figure { flex: 1 1 260px; min-width: 0; margin: 0; }
button { color: inherit; font: inherit; cursor: pointer; }
.photo-button { width: 100%; padding: 0; text-align: left; background: transparent; border: 0; }
.photo-button img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 4px; }
.photo-name { display: block; margin-top: 12px; font: 1.5rem/1.3 Georgia, serif; overflow-wrap: anywhere; }
figcaption { margin-top: 6px; color: #5c655e; font-size: 14px; }
a { color: #814820; text-underline-offset: 4px; }
button:focus-visible, a:focus-visible { outline: 3px solid #af421f; outline-offset: 5px; }
footer { margin-top: 48px; border-top: 1px solid #d1c8b8; padding-top: 18px; color: #5c655e; font-size: 13px; }
dialog { width: min(880px, 94vw); max-height: 94vh; overflow: auto; padding: 24px; border: 0; border-radius: 8px; background: #fffcf6; color: #252c28; }
dialog::backdrop { background: #19251fe6; }
dialog h2 { font: 1.8rem/1.3 Georgia, serif; overflow-wrap: anywhere; }
dialog img { display: block; max-width: 100%; max-height: 52vh; margin: auto; object-fit: contain; }
dialog img[hidden] { display: none; }
dialog button { padding: 8px 14px; border: 1px solid #6c746b; border-radius: 4px; background: #fffcf6; }
dialog nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
dialog button:disabled { opacity: .45; cursor: default; }
#photo-position { white-space: nowrap; }
#photo-error { color: #9b301c; }
#photo-error:empty { display: none; }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } }
Web 8080 に切り替え、Current exhibition を再読み込みします。広いビューポートではカードが1行を共有し、狭い場合は読みやすい1列に積み重なります。ブラウザーまたはサイトパネルを約390ピクセル幅にしてから、再び広げます。ページの横スクロール、切れたタイトル、消えたカードがあってはいけません。開いた実際の大きな写真は、縦長または正方形の形を維持する必要があります。
ページ上で Tab を繰り返し押します。サイトのリンクと3つの写真ボタンすべてに、見えるフォーカス輪郭が必要です。Enter はフォーカスのある写真を開き、Esc はそのカードへ戻ります。どの説明もマウスホバーを必要としてはいけません。Image credits を開き、実際のページに NASA の画像ソースが記載されていることを確認してから、Current exhibition を選んで戻ります。これらはサイト内リンクであり、WebIDE と Web 8080 の切り替えとは違います。
レイアウトが簡素なままなら、保存したファイル名が styles.css で、index.html からのリンクが残っていることを確認します。カードがページより広い場合は、min-width: 0、画像の width: 100%、flex の折り返し規則を確認してください。必要ならスタイルシート全体を復元し、扱いにくい写真を隠さず全写真を保ちます。2種類の画面サイズとキーボード経路を確認してから最終チェックを実行します。

この最終的な広い画面の表示で、最初と最後の Earthrise のキャプションを比べてください。写真は異なるミッションのものです。足跡は独立した中央のカードのままです。狭い画面では同じ3枚のカードが縦に積み重なります。
まとめ
実際の画像と説明を関連付け、1枚の意味のあるカードをデータ駆動のコレクション全体へ広げ、同名の2枚の写真を安定した ID で区別しました。レスポンシブなギャラリーは、全カード、実際の画像内容、見えるキーボードフォーカスを保持します。次のラボでは、ナビゲーションの端での制御と、復旧可能な画像エラーを含む詳細ビューアーを自分で作ります。
