// ui.js — 포트폴리오 프론트엔드 공용 UI 헬퍼 모듈 // 의존성 없음. 모든 사용자/DB 텍스트는 escapeHtml() 또는 textContent 로만 DOM 에 넣는다. /** HTML 특수문자 → 엔티티 매핑 테이블 */ const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; /** * 문자열을 HTML 안전 문자열로 변환한다. null/undefined 는 빈 문자열. * @param {*} str * @returns {string} */ export function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str).replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]); } /** 자식 노드 하나를 부모에 붙인다. null/undefined/불리언 은 건너뛴다. */ function appendChild(parent, child) { if (child === null || child === undefined || child === false || child === true) return; if (Array.isArray(child)) { for (const item of child) appendChild(parent, item); return; } if (child instanceof Node) { parent.appendChild(child); return; } parent.appendChild(document.createTextNode(String(child))); } /** attrs 의 특수 키(class/text/html/dataset/style/on*)를 처리한다. */ function applyAttribute(node, key, value) { if (value === null || value === undefined || value === false) return; if (key === 'class' || key === 'className') { node.setAttribute('class', Array.isArray(value) ? value.filter(Boolean).join(' ') : String(value)); return; } if (key === 'text') { node.textContent = String(value); return; } if (key === 'html') { // 호출자가 신뢰 가능한(이미 escape 된) HTML 만 넘겨야 한다. node.innerHTML = String(value); return; } if (key === 'dataset') { for (const dk of Object.keys(value)) { const dv = value[dk]; if (dv === null || dv === undefined) continue; node.dataset[dk] = String(dv); } return; } if (key === 'style') { if (typeof value === 'object') Object.assign(node.style, value); else node.setAttribute('style', String(value)); return; } if (key.length > 2 && key.slice(0, 2) === 'on' && typeof value === 'function') { node.addEventListener(key.slice(2).toLowerCase(), value); return; } if (value === true) { node.setAttribute(key, ''); return; } node.setAttribute(key, String(value)); } /** * 엘리먼트 생성 헬퍼. * @param {string} tag 태그 이름 * @param {Object} [attrs] class / text / html / dataset / style / on* / 그 밖의 속성 * @param {...any} children 문자열·노드·배열 (null·undefined 는 무시) * @returns {HTMLElement} */ export function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); if (attrs && typeof attrs === 'object') { for (const key of Object.keys(attrs)) applyAttribute(node, key, attrs[key]); } for (const child of children) appendChild(node, child); return node; } const TOAST_LIFETIME_MS = 2800; const TOAST_FADE_MS = 220; /** #toast-root 를 찾고, 없으면 body 에 하나 만든다. */ function toastRoot() { let root = document.getElementById('toast-root'); if (!root) { root = el('div', { id: 'toast-root', class: 'toast-root', 'aria-live': 'polite' }); document.body.appendChild(root); } return root; } /** * 토스트 알림. 약 2.8초 뒤 자동으로 사라지며 여러 개가 위로 쌓인다. * @param {string} message * @param {'info'|'ok'|'error'} [kind] * @returns {HTMLElement} */ export function toast(message, kind = 'info') { const cls = kind === 'ok' ? 'toast toast--ok' : kind === 'error' ? 'toast toast--error' : 'toast'; const text = message === null || message === undefined ? '' : String(message); const node = el('div', { class: cls, role: 'status', text }); const root = toastRoot(); root.appendChild(node); let removed = false; const remove = () => { if (removed) return; removed = true; node.style.opacity = '0'; window.setTimeout(() => node.remove(), TOAST_FADE_MS); }; window.setTimeout(remove, TOAST_LIFETIME_MS); node.addEventListener('click', remove); return node; } const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ].join(','); /** 컨테이너 안에서 실제로 포커스 가능한 요소 목록 */ function focusableIn(container) { const all = Array.prototype.slice.call(container.querySelectorAll(FOCUSABLE_SELECTOR)); return all.filter((node) => node.offsetParent !== null || node === document.activeElement); } /** #modal-root 를 찾고, 없으면 body 에 하나 만든다. */ function modalRoot() { let root = document.getElementById('modal-root'); if (!root) { root = el('div', { id: 'modal-root' }); document.body.appendChild(root); } return root; } /** * 확인/취소 모달. Esc 또는 배경 클릭 시 false 로 resolve 된다. * 모달이 열려 있는 동안 Tab 포커스는 모달 안에 갇힌다. * @param {string} message * @param {{okText?:string, cancelText?:string, title?:string, danger?:boolean}} [opts] * @returns {Promise} */ export function confirmDialog(message, opts = {}) { const okText = opts.okText || '확인'; const cancelText = opts.cancelText || '취소'; const danger = opts.danger === true; const msgText = message === null || message === undefined ? '' : String(message); return new Promise((resolve) => { const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; const cancelBtn = el('button', { type: 'button', class: 'btn btn--ghost', text: cancelText }); const okBtn = el('button', { type: 'button', class: danger ? 'btn btn--danger' : 'btn btn--primary', text: okText }); const card = el( 'div', { class: 'modal__card glass', role: 'dialog', 'aria-modal': 'true' }, opts.title ? el('p', { class: 'modal__msg', text: String(opts.title) }) : null, el('p', { class: 'modal__msg', text: msgText }), el('div', { class: 'modal__actions' }, cancelBtn, okBtn) ); const overlay = el('div', { class: 'modal' }, card); let settled = false; const close = (result) => { if (settled) return; settled = true; document.removeEventListener('keydown', onKeydown, true); overlay.remove(); if (previousFocus && document.contains(previousFocus)) previousFocus.focus(); resolve(result); }; function onKeydown(ev) { if (ev.key === 'Escape') { ev.preventDefault(); close(false); return; } if (ev.key !== 'Tab') return; // 포커스 트랩: 모달 밖으로 Tab 이 빠져나가지 못하게 한다. const items = focusableIn(card); if (items.length === 0) { ev.preventDefault(); return; } const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement; if (!card.contains(active)) { ev.preventDefault(); first.focus(); return; } if (ev.shiftKey && active === first) { ev.preventDefault(); last.focus(); } else if (!ev.shiftKey && active === last) { ev.preventDefault(); first.focus(); } } overlay.addEventListener('mousedown', (ev) => { if (ev.target === overlay) close(false); }); cancelBtn.addEventListener('click', () => close(false)); okBtn.addEventListener('click', () => close(true)); document.addEventListener('keydown', onKeydown, true); modalRoot().appendChild(overlay); okBtn.focus(); }); } /** 서버 DateTime 은 UTC 기준이지만 오프셋이 없을 수 있어 Z 를 보정해 준다. */ function toLocalDate(value) { if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; if (value === null || value === undefined) return null; let text = String(value).trim(); if (!text) return null; const hasZone = /(?:Z|z|[+-]\d{2}:?\d{2})$/.test(text); if (!hasZone && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(text)) { text = text.replace(' ', 'T') + 'Z'; } const date = new Date(text); return Number.isNaN(date.getTime()) ? null : date; } function pad2(n) { return String(n).padStart(2, '0'); } /** * UTC 시각을 로컬 기준 'YYYY. MM. DD.' 로 표시한다. * @param {string|Date} iso * @returns {string} */ export function formatDate(iso) { const date = toLocalDate(iso); if (!date) return ''; return date.getFullYear() + '. ' + pad2(date.getMonth() + 1) + '. ' + pad2(date.getDate()) + '.'; } /** * 디바운스. 반환된 함수에는 cancel() 이 붙어 있다. * @param {Function} fn * @param {number} ms * @returns {Function} */ export function debounce(fn, ms) { const delay = typeof ms === 'number' && ms >= 0 ? ms : 0; let timer = null; function wrapped(...args) { if (timer !== null) window.clearTimeout(timer); const self = this; timer = window.setTimeout(() => { timer = null; fn.apply(self, args); }, delay); } wrapped.cancel = () => { if (timer !== null) window.clearTimeout(timer); timer = null; }; return wrapped; } /** * 상단 진행 표시줄(#app-progress) 토글. * @param {boolean} isBusy */ export function setBusy(isBusy) { const bar = document.getElementById('app-progress'); if (!bar) return; bar.classList.toggle('is-busy', !!isBusy); } /** * 로딩 중 표시할 인덱스 행 스켈레톤 HTML 문자열. * 구조는 views/home.js 의 실제 행(.idx)과 같게 맞춘다. * @param {number} n * @returns {string} */ export function skeletonRows(n) { const count = Math.max(0, Math.floor(Number(n) || 0)); let html = ''; for (let i = 0; i < count; i += 1) { html += ''; } return html; }