Введение
Moonroom — небольшая выставка настоящих фотографий миссий Apollo. Коллекция подготовлена, но в текущей выставке ещё нет карточек фотографий. Вы создадите одну содержательную карточку, развернёте её в коллекцию на основе данных и сделаете всю галерею удобной для чтения на узких и широких экранах. Отдельная Study collection показывает ожидаемый процесс просмотра ещё до начала работы.
Нужны базовые знания структуры HTML, селекторов и компоновки CSS, а также объектов, массивов и событий DOM в JavaScript. В этой лабораторной работе объясняются figure, figcaption, встроенные кнопки, стабильные идентификаторы фотографий, forEach, dataset и используемые адаптивные правила. Подробный просмотрщик в первой работе уже подготовлен; в следующей вы создадите его поведение сами. Оценка автора — 50 минут, включая чтение и исправление обычных ошибок редактирования.
После открытия или обновления страницы сайта дождитесь завершения загрузки, прежде чем активировать элементы управления фотографиями. Используйте WebIDE для редактирования /home/labex/project/moonroom, а Web 8080 — для работы с сайтом. В этой работе меняйте только index.html, gallery.js и styles.css. Сохраните photos.js, viewer.js, images/ и credits.html. Предоставленные файлы изображений и метаданные должны оставаться связанными с исходными идентификаторами. Все подписи интерфейса сайта остаются на английском. Эти практические проверки клавиатурой не являются полной сертификацией доступности.
Свяжите Фотографию с Её Смыслом
На этом шаге вы воспользуетесь работающей выставкой с клавиатуры, затем создадите первую карточку своей выставки.
Переключитесь в Web 8080 и выберите ссылку страницы Study collection. Это отдельно подготовленный пример; его изменение не входит в вашу рабочую область. Нажимайте Tab, пока у первой кнопки фотографии Earthrise не появится видимый контур, затем нажмите Enter. Подробный вид показывает Землю над лунным горизонтом. Нажмите Esc: фокус вернётся на ту же карточку. Обратите внимание, что другая карточка Earthrise содержит другую фотографию и подпись. Заголовок — это название, а не уникальный идентификатор.
Выберите ссылку страницы Current exhibition, чтобы вернуться на свою страницу. Эта ссылка перемещает внутри сайта, а не переключает вкладку LabEx. Изначально на вашей странице есть заголовок и сведения об источниках, но нет карточек. Именно это незавершённое состояние вы измените.
Переключитесь в WebIDE. В Explorer откройте папку moonroom в /home/labex/project. Если Explorer скрыт, выберите View → Explorer. Откройте photos.js. Массив photos содержит один объект на фотографию. Каждый объект объединяет связанные значения:
| Поле | Значение |
|---|---|
id |
Стабильный идентификатор, например earthrise-eight; два заголовка могут совпадать. |
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 описывает содержимое изображения. Поскольку две фотографии имеют такое имя, aria-describedby дополнительно связывает кнопку с подписью через уникальный id этой подписи. Подпись добавляет различающий контекст, например Apollo 8 или Apollo 11. Доступное имя обозначает элемент управления, а связанное описание дополняет контекст. Это не позволяет двум одноимённым элементам оказаться неразличимыми для пользователя, который не видит фотографии. Заполнитель или фоновое изображение не дают такого описания изображения.
Атрибут data-photo-card обозначает полный контейнер карточки; data-photo-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 и обновите эту страницу сайта. Должна появиться одна карточка Earthrise с настоящим изображением и подписью Apollo 8. Оформление пока простое; вы добавите его позже. Перейдите к карточке клавишей Tab, откройте через Enter и вернитесь через Esc. Большое изображение должно показывать ту же сцену подробнее.
Если изображение не загружается или страница пуста, вернитесь в WebIDE. Убедитесь, что файл называется index.html, сохранён в moonroom, а src, кавычки и закрывающие теги совпадают с примером. После сохранения снова откройте Current exhibition и обновите страницу. Не сбрасывайте окружение и не подменяйте фотографию другим файлом, чтобы скрыть неверный путь. При необходимости можно восстановить весь файл, повторно скопировав полный HTML.
Запустите проверку этого шага, когда настоящая карточка видна. Проверяется ваша текущая выставка, а не отдельный пример.
Отобразите Все Записи без Потери Идентичности
На этом шаге вы замените одну написанную вручную карточку карточками, создаваемыми из полного массива.
Оставайтесь в WebIDE и снова откройте photos.js для сравнения. Первая и третья записи называются Earthrise, но их идентификаторы, пути изображений и подписи различаются. Поиск фотографий по заголовку потерял бы это различие. Готовая галерея должна содержать ровно одну видимую кнопку открытия для каждого из трёх заданных идентификаторов с соответствующими миниатюрой, заголовком, альтернативным текстом и подписью. Сохраните предоставленные порядок и данные.
Теперь откройте gallery.js. document.querySelector('#gallery') выбирает элемент, у которого id равен gallery. replaceChildren() очищает его и удаляет временную ручную карточку, чтобы она не отображалась дважды. photos.forEach(function (photo) { ... }) вызывает вложенную функцию один раз для каждой записи массива по порядку; photo — текущий объект. Это тот же принцип повторяющейся работы, что и у цикла, с передачей каждой записи в функцию.
document.createElement() создаёт настоящий HTML-элемент в памяти. Присваивание textContent помещает в него текст, а не интерпретирует строку как HTML. append() вставляет подготовленные элементы в указанном порядке. Браузер видит те же отношения, что и в вашей первой структуре из figure, кнопки, изображения и подписи.
dataset читает и записывает атрибуты data-. JavaScript использует camel case: figure.dataset.photoCard записывает data-photo-card, а button.dataset.photoId — data-photo-id. Оба получают photo.id, тогда как видимый заголовок получает photo.title. Каждая подпись также получает уникальный id путём соединения caption- с photo.id; aria-describedby кнопки ссылается на этот идентификатор подписи. Видимая подпись остаётся вне кнопки, но связана с ней. 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 и второй Earthrise в таком порядке. Каждая подпись остаётся видимой под своей кнопкой. След ботинка — вертикальная фотография, изображения Earthrise — квадратные. Скоро галерея поместит миниатюры в одинаковые рамки, сохранив разные пропорции в подробном просмотре.
Используйте Tab и Enter, чтобы открыть каждую карточку отдельно. У первого Earthrise подпись Apollo 8, у последнего — Apollo 11. После каждого нажмите Esc и убедитесь, что фокус возвращается к использованной карточке, включая последнюю, а не всегда к первой.
Если осталась только одна карточка, скрипт может быть не сохранён, содержать синтаксическую ошибку или лежать не в той папке. Вернитесь в WebIDE, сравните gallery.js с полным блоком и проверьте закрывающее }); в конце forEach. Не меняйте photos.js, чтобы записи стали похожими. Сохраните, обновите сайт и снова сравните карточки перед запуском проверки.
Адаптируйте Галерею и Покажите Фокус Клавиатуры
На этом шаге вы сделаете всю выставку удобной для чтения на узком экране и поможете определить карточку с фокусом.
Вернитесь в WebIDE и откройте styles.css. Адаптивная компоновка подстраивается под доступное место вместо принудительной фиксированной ширины страницы. Эта таблица стилей использует flexbox, знакомый по основам CSS. 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 — сотые доли ширины и высоты области просмотра. min() ограничивает размер, а clamp() позволяет размеру заголовка расти между минимумом и максимумом. overflow-wrap: anywhere позволяет очень длинному заголовку переноситься, а не расширять страницу.
:focus-visible выбирает элемент управления, когда следует показывать клавиатурный фокус. Контрастный контур и outline-offset выделяют текущую кнопку, не сдвигая компоновку. Не убирайте контур браузера без другого чёткого индикатора. Этот проект принимает отчётливый контур фокуса толщиной не менее 2 пикселей, отчётливую видимую границу толщиной не менее 2 пикселей или видимую тень, появляющуюся при получении фокуса с клавиатуры. Можно менять цвета и компоновку, сохраняя эти наблюдаемые свойства.
Нижние правила сохраняют подготовленный просмотрщик: ::backdrop оформляет фон за модальным диалогом, :disabled визуально отличает кнопку на границе, а [hidden] должен действительно скрывать не загрузившееся изображение, хотя обычные изображения просмотрщика используют display: block. Медиазапрос уменьшенного движения учитывает настройку браузера; у этой выставки нет анимированных переходов.
Замените всё содержимое 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. В широкой области просмотра карточки занимают один ряд, а в узкой выстраиваются в один читаемый столбец. Измените ширину браузера или панели сайта примерно до 390 пикселей, затем снова увеличьте. Не должно быть горизонтальной прокрутки страницы, обрезанных заголовков или отсутствующей карточки. Настоящая большая фотография при открытии должна сохранять вертикальную или квадратную форму.
На странице многократно нажимайте Tab. Ссылки сайта и все три кнопки фотографий должны показывать видимый контур фокуса. Enter открывает выбранное фото, Esc возвращает к его карточке. Ни одна подпись не должна требовать наведения мыши. Откройте Image credits, чтобы убедиться, что настоящая страница перечисляет источники изображений NASA, затем выберите Current exhibition для возврата. Это ссылки сайта, а не переключение между WebIDE и Web 8080.
Если оформление осталось простым, проверьте сохранённое имя styles.css и то, что index.html по-прежнему на него ссылается. Если карточка шире страницы, проверьте min-width: 0, width: 100% у изображения и правила переноса flex. При необходимости восстановите полную таблицу стилей; сохраните все фото вместо того, чтобы скрывать сложное. Запустите финальную проверку после просмотра обоих размеров экрана и клавиатурного пути.

В этом итоговом широком виде сравните подписи первого и последнего Earthrise: фотографии относятся к разным миссиям. След ботинка остаётся отдельной средней карточкой. Те же три карточки на узком экране выстраиваются вертикально.
Резюме
Вы связали настоящее изображение с описанием, расширили одну семантическую карточку до полной коллекции на основе данных и различили две одноимённые фотографии с помощью стабильных идентификаторов. Адаптивная галерея сохраняет все карточки, реальное содержимое изображений и видимый клавиатурный фокус. В следующей работе вы сами создадите подробный просмотрщик, включая границы навигации и восстановление после ошибки изображения.
