326 lines
11 KiB
JavaScript
326 lines
11 KiB
JavaScript
// 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<boolean>}
|
|
*/
|
|
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 +=
|
|
'<li class="index__item index__item--skeleton" aria-hidden="true">' +
|
|
'<span class="idx">' +
|
|
'<span class="idx__no"></span>' +
|
|
'<span class="idx__title"></span>' +
|
|
'<span class="idx__meta"></span>' +
|
|
'</span>' +
|
|
'</li>';
|
|
}
|
|
return html;
|
|
}
|