/* ========================================================================== METADEXTER — 모바일 GNB 토글 (헤더 단일화 2단계) 파일 위치: mockups/js/gnb-mobile.js 로딩: 각 HTML 직전, chat-panel.js 옆에서 (_layout.html · home.html · about.html · contact.html 4곳) 짝이 되는 스타일: /css/header.css (E~G절) 마크업 계약 (mockups/_header.html):
← 바로 뒤 형제여야 한다 (CSS가 .site-header.menu-open ~ .gnb-overlay 로 잡는다) 상태는 3곳에만 둔다 — 셋이 항상 함께 움직이도록 setOpen() 한 함수에서만 바꾼다: 1) .site-header 의 .menu-open → 패널/오버레이/z-index (CSS) 2) .hamburger 의 aria-expanded → 아이콘 ☰↔✕ 모프 + 보조기술 상태 (CSS+a11y) 3) html·body 의 .gnb-locked → 배경 스크롤 잠금 (CSS, ≤960px 안에서만 유효) [스크롤 잠금 방식] position:fixed + top:-scrollY 트릭을 쓰지 않는다. 그 방식은 닫을 때 스크롤 위치를 복원해야 하고, 복원 타이밍이 어긋나면 페이지가 맨 위로 튄다. overflow:hidden 은 스크롤 위치를 건드리지 않는다. (iOS Safari 대응으로 html에도 함께 건다 — header.css G절) [961px 방어] CSS가 모든 모바일 규칙을 @media (max-width:960px) 안에 두므로 클래스가 남아 있어도 데스크톱 표시는 정상이다. 다만 aria-expanded="true"가 남으면 보조기술에 거짓 상태가 보이므로 matchMedia change 에서 정리한다. resize 이벤트를 쓰지 않는 이유: 드래그 중 초당 수십 번 발화하는데 실제 필요한 것은 경계를 넘는 순간 1회뿐이다. ========================================================================== */ (function () { 'use strict'; var header = document.querySelector('.site-header'); if (!header) return; var btn = header.querySelector('.hamburger'); var panel = document.getElementById('gnb') || header.querySelector('.gnb'); var overlay = document.querySelector('.gnb-overlay'); if (!btn || !panel) return; var MOBILE_MQ = '(max-width:960px)'; var mq = window.matchMedia ? window.matchMedia(MOBILE_MQ) : null; function isOpen() { return header.classList.contains('menu-open'); } function setOpen(open) { header.classList.toggle('menu-open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-label', open ? '메뉴 닫기' : '메뉴 열기'); document.documentElement.classList.toggle('gnb-locked', open); if (document.body) document.body.classList.toggle('gnb-locked', open); } function open() { if (isOpen()) return; setOpen(true); // 열자마자 패널 첫 항목(링크 또는 하위 메뉴 버튼)으로 포커스를 옮긴다. // CSS가 visibility:hidden → visible 로 바꾸므로, 같은 틱에 focus()를 부르면 // 아직 hidden 상태라 포커스가 먹지 않는다. 다음 프레임으로 미룬다. var first = panel.querySelector('a, button'); if (!first) return; if (window.requestAnimationFrame) { window.requestAnimationFrame(function () { if (isOpen()) first.focus(); }); } else { first.focus(); } } // refocus=true 이면 햄버거로 포커스를 되돌린다 (ESC 경로). // 오버레이 클릭·링크 클릭은 사용자가 이미 다른 곳을 가리키고 있으므로 되돌리지 않는다. function close(refocus) { if (!isOpen()) return; setOpen(false); if (refocus) btn.focus(); } btn.addEventListener('click', function () { if (isOpen()) close(false); else open(); }); if (overlay) { overlay.addEventListener('click', function () { close(false); }); } // 패널 안 링크를 누르면 닫는다. 같은 페이지 앵커(#…)로 이동할 때 // 패널이 열린 채 남아 목적지를 가리는 것을 막는다. panel.addEventListener('click', function (e) { var t = e.target; if (t && t.closest && t.closest('a')) close(false); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' || e.key === 'Esc') close(true); }); // 961px 이상으로 넘어가면 상태를 정리한다. if (mq) { var onChange = function (e) { if (!e.matches) close(false); }; if (mq.addEventListener) mq.addEventListener('change', onChange); else if (mq.addListener) mq.addListener(onChange); // Safari 13 이하 } })(); /* ========================================================================== GNB 하위 메뉴 — 3단 드롭다운 (2026-10-28, Use Cases) 마크업(_header.html): .gnb-item > button.gnb-toggle[aria-controls] + div.gnb-sub[hidden] > … button.gnb-l2-toggle[aria-controls] + ul.gnb-l3[hidden] > … a 데이터: app.py GNB_SUBMENUS. 스타일: /css/header.css I절. 상태는 버튼의 aria-expanded 와 그 버튼이 가리키는 패널의 hidden 두 곳뿐 — setExpanded() 한 함수에서만 바꾼다. 같은 마크업이 데스크톱 드롭다운과 ≤960 패널 아코디언을 겸하므로 동작도 대부분 같다: · 대분류 버튼: 열기/닫기(다른 대분류 하위 메뉴는 닫는다). 2단 버튼: 열기/닫기(같은 패널의 다른 2단은 닫는다). · Esc: 가장 깊은 열린 단계부터 한 단계씩 닫고 그 버튼으로 포커스를 돌린다. 열린 하위 메뉴가 없을 때만 위 모바일 패널의 Esc(패널 닫기)가 동작한다 — 그래서 이 리스너는 capture 단계에 둔다. · 바깥 클릭·3단 링크 클릭 → 닫힘. 데스크톱에서는 Tab 으로 하위 메뉴 밖으로 나가도 닫힘. · 모바일 패널이 닫히거나(.menu-open 해제) 960px 경계를 넘으면 모두 접는다. 키보드: 버튼이라 Enter/Space 로 열고 닫히며, 열린 패널은 DOM 순서상 버튼 바로 뒤라 Tab 으로 이어진다. ========================================================================== */ (function () { 'use strict'; var nav = document.getElementById('gnb'); if (!nav) return; var tops = Array.prototype.slice.call(nav.querySelectorAll('.gnb-toggle')); if (!tops.length) return; var mq = window.matchMedia ? window.matchMedia('(max-width:960px)') : null; function isMobile() { return !!(mq && mq.matches); } function isExpanded(btn) { return btn.getAttribute('aria-expanded') === 'true'; } function setExpanded(btn, open) { btn.setAttribute('aria-expanded', open ? 'true' : 'false'); var target = document.getElementById(btn.getAttribute('aria-controls')); if (target) target.hidden = !open; } function l2Of(top) { var sub = document.getElementById(top.getAttribute('aria-controls')); return sub ? Array.prototype.slice.call(sub.querySelectorAll('.gnb-l2-toggle')) : []; } function closeTop(top) { l2Of(top).forEach(function (b) { setExpanded(b, false); }); setExpanded(top, false); } function closeAll() { tops.forEach(closeTop); } tops.forEach(function (top) { var item = top.parentNode; var l2s = l2Of(top); top.addEventListener('click', function () { if (isExpanded(top)) { closeTop(top); return; } tops.forEach(function (t) { if (t !== top) closeTop(t); }); setExpanded(top, true); }); l2s.forEach(function (b) { b.addEventListener('click', function () { var open = !isExpanded(b); l2s.forEach(function (o) { if (o !== b) setExpanded(o, false); }); setExpanded(b, open); }); }); // 데스크톱: 키보드로 하위 메뉴 밖으로 나가면 닫는다. relatedTarget 이 없으면(포커스 없는 곳 클릭·창 전환) // 바깥 클릭 처리에 맡긴다. 모바일 아코디언은 패널 안 목록이라 펼친 채 둔다. item.addEventListener('focusout', function (e) { if (isMobile() || !isExpanded(top)) return; var to = e.relatedTarget; if (to && !item.contains(to)) closeTop(top); }); }); // 바깥 클릭(다른 대분류 링크 포함) → 그 하위 메뉴를 닫는다. document.addEventListener('click', function (e) { tops.forEach(function (top) { if (isExpanded(top) && !top.parentNode.contains(e.target)) closeTop(top); }); }); // 3단 링크를 누르면 접어 둔다 — 뒤로 가기(bfcache)로 돌아왔을 때 열린 채 남지 않게. nav.addEventListener('click', function (e) { var t = e.target; if (t && t.closest && t.closest('.gnb-sub a')) closeAll(); }); window.addEventListener('pageshow', function (e) { if (e.persisted) closeAll(); }); document.addEventListener('keydown', function (e) { if (e.key !== 'Escape' && e.key !== 'Esc') return; var btn = nav.querySelector('.gnb-l2-toggle[aria-expanded="true"]'); if (btn) { setExpanded(btn, false); } else { btn = nav.querySelector('.gnb-toggle[aria-expanded="true"]'); if (!btn) return; // 열린 하위 메뉴 없음 → 모바일 패널 Esc 로 넘긴다 closeTop(btn); } btn.focus(); e.stopImmediatePropagation(); }, true); // 모바일 패널이 닫히면(.menu-open 해제 — 햄버거·오버레이·Esc 어느 경로든) 모두 접는다. // 홈 헤더는 스크롤 때 .is-scrolled 도 바뀌므로 "열림 → 닫힘" 전환에만 반응한다 // (그러지 않으면 데스크톱 드롭다운이 스크롤만으로 닫힌다). var header = nav.closest ? nav.closest('.site-header') : null; if (header && window.MutationObserver) { var wasOpen = header.classList.contains('menu-open'); new MutationObserver(function () { var nowOpen = header.classList.contains('menu-open'); if (wasOpen && !nowOpen) closeAll(); wasOpen = nowOpen; }).observe(header, { attributes: true, attributeFilter: ['class'] }); } if (mq) { if (mq.addEventListener) mq.addEventListener('change', closeAll); else if (mq.addListener) mq.addListener(closeAll); // Safari 13 이하 } })();