/** * src/static/js/nav-guard.js — v1.5.0 Phase 1 * * Navigation dirty guard — page 이동 시 unsaved changes 검사 + 모달 (Save / Discard / Cancel). * * Promise-based API: * const ok = await confirmNavigation('register'); // current page id 전달 * if (ok) showPage(targetPage); * * Modal 디자인: * - role="dialog" + aria-modal="true" + aria-labelledby * - focus trap (Tab/Shift+Tab wrap) * - Esc → Cancel (resolve false) * - Save 클릭 → clearDirty(currentPage) + resolve true (Phase 4에서 collector 통합) * - Discard 클릭 → clearDirty(currentPage) + resolve true */ import { state, discardPageChanges } from './state.js'; import { clearDirty } from './page-dirty.js'; import { showToast } from './toast.js'; /** * @param {string} currentPageId source page id (이동 전) * @returns {Promise} true → proceed navigation, false → stay on current page */ export function confirmNavigation(currentPageId) { // Phase 1: page-level dirty 확인. 안 dirty면 즉시 proceed. if (!state.pageDirty[currentPageId]) { return Promise.resolve(true); } return new Promise((resolve) => { // v1.5.2 C4 (H13 WCAG 2.4.3): capture trigger element to restore focus on close const trigger = document.activeElement; const modal = _buildModal(currentPageId, (proceed) => { // Restore focus to the element that triggered navigation if (trigger && typeof trigger.focus === 'function') { try { trigger.focus(); } catch (_) {} } resolve(proceed); }); document.body.appendChild(modal); _trapFocus(modal); // Focus first button (Save) const saveBtn = modal.querySelector('[data-action="save"]'); if (saveBtn) saveBtn.focus(); }); } function _buildModal(currentPageId, resolve) { const wrap = document.createElement('div'); wrap.className = 'nav-guard__backdrop'; wrap.setAttribute('role', 'dialog'); wrap.setAttribute('aria-modal', 'true'); wrap.setAttribute('aria-labelledby', 'nav-guard-title'); wrap.innerHTML = ` `; const close = (proceed) => { wrap.remove(); document.removeEventListener('keydown', escHandler); resolve(proceed); }; wrap.querySelector('[data-action="save"]').addEventListener('click', async () => { // v1.5.4.3 U1 fix: 실제로 save를 수행 (이전엔 clearDirty만 하고 변경사항 소실). // app.js → nav-guard.js 정적 import 가 이미 있어 순환을 피하려 dynamic import. const saveBtn = wrap.querySelector('[data-action="save"]'); const discardBtn = wrap.querySelector('[data-action="discard"]'); const cancelBtn = wrap.querySelector('[data-action="cancel"]'); const origLabel = saveBtn.textContent; saveBtn.disabled = true; if (discardBtn) discardBtn.disabled = true; if (cancelBtn) cancelBtn.disabled = true; saveBtn.textContent = 'Saving...'; try { const app = await import('./app.js'); const ok = await app.handleSaveAll({ skipConfirm: true }); if (ok) { clearDirty(currentPageId); close(true); } else { // Save 실패 또는 validation error — handleSaveAll이 이미 toast로 사유 표시. // 모달 유지 + 버튼 복원 → 사용자가 수정 후 재시도 가능. saveBtn.disabled = false; if (discardBtn) discardBtn.disabled = false; if (cancelBtn) cancelBtn.disabled = false; saveBtn.textContent = origLabel; } } catch (e) { showToast('Save failed — ' + (e && e.message ? e.message : 'unknown error'), 'error'); saveBtn.disabled = false; if (discardBtn) discardBtn.disabled = false; if (cancelBtn) cancelBtn.disabled = false; saveBtn.textContent = origLabel; } }); wrap.querySelector('[data-action="discard"]').addEventListener('click', async () => { // Page-scoped restore: discard only the current page's owned state slice. // Other dirty pages keep their edits and sidebar dirty dots. discardPageChanges(currentPageId); if (typeof window !== 'undefined' && window.__pageCollectors) { window.__pageCollectors[currentPageId] = null; } clearDirty(currentPageId); if (currentPageId === 'general' || currentPageId === 'register') { try { const app = await import('./app.js'); app.updateTabVisibility?.(); } catch (_) {} } close(true); }); wrap.querySelector('[data-action="cancel"]').addEventListener('click', () => { close(false); }); const escHandler = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(false); } }; document.addEventListener('keydown', escHandler); return wrap; } /** Focus trap: Tab/Shift+Tab 가 modal 내부에서 순환. */ function _trapFocus(modalRoot) { modalRoot.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return; const focusable = modalRoot.querySelectorAll( 'button, [href], input, [tabindex]:not([tabindex="-1"])' ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); } function _escape(s) { return String(s).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); }