Files
Genesis_GameServer/GameServer/wwwroot/js/ui.js
2026-09-03 19:18:46 +09:00

326 lines
11 KiB
JavaScript

// ui.js — 포트폴리오 프론트엔드 공용 UI 헬퍼 모듈
// 의존성 없음. 모든 사용자/DB 텍스트는 escapeHtml() 또는 textContent 로만 DOM 에 넣는다.
/** HTML 특수문자 → 엔티티 매핑 테이블 */
const ESCAPE_MAP = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
/**
* 문자열을 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;
}