/** * YS Project - 추천 스타일 공용 이미지 라이트박스 (작품/배우 상세) * Mobile-first, vanilla JS (no framework dependency) * * 기획서: docs/RECOMMEND_STYLE_PLAN.md §11.1 / §11.3 / §11.4 * * 담당 범위 * - 단독 모드: 작품 커버 · 배우 프로필(.styles-detail__cover-img) 확대 보기 * - 갤러리 모드: 샘플 이미지(.styles-detail__sample) 순회 보기 * * 설계 원칙 * ① 새 모달을 발명하지 않는다 — styles-index.js 의 시트 패턴(ESC·포커스 트랩· * body.styles-sheet-lock·트리거 포커스 복귀)을 그대로 따른다. * ② 데이터 출처는 DOM 이다. 갤러리 목록은 서버가 styleSafeImageUrl() 을 통과시켜 * 렌더한 앵커의 href 를 순서대로 수집해서 만든다. JSON 주입 지점을 새로 만들지 않는다. * ③ JS 가 죽어도 기능이 남는다. 앵커의 href/target="_blank" 는 그대로 두고 * 라이트박스를 실제로 열 때만 preventDefault() 한다. * ④ 사용자 데이터는 textContent / .alt / setAttribute 로만 넣는다 (innerHTML 금지). */ (function () { 'use strict'; // styles-index.js 의 시트와 동일한 포커스 대상 선택자 var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]),' + ' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; var COVER_SELECTOR = 'img.styles-detail__cover-img'; var SAMPLE_SELECTOR = 'a.styles-detail__sample'; // 가로 스와이프로 인정할 최소 이동량(px) var SWIPE_THRESHOLD = 40; var lb = null; // 오버레이 DOM 묶음 — 첫 열림 때 생성해서 재사용 var items = []; // [{ src, alt }] — 현재 순회 중인 목록 var current = 0; var lastTrigger = null; // 닫은 뒤 포커스를 되돌릴 요소 var touchTracking = false; var touchStartX = 0; var touchStartY = 0; // ============================================================ // Overlay DOM (lazy) // ============================================================ /** * 오버레이는 첫 열림 때 한 번만 만든다. * 트리거가 없는 페이지에서는 init() 이 일찍 빠져나가므로 아예 생성되지 않는다. */ function buildLightbox() { var root = document.createElement('div'); root.className = 'styles-lightbox'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-modal', 'true'); root.setAttribute('aria-label', '이미지 보기'); var closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'styles-lightbox__close'; closeBtn.setAttribute('aria-label', '닫기'); closeBtn.textContent = '×'; var prevBtn = document.createElement('button'); prevBtn.type = 'button'; prevBtn.className = 'styles-lightbox__nav styles-lightbox__nav--prev'; prevBtn.setAttribute('aria-label', '이전 이미지'); prevBtn.textContent = '‹'; var nextBtn = document.createElement('button'); nextBtn.type = 'button'; nextBtn.className = 'styles-lightbox__nav styles-lightbox__nav--next'; nextBtn.setAttribute('aria-label', '다음 이미지'); nextBtn.textContent = '›'; var img = document.createElement('img'); img.className = 'styles-lightbox__img'; img.alt = ''; var error = document.createElement('p'); error.className = 'styles-lightbox__error'; error.textContent = '이미지를 불러올 수 없습니다.'; var counter = document.createElement('p'); counter.className = 'styles-lightbox__counter'; counter.setAttribute('aria-live', 'polite'); // DOM 순서 = 탭 순서. 닫기 → 이전 → 다음 순으로 잡히게 둔다 // (단독 모드에서는 nav 가 display:none 이라 포커스 목록에서 자동으로 빠진다). root.appendChild(closeBtn); root.appendChild(prevBtn); root.appendChild(nextBtn); root.appendChild(img); root.appendChild(error); root.appendChild(counter); document.body.appendChild(root); closeBtn.addEventListener('click', function () { closeLightbox(); }); prevBtn.addEventListener('click', function () { go(-1); }); nextBtn.addEventListener('click', function () { go(1); }); // 이미지 자체와 컨트롤을 뺀 나머지(= 배경)를 누르면 닫는다 root.addEventListener('click', function (e) { var t = e.target; if (!t || typeof t.closest !== 'function') return; if (t.closest('.styles-lightbox__img, .styles-lightbox__nav, .styles-lightbox__close')) return; closeLightbox(); }); img.addEventListener('load', function () { root.classList.remove('styles-lightbox--error'); }); img.addEventListener('error', function () { // 이미지 하나가 깨져도 좌우 이동은 계속 되어야 한다 — 닫지 않고 안내만 띄운다 root.classList.add('styles-lightbox--error'); }); root.addEventListener('touchstart', onTouchStart, { passive: true }); root.addEventListener('touchend', onTouchEnd, { passive: true }); return { root: root, img: img, counter: counter }; } // ============================================================ // Open / Close / Navigate // ============================================================ function isOpen() { return lb !== null && lb.root.classList.contains('styles-lightbox--open'); } /** * 라이트박스 안에서 실제로 포커스를 받을 수 있는 요소들. * getClientRects() 만으로는 부족하다 — visibility:hidden 은 레이아웃 박스를 남기므로 * 사각형이 잡히지만 focus() 는 조용히 무시된다. visibility 까지 봐야 한다. * (styles-index.js 의 focusablesInSheet() 와 같은 판정) */ function focusables() { if (lb === null) return []; return Array.prototype.filter.call( lb.root.querySelectorAll(FOCUSABLE), function (el) { return el.getClientRects().length > 0 && window.getComputedStyle(el).visibility !== 'hidden'; } ); } function render() { var item = items[current]; if (!item) return; // 이전 장에서 남은 오류 표시를 먼저 지운다 (load 가 늦게 와도 상태가 꼬이지 않게) lb.root.classList.remove('styles-lightbox--error'); lb.img.alt = item.alt || ''; lb.img.src = item.src; lb.counter.textContent = (current + 1) + ' / ' + items.length; preloadNeighbours(); } /** 인접 1장씩 미리 받아 둔다 — 좌우로 넘길 때의 흰 화면을 없앤다 */ function preloadNeighbours() { if (items.length < 2) return; var targets = [ items[(current + 1) % items.length], items[(current - 1 + items.length) % items.length] ]; targets.forEach(function (item) { if (!item || !item.src) return; var pre = new Image(); pre.src = item.src; }); } /** 양 끝에서 순환한다 (마지막 → 다음 = 첫 장) */ function go(delta) { if (!isOpen() || items.length < 2) return; current = (current + delta + items.length) % items.length; render(); } /** * @param {Array} list [{ src, alt }] 순회 목록 (1개면 단독 모드) * @param {number} index 시작 위치 (0-base) * @param {Element} trigger 닫은 뒤 포커스를 되돌릴 요소 */ function openLightbox(list, index, trigger) { if (list.length === 0) return; if (lb === null) lb = buildLightbox(); items = list; current = index; lastTrigger = trigger; // 단독 모드는 n/m 인디케이터와 좌우 화살표를 숨긴다 (기획서 §11.1) if (items.length < 2) { lb.root.classList.add('styles-lightbox--single'); } else { lb.root.classList.remove('styles-lightbox--single'); } render(); lb.root.classList.add('styles-lightbox--open'); // 배경 스크롤 잠금은 시트와 같은 클래스를 재사용한다 — 잠금 수단을 두 개 만들지 않는다 document.body.classList.add('styles-sheet-lock'); document.addEventListener('keydown', onModalKeydown); // 클래스를 붙인 직후에는 아직 visibility:hidden 이라 focus() 가 무시된다 → 다음 프레임에 옮긴다 window.requestAnimationFrame(function () { if (!isOpen()) return; var nodes = focusables(); if (nodes.length > 0) nodes[0].focus(); }); } function closeLightbox() { if (!isOpen()) return; lb.root.classList.remove('styles-lightbox--open'); lb.root.classList.remove('styles-lightbox--error'); document.body.classList.remove('styles-sheet-lock'); document.removeEventListener('keydown', onModalKeydown); var trigger = lastTrigger; items = []; current = 0; lastTrigger = null; // 트리거가 그 사이 교체됐을 수 있다 (styles-detail.js 가 깨진 img 를 플레이스홀더로 바꾼다) if (trigger && document.contains(trigger)) { trigger.focus(); } } // ============================================================ // Keyboard // ============================================================ function onModalKeydown(e) { if (!isOpen()) return; if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); return; } if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); return; } if (e.key === 'ArrowRight') { e.preventDefault(); go(1); return; } if (e.key !== 'Tab') return; // 포커스 트랩 — 라이트박스 밖으로 탭이 새어 나가지 않게 양 끝에서 되돌린다. var nodes = focusables(); if (nodes.length === 0) { e.preventDefault(); return; } var first = nodes[0]; var last = nodes[nodes.length - 1]; if (!lb.root.contains(document.activeElement)) { 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(); } } // ============================================================ // Touch swipe // ============================================================ function onTouchStart(e) { if (!e.touches || e.touches.length !== 1) { touchTracking = false; return; } touchTracking = true; touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; } function onTouchEnd(e) { if (!touchTracking) return; touchTracking = false; if (!e.changedTouches || e.changedTouches.length === 0) return; var dx = e.changedTouches[0].clientX - touchStartX; var dy = e.changedTouches[0].clientY - touchStartY; if (Math.abs(dx) < SWIPE_THRESHOLD) return; // 세로로 더 많이 움직였으면 스와이프가 아니다 (스크롤 의도) if (Math.abs(dy) > Math.abs(dx)) return; go(dx < 0 ? 1 : -1); } // ============================================================ // Triggers (event delegation) // ============================================================ /** 수식키가 섞인 클릭은 가로채지 않는다 — 새 탭/새 창으로 여는 사용자 의도를 존중한다 */ function isPlainClick(e) { return e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey; } /** * 로드에 실패한 이미지는 열지 않는다 (기획서 §11.1). * styles-detail.js 의 data-styles-fallback 대체 동작과 충돌하지 않게 하기 위함이다. */ function imageIsUsable(img) { return !(img.complete && img.naturalWidth === 0); } function openStandalone(img) { openLightbox([{ src: img.src, alt: img.alt || '' }], 0, img); } /** * 갤러리 목록은 클릭한 앵커가 속한 [data-styles-gallery] 안에서만 모은다. * 배우 상세는 작품 묶음마다 컨테이너가 따로라 순회가 작품 경계를 넘지 않는다 (기획서 §11.4). */ function collectGallery(anchor) { var container = anchor.closest('[data-styles-gallery]'); var anchors = container === null ? [anchor] : Array.prototype.slice.call(container.querySelectorAll(SAMPLE_SELECTOR)); var list = []; var start = 0; for (var i = 0; i < anchors.length; i++) { var href = anchors[i].getAttribute('href'); if (!href) continue; if (anchors[i] === anchor) start = list.length; var thumb = anchors[i].querySelector('img'); list.push({ src: href, alt: thumb !== null ? (thumb.alt || '') : '' }); } return { list: list, start: start }; } function onDocumentClick(e) { if (!isPlainClick(e)) return; var t = e.target; if (!t || typeof t.closest !== 'function') return; var anchor = t.closest(SAMPLE_SELECTOR); if (anchor !== null) { // href 가 없거나 썸네일이 깨졌으면 가로채지 않는다 → 기존 새 탭 동작이 그대로 남는다 if (!anchor.getAttribute('href')) return; var thumb = anchor.querySelector('img'); if (thumb === null || !imageIsUsable(thumb)) return; var gallery = collectGallery(anchor); if (gallery.list.length === 0) return; e.preventDefault(); openLightbox(gallery.list, gallery.start, anchor); return; } var cover = t.closest(COVER_SELECTOR); if (cover !== null) { if (!imageIsUsable(cover)) return; e.preventDefault(); openStandalone(cover); } } /** 커버 이미지는 role="button" 이므로 Enter/Space 로도 열려야 한다 */ function onDocumentKeydown(e) { if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return; var t = e.target; if (!t || typeof t.closest !== 'function') return; var cover = t.closest(COVER_SELECTOR); if (cover === null) return; // Space 로 페이지가 스크롤되지 않게 막는다 (Enter 도 버튼 시맨틱상 기본 동작이 없다) e.preventDefault(); if (!imageIsUsable(cover)) return; openStandalone(cover); } // ============================================================ // Progressive enhancement // ============================================================ /** * 커버/프로필 이미지를 버튼처럼 만든다. * 마크업이 아니라 JS 로 붙이는 이유: 이 파일이 로드되지 않으면 확대 기능도 없으므로 * role="button" 만 남아 스크린리더에 거짓말을 하는 상태를 피한다. */ function enhanceCovers() { var covers = document.querySelectorAll(COVER_SELECTOR); Array.prototype.forEach.call(covers, function (img) { var label = (img.alt || '').trim(); img.setAttribute('role', 'button'); img.setAttribute('tabindex', '0'); img.setAttribute('aria-label', label !== '' ? label + ' 크게 보기' : '이미지 크게 보기'); img.classList.add('styles-detail__cover-img--zoomable'); }); } // ============================================================ // Initialize // ============================================================ function init() { // 트리거 후보가 하나도 없는 페이지에서는 리스너도 오버레이도 만들지 않는다 if (document.querySelector(COVER_SELECTOR) === null && document.querySelector(SAMPLE_SELECTOR) === null) { return; } enhanceCovers(); // 위임으로 붙인다 — styles-detail.js 가 깨진 를 런타임에 교체하므로 // 요소에 직접 건 리스너는 사라질 수 있다. document.addEventListener('click', onDocumentClick); document.addEventListener('keydown', onDocumentKeydown); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();