Introduction
Moonroom's real photo cards now have readable captions and a responsive layout. The detailed viewer is disconnected. Your task is to make each card open its own larger photograph, let visitors move through the collection, and let them return to exactly the card they used. You will also create and recover from a real missing-image error without substituting another picture.
This lab starts with its own complete grid and local photos. It does not depend on files from a previous environment. You need the first lab's card/data relationships, JavaScript functions, DOM selection and events. New array lookup, dialog, focus and image-event behavior are introduced before use. The reading and practice time is an author estimate of 60 minutes.
After opening or refreshing a website page, let it finish loading before activating its photo controls. Use WebIDE for /home/labex/project/moonroom and Web 8080 for the website. Edit viewer.js; the temporary large path change in photos.js is only for the explicitly taught failure exercise and must be restored before checking. Preserve the other supplied files, metadata and original images. The two Earthrise records remain separate, and every card, thumbnail and caption must remain usable. You may use equivalent JavaScript behavior; keep the supplied button IDs, stable photo hooks, visible Close, Previous and Next controls, and the native dialog's title/description associations. These keyboard exercises are not a complete accessibility certification.
Open the Right Photo and Return to Its Card
In this step, connect the existing cards to a native modal dialog, including a reliable return point for keyboard focus.
Switch to Web 8080, choose the website link Study collection, and use Tab, Enter and Esc to open and close a photograph. Then choose Current exhibition. Its three cards and captions are already present. Try Enter on a card: it has no effect yet because your viewer.js contains only a comment. That is the expected incomplete behavior, not an environment failure.
Switch to WebIDE, open the moonroom folder in Explorer, and read index.html. The dialog element is a built-in browser panel. Its aria-labelledby="photo-title" points to the visible heading, and aria-describedby="photo-caption" points to the current explanation. showModal() opens it as a modal: other page content becomes inactive while it is open. The browser handles the basic modal Tab behavior and Esc close action; you do not need to write a custom focus trap. Do not add tabindex to the dialog itself. The prepared Close button has autofocus, so opening the panel gives focus to a clear way to leave it.
Open viewer.js. const holds references to DOM elements. let is used for values we will change: currentIndex is the selected array position, starting at zero, and openingButton remembers the actual button that opened the panel. photos[currentIndex] retrieves that photograph's complete record. showPhoto() updates its title, caption, alternative text and real larger image together.
The grid has one click listener. Keyboard Enter on a native button also activates it. event.target is the exact clicked element, which might be the image or title inside a button. closest('button[data-photo-id]') walks outward to the matching opening button. if (!button) return ignores clicks that did not come from one. This is event delegation: one listener handles all the existing card buttons.
findIndex() searches the array and returns the position whose ID matches the button's dataset.photoId. Its callback returns true when photo.id === button.dataset.photoId; === compares without converting the values' types. If no record matches, findIndex() returns -1, so the guard stops rather than opening the wrong photograph. We deliberately compare IDs, not the repeated title Earthrise.
The dialog's close event runs whether it closed through Esc or the Close button. openingButton.focus() restores focus to the remembered card. This use of focus() implements the return behavior; your manual test still needs to arrive at and activate cards using the real keyboard.
Replace all of viewer.js with the complete code below, then save with Ctrl+S or 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();
});
Switch to Web 8080, choose Current exhibition if necessary, and refresh the page. Use Tab to reach each card, then Enter to open it. The first Earthrise must show the Apollo 8 photograph and caption; the last must show Apollo 11. Press Esc after each one and observe focus on that same card. Reopen one photo and use Tab then Enter on Close as a second closing path. Previous and Next are not connected until the next step.
If no panel opens, return to WebIDE and compare the file with the complete block. Check that it is saved as viewer.js, the script line remains in index.html, and the data-photo-id values still match photos.js. Save and refresh the website after correcting a typo. If the wrong scene appears, compare the current record's large, title and caption together; do not change the source data to disguise an ID lookup error. Run the check after all three independent Enter/Esc paths work.
Navigate the Whole Collection and Its Boundaries
In this step, update the same detailed view without losing the original card that opened it.
Return to WebIDE and compare the zero-based array positions in photos.js: first is 0, second is 1, last is photos.length - 1. length counts the records. A visible position should instead count from one, so currentIndex + 1 gives a human-readable number such as 1 / 3.
The disabled property takes a Boolean. previous.disabled = currentIndex === 0 disables Previous only at the first photograph; the corresponding Next rule disables it only at the last. These boundaries prevent an out-of-range lookup. The click handlers also check the bounds before changing the index. currentIndex -= 1 subtracts one and stores the new index; += 1 adds one. Every change calls showPhoto() so the image, description and position stay synchronized.
Only opening a card changes openingButton. Navigating to another photograph inside the dialog must not change it: closing still returns to the card where the visit began, even if a different photograph is currently shown.
Replace all of viewer.js with this complete version and save it. It preserves opening and closing while adding the bounded navigation:
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();
});
Switch to Web 8080 and refresh Current exhibition. Use the keyboard to open the first Earthrise. Previous is disabled; Tab skips that disabled button. Tab to Next and press Enter. You should see the portrait bootprint, its title and caption, and 2 / 3. Continue to the last Earthrise and confirm that its Apollo 11 image and caption appear while Next is disabled.
Tab to Previous and travel back through both images. Hold Shift while pressing Tab when you need to move backward through available controls; this moves keyboard focus, while activating Previous changes the photograph. These are different actions. Press Esc and confirm that focus returns to the original first card. Now open the middle card directly, navigate elsewhere, and close: focus must return to the middle card.
A disabled first/last boundary is expected. A control that appears active but does nothing is not. If a title changes while the photograph does not, return to WebIDE and ensure every move calls the same showPhoto() function. If Esc always returns to the first card, confirm openingButton is assigned inside the card-opening listener and is not overwritten in the navigation handlers. Restore the complete code if needed, save and refresh before checking.
Make a Missing Image Honest and Recoverable
In this step, handle a real image-loading failure while keeping the visitor's place and the rest of the collection usable.
An image's load event means that its file loaded successfully; error means the image could not be loaded, for example because its path is missing. These are browser image events, not proof that an arbitrary HTTP response contains a valid picture. A server connection failure can make the whole page unavailable; here the page works while one image request fails.
In WebIDE, open viewer.js. This version sets the event handlers before assigning src, so a fast cached image cannot finish before its handlers exist. It hides the old picture while loading and displays Loading photo... in the existing role="status" paragraph. On success it shows the real image and clears the message. On error it keeps the image hidden and provides readable recovery guidance. The CSS rule dialog img[hidden] { display: none; } is necessary because the ordinary dialog image rule uses display: block. A hidden attribute alone would otherwise be overridden by that CSS rule.
The error handler does not change src to a thumbnail or another photograph. That would hide the failure and show content that does not match the current record. The user can still close the panel or navigate to an intact photograph.
Replace all of viewer.js with the complete final version, and save it:
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();
});
Now perform a controlled path mistake and recovery in your own files. Open photos.js in WebIDE. In the record whose id is footprint, change only its large value from images/footprint.jpg to images/missing-photo.jpg. Do not change its thumbnail, ID, caption or alternative text. Save the file.
Switch to Web 8080 and refresh Current exhibition, then use Tab and Enter to open A trace of a visitor. Its thumbnail still works, but the larger image request now fails. Expect the visible text This photo could not be loaded. Close the viewer and check its large image path. The panel must not display Earthrise or a stretched thumbnail as a replacement. Its bootprint title and caption remain, and the Close, Previous and Next controls are still available.

The failed panel keeps the bootprint title and caption, with no replacement image. Next remains available so the visitor can continue.
Use Tab and Enter on Next. The intact Apollo 11 image should load, and the error message should clear. Press Esc and check that focus returns to the bootprint card that originally opened the panel. This is recovery within the collection; it has not repaired the bad file path.
Return to WebIDE, restore the footprint record's exact large value to images/footprint.jpg, and save. Switch to Web 8080 and refresh Current exhibition so the browser reads the restored data. Open the bootprint again. Its real portrait image must now decode and appear without the error message. A browser refresh reloads page data; it does not reset your saved work or recreate the environment.

After restoring the path and refreshing, the portrait bootprint fills the viewer without stretching; the error message is gone.
Finally, check all three cards, forward/backward boundaries and both closing paths again. Narrow the website pane and confirm the whole photograph and wrapped heading fit inside the dialog without horizontal page scrolling. All original thumbnails, metadata, images and credits must remain. The final check requires the supplied data to be restored and tests failure handling only in a disposable copy of your files; it never deletes an image in your actual exhibition.
Summary
You used stable IDs to show each photograph's real content in a native dialog. You added bounded previous/next navigation, preserved the original return point for Esc and Close, and handled a genuine missing-image request without substituting another picture. Your gallery keeps its grid, captions and other photographs usable while reporting a local failure honestly.
