/* ============================================================================
* 서명전에 — TDS Skeleton 컴포넌트
* · 데이터 로딩 중 콘텐츠의 기본 레이아웃을 임시로 보여 주는 컴포넌트.
*
* · 두 가지 사용 방식:
* pattern — 미리 정의된 9 개 패턴 중 선택 (기본 "topList")
* custom — 자유롭게 조립한 타입 배열 (있으면 pattern 무시)
*
* · 9 개 프리셋 패턴 → 내부 시퀀스:
* topList → ["title", "list"]
* topListWithIcon → ["title", "listWithIcon"]
* amountTopList → ["title", "subtitle", "list"]
* amountTopListWithIcon → ["title", "subtitle", "listWithIcon"]
* subtitleList → ["subtitle", "list"]
* subtitleListWithIcon → ["subtitle", "listWithIcon"]
* listOnly → ["list"]
* listWithIconOnly → ["listWithIcon"]
* cardOnly → ["card"]
*
* · custom 가능한 타입:
* "title" — 굵고 큰 바 (24px)
* "subtitle" — 얇은 바 (16px)
* "list" — 가로형 바 2 줄
* "listWithIcon" — 좌측 40px 원형 + 우측 바 2 줄
* "card" — 직사각형 블록 (120px)
* "spacer(NN)" — NN 픽셀 빈 공간 (예: "spacer(20)")
*
* · repeatLastItemCount: number | "infinite"
* - 기본 3 — 마지막 요소가 총 3 번 반복돼요.
* - "infinite" — 30 회로 캡 (성능 보호).
* - 숫자 범위 외(0 이하/30 초과/소수)는 [1, 30] 으로 클램프.
*
* · play: "show" | "hide"
* - hide → 컴포넌트 자체가 렌더되지 않음 (return null).
*
* · background: "white" | "grey" | "greyOpacity100"
* - 시각적 바(bar/icon/card) 의 색을 결정.
* - 컨테이너 배경은 transparent — 부모의 배경에 자연스럽게 얹힘.
* - white: 어두운 부모용 / grey(기본): 일반 / greyOpacity100: 진한 회색.
*
* · 접근성:
* role="status" + aria-busy="true" + aria-live="polite"
* + aria-label="콘텐츠 로딩 중" (외부에서 aria-label 오버라이드 가능)
* ========================================================================== */
const __SKELETON_PATTERNS = {
topList: ["title", "list"],
topListWithIcon: ["title", "listWithIcon"],
amountTopList: ["title", "subtitle", "list"],
amountTopListWithIcon: ["title", "subtitle", "listWithIcon"],
subtitleList: ["subtitle", "list"],
subtitleListWithIcon: ["subtitle", "listWithIcon"],
listOnly: ["list"],
listWithIconOnly: ["listWithIcon"],
cardOnly: ["card"],
};
const __SKELETON_BG_CLASS = {
white: "tds-skeleton--bg-white",
grey: "tds-skeleton--bg-grey",
greyOpacity100: "tds-skeleton--bg-grey-opacity-100",
};
const __SKELETON_PLAY = { show: 1, hide: 1 };
const __SKELETON_TYPES = {
title: 1, subtitle: 1, list: 1, listWithIcon: 1, card: 1,
};
const __SKELETON_SPACER_RE = /^spacer\((\d+)\)$/;
function __isSkeletonSpacer(t) {
return typeof t === "string" && __SKELETON_SPACER_RE.test(t);
}
function __getSkeletonSpacerSize(t) {
const m = __SKELETON_SPACER_RE.exec(t);
return m ? parseInt(m[1], 10) : 0;
}
// ----- 단일 아이템 렌더 -----
function __renderSkeletonItem(type, key) {
if (__isSkeletonSpacer(type)) {
const n = __getSkeletonSpacerSize(type);
return (
);
}
switch (type) {
case "title":
return (
);
case "subtitle":
return (
);
case "list":
return (
);
case "listWithIcon":
return (
);
case "card":
return ;
default:
return null;
}
}
function Skeleton({
height = "auto",
pattern = "topList",
custom,
repeatLastItemCount = 3,
play = "show",
background = "grey",
className = "",
style,
id,
"aria-label": ariaLabel,
...rest
}) {
// ----- play 검증 + hide 시 즉시 unmount -----
if (!__SKELETON_PLAY[play]) {
if (window.console && console.warn) {
console.warn(
`[Skeleton] play 는 "show" | "hide" 만 가능해요. 받은 값: ${play}`
);
}
}
if (play === "hide") return null;
// ----- 시퀀스 결정: custom 우선, 없으면 pattern -----
const usingCustom = Array.isArray(custom) && custom.length > 0;
let sequence;
if (usingCustom) {
// 알 수 없는 타입은 거르고 콘솔 경고
sequence = custom.filter(function (t) {
const ok = (typeof t === "string" && __SKELETON_TYPES[t]) || __isSkeletonSpacer(t);
if (!ok && window.console && console.warn) {
console.warn(
`[Skeleton] custom 의 알 수 없는 타입: ${JSON.stringify(t)}. ` +
`허용: title / subtitle / list / listWithIcon / card / spacer(NN)`
);
}
return ok;
});
} else {
if (!__SKELETON_PATTERNS[pattern]) {
if (window.console && console.warn) {
console.warn(
`[Skeleton] 알 수 없는 pattern: "${pattern}". 'topList' 로 대체해요.`
);
}
}
sequence = __SKELETON_PATTERNS[pattern] || __SKELETON_PATTERNS.topList;
}
if (!sequence || sequence.length === 0) return null;
// ----- 반복 횟수 결정 -----
let repeatN;
if (repeatLastItemCount === "infinite") {
repeatN = 30;
} else if (
typeof repeatLastItemCount === "number" &&
isFinite(repeatLastItemCount)
) {
repeatN = Math.max(1, Math.min(30, Math.floor(repeatLastItemCount)));
} else {
if (
repeatLastItemCount !== undefined &&
repeatLastItemCount !== null &&
window.console &&
console.warn
) {
console.warn(
`[Skeleton] repeatLastItemCount 는 number 또는 "infinite" 여야 해요. ` +
`받은 값: ${JSON.stringify(repeatLastItemCount)}. 기본값 3 사용.`
);
}
repeatN = 3;
}
// ----- 마지막 요소 반복 — [...앞부분, 마지막 × N] -----
// 마지막은 spacer 가 아닌 시각 요소여야 함. spacer 면 그 앞의 시각 요소를 반복.
let lastIdx = sequence.length - 1;
while (lastIdx >= 0 && __isSkeletonSpacer(sequence[lastIdx])) {
lastIdx -= 1;
}
if (lastIdx < 0) {
// 전부 spacer 면 그대로 한 번만 렌더 (반복 의미 없음)
return __renderSkeletonRoot({
cls: __buildSkeletonCls(background, className),
style: __buildSkeletonStyle(height, style),
id,
ariaLabel,
restProps: rest,
children: sequence.map(function (t, i) {
return __renderSkeletonItem(t, i);
}),
});
}
const lastItem = sequence[lastIdx];
const beforeLast = sequence.slice(0, lastIdx);
const afterLastSpacers = sequence.slice(lastIdx + 1); // 마지막 뒤 spacer 들 (있다면)
const finalItems = beforeLast.slice();
for (let i = 0; i < repeatN; i++) {
finalItems.push(lastItem);
// 반복 사이에 원래 마지막 뒤 spacer 가 있었으면 사이에 끼움
if (afterLastSpacers.length > 0 && i < repeatN - 1) {
for (let j = 0; j < afterLastSpacers.length; j++) {
finalItems.push(afterLastSpacers[j]);
}
}
}
return __renderSkeletonRoot({
cls: __buildSkeletonCls(background, className),
style: __buildSkeletonStyle(height, style),
id,
ariaLabel,
restProps: rest,
children: finalItems.map(function (t, i) {
return __renderSkeletonItem(t, i);
}),
});
}
// ----- 헬퍼: root wrapper 렌더 -----
function __renderSkeletonRoot({ cls, style, id, ariaLabel, restProps, children }) {
return (
{children}
);
}
function __buildSkeletonCls(background, className) {
if (!__SKELETON_BG_CLASS[background] && window.console && console.warn) {
console.warn(
`[Skeleton] background 는 "white" | "grey" | "greyOpacity100" 만 가능해요. ` +
`받은 값: ${background}. 'grey' 사용.`
);
}
const bgCls = __SKELETON_BG_CLASS[background] || __SKELETON_BG_CLASS.grey;
return ["tds-skeleton", bgCls, className].filter(Boolean).join(" ");
}
function __buildSkeletonStyle(height, style) {
const out = style ? { ...style } : {};
if (height !== undefined && height !== null && height !== "auto") {
out.height = typeof height === "number" ? `${height}px` : height;
}
return out;
}
window.Skeleton = Skeleton;
Object.assign(window, { Skeleton });