/* ========================================================================== cert-viewer.js — 회사소개 #certifications 증서 이미지 뷰어 about-admin-spec.md §3-1 / §4 · about.html 에서만 로드한다. [설계 메모] - home.html 영상 모달(.vmodal)의 패턴을 따르되 두 곳만 다르게 했다: 1) 스크롤 잠금: .vmodal의 body{position:fixed; top:-scrollY} 트릭을 쓰지 않는다. gnb-mobile.js가 그 방식을 명시적으로 거부한다(복원 타이밍이 어긋나면 맨 위로 튄다). 대신 프로젝트의 최신 관례인 overflow:hidden 클래스를 쓴다 — 단, GNB의 .gnb-locked 와 "다른" .cv-locked 이고, 서로의 클래스를 건드리지 않는다. 2) 전체화면 버튼 없음: 증서 이미지는 90vw/80vh contain 으로 이미 판독 가능하고, 버튼을 없애면 포커스 트랩 구성원이 하나로 줄어 로직이 단순해진다. - prompt / alert / confirm 사용 금지 (스펙 §4). - 같은 분류 안 이전/다음 탐색은 넣지 않았다. 필요해지면 이 파일만 고치면 된다: 버튼들이 각자의
안에 DOM 순서대로 있으므로 btn.closest('details').querySelectorAll('.cert-link') 로 목록을 만들 수 있다. ========================================================================== */ (function () { 'use strict'; var viewer = document.getElementById('certViewer'); var panel = document.getElementById('cvPanel'); var titleEl = document.getElementById('cvTitle'); var dateEl = document.getElementById('cvDate'); var img = document.getElementById('cvImg'); var live = document.getElementById('cvLive'); var btnX = document.getElementById('cvClose'); if (!viewer || !panel || !titleEl || !dateEl || !img || !live || !btnX) return; // 이미지가 있는 항목이 하나도 없으면(= 지금 DB 상태) 아무 것도 하지 않는다. var triggers = document.querySelectorAll('.cert-link'); if (!triggers.length) return; var lastFocus = null; // 닫힐 때 포커스를 되돌릴 원래 제목 버튼 function isOpen() { return viewer.classList.contains('is-open'); } /* '' | 'loading' | 'error' — 보이는 문구는 CSS가 이 클래스로만 고른다 */ function setState(name) { viewer.classList.toggle('is-loading', name === 'loading'); viewer.classList.toggle('is-error', name === 'error'); } /* 배경 스크롤 잠금. GNB의 .gnb-locked 는 절대 건드리지 않는다 — 클래스가 달라서 한쪽을 지워도 다른 쪽 잠금이 그대로 유지된다. */ function lock(on) { document.documentElement.classList.toggle('cv-locked', on); if (document.body) document.body.classList.toggle('cv-locked', on); } function open(btn) { if (isOpen()) return; var src = btn.getAttribute('data-cert-src') || ''; var title = btn.getAttribute('data-cert-title') || ''; var date = btn.getAttribute('data-cert-date') || ''; if (!src) return; // 이미지 없는 항목은 애초에 버튼이 아니지만 방어 lastFocus = btn; titleEl.textContent = title; dateEl.textContent = date; dateEl.hidden = !date; // 날짜 없는 항목이면 캡션 둘째 줄을 없앤다 img.alt = title ? title + ' 증서 이미지' : '증서 이미지'; setState('loading'); live.textContent = '증서 이미지를 불러오는 중입니다.'; img.removeAttribute('src'); img.src = src; // 클릭한 시점에만 요청 — 목록 렌더 때 요청 0건 viewer.classList.add('is-open'); lock(true); btnX.focus(); // .vmodal 과 달리 rAF 불필요 // (display:none→block 이라 같은 틱에 포커스가 먹는다) // 캐시된 이미지는 load 이벤트가 안 올 수 있다 → 즉시 확정 if (img.complete && img.naturalWidth > 0) { setState(''); live.textContent = ''; } } function close() { if (!isOpen()) return; viewer.classList.remove('is-open'); setState(''); live.textContent = ''; img.removeAttribute('src'); // 큰 증서 이미지 메모리 해제 img.alt = ''; lock(false); if (lastFocus) { lastFocus.focus(); lastFocus = null; } } img.addEventListener('load', function () { if (!isOpen()) return; setState(''); live.textContent = ''; }); img.addEventListener('error', function () { if (!isOpen()) return; // close()의 removeAttribute('src')가 만드는 error 는 무시한다 if (!img.getAttribute('src')) return; setState('error'); live.textContent = '이미지를 불러올 수 없습니다.'; }); for (var i = 0; i < triggers.length; i++) { triggers[i].addEventListener('click', function () { open(this); }); } /* 닫기 3가지 — ✕ / 배경 / ESC. ✕와 배경은 data-cv-close 하나로 묶어 위임 처리한다(.vmodal 과 같은 방식). */ viewer.addEventListener('click', function (e) { var t = e.target; if (t && t.closest && t.closest('[data-cv-close]')) close(); }); document.addEventListener('keydown', function (e) { if (!isOpen()) return; if (e.key === 'Escape' || e.key === 'Esc') { // preventDefault / stopPropagation 을 하지 않는다. // gnb-mobile.js 도 document 에서 ESC 를 듣지만 close()가 "열려 있지 않으면" // 바로 빠지므로 충돌이 없다. stopPropagation 은 같은 document 리스너끼리 // 어차피 효과가 없다. close(); return; } if (e.key !== 'Tab') return; /* 포커스 트랩 — 범위는 .cv-panel 안쪽뿐. 지금 패널의 포커스 가능 요소는 ✕ 하나다(이미지·캡션은 포커스 불가). 그래서 Tab/Shift+Tab 은 항상 ✕로 되돌아온다 — 버그가 아니라 의도다. 나중에 이전/다음 버튼을 넣으면 아래 루프가 그대로 3개를 순환한다. */ var f = panel.querySelectorAll( 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (!f.length) return; var first = f[0], last = f[f.length - 1]; if (f.length === 1) { e.preventDefault(); first.focus(); return; } if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); })();