키보드 사진 뷰어 만들기

JavaScriptBeginner
지금 연습하기

소개

Moonroom의 실제 사진 카드에는 읽기 좋은 설명과 반응형 레이아웃이 생겼습니다. 상세 뷰어는 아직 연결되지 않았습니다. 각 카드가 자신의 큰 사진을 열고, 방문자가 모음을 탐색하며, 처음 사용한 바로 그 카드로 돌아오게 만드는 것이 목표입니다. 다른 이미지로 대신하지 않고 실제 이미지 누락 오류를 만들고 복구하는 연습도 합니다.

이 실습은 자체 완성된 갤러리와 로컬 사진으로 시작하며 이전 환경의 파일에 의존하지 않습니다. 첫 실습의 카드와 데이터 관계, JavaScript 함수, DOM 선택과 이벤트를 알아야 합니다. 새로운 배열 검색, 대화상자, 초점, 이미지 이벤트 동작은 사용 전에 소개합니다. 읽기와 연습에 걸리는 60분은 작성자의 예상 시간입니다.

웹 페이지를 열거나 새로 고친 뒤에는 로딩이 끝난 후 사진 컨트롤을 작동하세요. /home/labex/project/moonroom에는 WebIDE, 웹 사이트에는 Web 8080을 사용합니다. viewer.js를 편집하세요. photos.js의 large 경로를 임시로 바꾸는 것은 명시적으로 가르친 실패 연습에만 허용되며 검사 전에 복원해야 합니다. 나머지 제공 파일, 메타데이터, 원본 이미지는 보존합니다. 두 Earthrise 레코드는 별도로 남고 모든 카드, 썸네일, 설명은 사용 가능해야 합니다. 동등한 JavaScript 동작을 사용해도 되지만 제공된 버튼 ID, 안정적인 사진 연결 속성, 보이는 Close, Previous, Next 컨트롤과 기본 대화상자의 제목 및 설명 연결을 유지하세요. 이 키보드 연습은 완전한 접근성 인증이 아닙니다.

올바른 사진을 열고 원래 카드로 돌아오기

이 단계에서는 기존 카드를 기본 제공 모달 대화상자에 연결하고 키보드 초점의 확실한 복귀 지점을 만듭니다.

Web 8080으로 전환하고 웹 사이트 링크 Study collection을 선택하여 Tab, Enter, Esc로 사진을 열고 닫으세요. 그런 다음 Current exhibition을 선택합니다. 세 카드와 설명은 이미 있습니다. 카드에서 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는 0에서 시작하는 선택된 배열 위치이고, openingButton은 실제로 패널을 연 버튼을 기억합니다. photos[currentIndex]는 해당 사진의 전체 레코드를 가져옵니다. showPhoto()는 제목, 설명, 대체 텍스트, 실제 큰 이미지를 함께 갱신합니다.

갤러리에는 클릭 리스너가 하나 있습니다. 기본 버튼에서 누른 키보드 Enter도 이를 작동시킵니다. event.target은 정확히 클릭된 요소로 버튼 안의 이미지나 제목일 수도 있습니다. closest('button[data-photo-id]')는 바깥으로 올라가 일치하는 열기 버튼을 찾습니다. if (!button) return은 그런 버튼에서 오지 않은 클릭을 무시합니다. 이것이 이벤트 위임이며 하나의 리스너가 기존의 모든 카드 버튼을 처리합니다.

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로 두 번째 닫기 방법을 시험하세요. Previous와 Next는 다음 단계에서 연결합니다.

패널이 열리지 않으면 WebIDE로 돌아가 전체 코드와 비교합니다. viewer.js로 저장되었는지, index.html의 스크립트 줄이 남아 있는지, data-photo-id 값이 여전히 photos.js와 일치하는지 확인하세요. 오타를 고친 후 저장하고 웹 사이트를 새로 고칩니다. 잘못된 장면이 나오면 현재 레코드의 large, 제목, 설명을 함께 비교하세요. ID 검색 오류를 감추려고 원본 데이터를 바꾸지 마세요. 세 개의 독립된 Enter/Esc 경로가 모두 작동한 뒤 검사를 실행합니다.

전체 모음과 경계 탐색하기

이 단계에서는 처음 열었던 카드를 잃지 않고 같은 상세 보기를 갱신합니다.

WebIDE로 돌아가 photos.js의 0부터 시작하는 배열 위치를 비교하세요. 첫 위치는 0, 두 번째는 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에 가서 두 이미지를 거쳐 돌아오세요. 사용 가능한 컨트롤 사이에서 뒤로 움직여야 하면 Shift를 누른 채 Tab을 누릅니다. 이는 키보드 초점을 움직이는 것이며 Previous를 작동시켜 사진을 바꾸는 것과 다릅니다. Esc를 누르고 원래 열었던 첫 카드로 초점이 돌아오는지 확인합니다. 이제 중간 카드를 직접 열고 다른 사진으로 이동한 뒤 닫으세요. 초점은 중간 카드로 돌아와야 합니다.

첫 번째나 마지막 경계에서 버튼이 비활성인 것은 정상입니다. 활성으로 보이는데 아무 동작도 없는 것은 정상이 아닙니다. 제목만 바뀌고 사진은 그대로라면 WebIDE로 돌아가 모든 이동이 같은 showPhoto() 함수를 호출하는지 확인하세요. Esc가 항상 첫 카드로 돌아오면 openingButton이 카드 열기 리스너 안에서 대입되고 탐색 핸들러에서 덮어쓰이지 않는지 확인합니다. 필요하면 전체 코드를 복원하고 저장하여 새로 고친 뒤 검사하세요.

누락된 이미지를 정직하게 알리고 복구하기

이 단계에서는 실제 이미지 로딩 실패를 처리하며 방문자의 위치와 나머지 사진 모음의 사용 가능성을 유지합니다.

이미지의 load 이벤트는 파일이 성공적으로 로딩되었음을, error는 경로가 없을 때처럼 이미지를 불러오지 못했음을 뜻합니다. 이는 브라우저 이미지 이벤트이지 임의의 HTTP 응답이 유효한 사진을 포함한다는 증거가 아닙니다. 서버 연결 실패는 전체 페이지를 사용할 수 없게 할 수 있지만, 여기서는 페이지는 동작하고 이미지 요청 하나만 실패합니다.

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을 새로 고쳐 브라우저가 복원된 데이터를 읽게 합니다. 발자국을 다시 엽니다. 이제 실제 세로 이미지가 디코딩되어 오류 메시지 없이 나타나야 합니다. 브라우저 새로 고침은 페이지 데이터를 다시 읽는 것이지 저장된 작업을 초기화하거나 환경을 다시 만드는 것이 아닙니다.

복원된 경로가 실제 세로 사진을 다시 로딩합니다.

경로를 복원하고 새로 고치면 세로 발자국이 늘어나지 않고 뷰어에 표시되며 오류 메시지는 사라집니다.

마지막으로 세 카드, 양방향 경계, 두 닫기 경로를 다시 확인하세요. 웹 사이트 패널을 좁혀 전체 사진과 줄바꿈된 제목이 페이지 가로 스크롤 없이 대화상자에 들어가는지 확인합니다. 모든 원본 썸네일, 메타데이터, 이미지, 출처를 유지해야 합니다. 최종 검사는 제공된 데이터의 복원을 요구하고 파일의 일회용 사본에서만 실패 처리를 시험합니다. 실제 전시의 이미지를 삭제하지 않습니다.

요약

안정적인 ID로 기본 대화상자에 각 사진의 실제 내용을 보여 주었습니다. 경계를 지키는 이전/다음 탐색을 추가하고 Esc와 Close의 원래 복귀 지점을 보존했으며, 다른 사진으로 대신하지 않고 진짜 이미지 누락 요청을 처리했습니다. 갤러리는 국소적인 실패를 정직하게 알리면서 카드 배치, 설명, 다른 사진을 사용 가능하게 유지합니다.