/* ============================================================================
* 서명전에 — TDS ListRow 컴포넌트
* · 목록의 기본 단위 "한 줄" 컴포넌트. 내부는 좌(left) / 본문(contents) / 우(right)
* 3단 구조이고, 각 슬롯에 서브 컴포넌트를 꽂아 쓰는 조합식이에요.
* · 서브 컴포넌트:
* ListRow.Loader — 로딩 스켈레톤 (shape = square/circle/bar)
* ListRow.AssetIcon — SVG 아이콘 (mono 지원)
* ListRow.AssetImage — 비트맵 이미지
* ListRow.AssetLottie — Lottie 애니메이션 자리(웹은 플레이스홀더)
* ListRow.AssetText — 뱃지 형태 텍스트
* ListRow.IconButton — 우측 클릭 가능한 아이콘 버튼
* ListRow.Texts — 제목/중간/보조 텍스트 타입 25종
*
* · 메인 ListRow props (ListRowProps):
* left ReactNode — Asset* 또는 Loader 권장
* contents* ReactNode — 보통
* right ReactNode — AssetText / IconButton / ListRow.Texts(Right*)
* withArrow boolean — 우측 끝에 ▶ chevron 표시
* border "indented" | "none" (기본 "indented")
* horizontalPadding "small" | "medium" (기본 "medium")
* verticalPadding "small" | "medium" | "large" | "xlarge" (기본 "medium")
* align "center" | "top" (기본 "center")
* onClick (e) => void — 있으면 클릭 가능 상태
* disabled boolean — 비활성(기본 false)
* disabledType "type1" | "type2" (기본 "type1" — 완전 회색 / type2 — 액션만 억제)
* className, style, aria-* ...
*
* · Ref API:
* const ref = useRef();
*
* ref.current.shine(1.6); // 1.6s 동안 가로지르는 반짝임
* ref.current.blink(1.2); // 1.2s 동안 배경색 깜빡임
* 각각 CSS 클래스(`tds-listrow--shine` / `tds-listrow--blink`) 토글 후
* setTimeout 으로 해제해요. CSS 변수(`--tds-listrow-shine-dur` /
* `--tds-listrow-blink-dur`) 로 시간을 연동.
*
* · 접근성:
* - onClick 이 있으면
+ Enter/Space 키보드 지원
* - disabled 면 aria-disabled="true" + 클릭 비활성
* - withArrow 의 ▶ 는 aria-hidden
* - AssetIcon/Image/Lottie 는 acc(=accessibility label) 를 반드시 받아 aria-label 로 투사
*
* · 구현 노트:
* - Babel Standalone 전역 스코프 충돌 방지를 위해 React 훅은
* `__lrUseRef`, `__lrUseImperativeHandle`, `__lrUseState` 로 프리픽스.
* - forwardRef 사용. 메인 컴포넌트만 ref 를 받음.
* - Asset 계열은 `shape` 에 따라 배경/라운드/가로폭 달라지는데, 우리 CSS 는
* `.tds-listrow-asset--{shape}` + `.tds-listrow-asset--{size}` 조합으로 처리.
* - Texts 는 type 이름이 "Right" 로 시작하면 자동으로
* `tds-listrow-texts--right` 클래스를 붙여 오른쪽 정렬.
* - window.ListRow / window.List 로 글로벌 등록.
* ========================================================================== */
// React 훅 프리픽스 (Babel Standalone 글로벌 충돌 방지)
const __lrUseRef = (typeof React !== "undefined" && React.useRef) || function () { return { current: null }; };
const __lrUseImperativeHandle = (typeof React !== "undefined" && React.useImperativeHandle) || function () {};
const __lrUseState = (typeof React !== "undefined" && React.useState) || function (v) { return [v, function () {}]; };
const __lrUseEffect = (typeof React !== "undefined" && React.useEffect) || function () {};
const __lrForwardRef = (typeof React !== "undefined" && React.forwardRef) || function (fn) { return fn; };
/* ──────────────────────────────────────────────────────────────────────────
* 로컬 유틸
* ────────────────────────────────────────────────────────────────────────── */
function __lrWarn(msg) {
if (typeof console !== "undefined" && console.warn) console.warn("[ListRow] " + msg);
}
function __lrJoin() {
const parts = [];
for (let i = 0; i < arguments.length; i += 1) {
const v = arguments[i];
if (v) parts.push(v);
}
return parts.join(" ");
}
function __lrSizeCss(v, fallback) {
if (typeof v === "number" && Number.isFinite(v)) return v + "px";
if (typeof v === "string" && v.length > 0) return v;
return fallback;
}
/* 정해진 값만 통과시키는 enum guard */
function __lrEnum(value, allowed, fallback, propPath) {
if (value == null) return fallback;
if (allowed.indexOf(value) === -1) {
__lrWarn(propPath + " 는 " + JSON.stringify(allowed) + " 중 하나여야 해요. 받은 값: " +
JSON.stringify(value) + " — '" + fallback + "' 로 폴백.");
return fallback;
}
return value;
}
/* Chevron — IOSIcon 이 있으면 위임, 아니면 inline SVG */
function __lrChevronRight(size) {
const s = typeof size === "number" && Number.isFinite(size) ? size : 14;
if (typeof window !== "undefined" && typeof window.IOSIcon === "function") {
const _IOSIcon = window.IOSIcon;
return <_IOSIcon name="chev-right" size={s} />;
}
return (
);
}
/* ==========================================================================
* List — ListRow 들의 컨테이너 (첫 행 구분선 숨김 책임)
* ========================================================================== */
function List({ children, className = "", style, ...rest }) {
return (
{children}
);
}
/* ==========================================================================
* ListRow.Loader — 로딩 스켈레톤
* props:
* type* "square" | "circle" | "bar"
* verticalPadding "extraSmall" | "small" | "medium" | "large" (기본 "medium")
* ========================================================================== */
function ListRowLoader({
type,
verticalPadding = "medium",
className = "",
style,
...rest
}) {
const safeType = __lrEnum(type, ["square", "circle", "bar"], "square", "ListRow.Loader.type");
const safeVp = __lrEnum(verticalPadding, ["extraSmall", "small", "medium", "large"], "medium", "ListRow.Loader.verticalPadding");
const cls = __lrJoin(
"tds-listrow-loader",
"tds-listrow-loader--vp-" + safeVp,
className
);
return (
);
}
/* ==========================================================================
* ListRow.AssetIcon — SVG 아이콘
* props:
* name/url/src string — 아이콘 경로 (셋 중 하나). `-mono.svg` 로 끝나면 mono 모드.
* shape "squircle" | "card" | "circle" | "square" | "original"
* | "circle-background" | "circle-masking" (기본 "squircle")
* size "xsmall" | "small" | "medium" (기본 "small")
* color string — mono 아이콘의 색 (CSS color)
* backgroundColor string — 배경색 (shape 이 있을 때만 의미)
* variant "fill" | "none" (기본 "fill" — none 이면 배경 투명)
* acc { node, position, masking } — 우측 상/하단 작은 뱃지
* ariaLabel string — 스크린리더용. 없으면 aria-hidden 처리.
* ========================================================================== */
function __lrAssetBase({
shape = "squircle",
size = "small",
backgroundColor,
color,
variant = "fill",
acc,
children,
ariaLabel,
className = "",
style,
extraAssetStyle,
...rest
}) {
const safeShape = __lrEnum(
shape,
["squircle", "card", "circle", "square", "original", "circle-background", "circle-masking"],
"squircle",
"AssetShape"
);
const safeSize = __lrEnum(size, ["xsmall", "small", "medium"], "small", "AssetSize");
const safeVariant = __lrEnum(variant, ["fill", "none"], "fill", "variant");
const rootStyle = { ...(style || {}), ...(extraAssetStyle || {}) };
if (backgroundColor && safeVariant === "fill") rootStyle["--tds-listrow-asset-bg"] = backgroundColor;
if (safeVariant === "none") rootStyle.background = "transparent";
if (color) rootStyle["--tds-listrow-asseticon-color"] = color;
const cls = __lrJoin(
"tds-listrow-asset",
"tds-listrow-asset--" + safeShape,
"tds-listrow-asset--" + safeSize,
className
);
// acc 슬롯 렌더 — 우측 상/하단 작은 뱃지
let accNode = null;
if (acc && acc.node != null) {
const accPos = __lrEnum(acc.position, ["top-right", "bottom-right"], "bottom-right", "acc.position");
const accMask = __lrEnum(acc.masking, ["circle", "none"], "none", "acc.masking");
const accCls = __lrJoin(
"tds-listrow-asset__acc",
"tds-listrow-asset__acc--" + accPos,
accMask === "circle" ? "tds-listrow-asset__acc--masking-circle" : ""
);
accNode =
{acc.node};
}
const hasLabel = typeof ariaLabel === "string" && ariaLabel.length > 0;
return (
{children}
{accNode}
);
}
function ListRowAssetIcon({
name,
url,
src,
shape = "squircle",
size = "small",
color,
backgroundColor,
variant = "fill",
acc,
ariaLabel,
className,
style,
...rest
}) {
const href = src || url || name;
if (!href) __lrWarn("AssetIcon: `src`(또는 url/name) 이 필요해요.");
const isMono = typeof href === "string" && /-mono\.svg(\?.*)?$/i.test(href);
let inner;
if (!href) {
inner = null;
} else if (isMono) {
inner = (
);
} else {
inner =

;
}
return __lrAssetBase({
shape,
size,
backgroundColor,
color,
variant,
acc,
ariaLabel,
className,
style,
children: inner,
...rest,
});
}
/* ==========================================================================
* ListRow.AssetImage — 비트맵 이미지
* props:
* src* string
* shape 같은 5 variants (기본 "square")
* size "xsmall" | "small" | "medium"
* scale (0, 1] — 내부 이미지 스케일 (기본 1)
* scaleType "fit" | "crop" (기본 "fit")
* backgroundColor string
* acc 같은 구조
* ariaLabel string
* ========================================================================== */
function ListRowAssetImage({
src,
shape = "square",
size = "small",
scale = 1,
scaleType = "fit",
backgroundColor,
acc,
ariaLabel,
className,
style,
...rest
}) {
if (!src) __lrWarn("AssetImage: `src` 는 필수예요.");
const safeScale = (typeof scale === "number" && Number.isFinite(scale) && scale > 0 && scale <= 1) ? scale : 1;
const safeScaleType = __lrEnum(scaleType, ["fit", "crop"], "fit", "AssetImage.scaleType");
const imgStyle = {
objectFit: safeScaleType === "crop" ? "cover" : "contain",
transform: safeScale !== 1 ? "scale(" + safeScale + ")" : undefined,
};
const inner = src ? (

) : null;
return __lrAssetBase({
shape,
size,
backgroundColor,
acc,
ariaLabel,
className,
style,
children: inner,
...rest,
});
}
/* ==========================================================================
* ListRow.AssetLottie — Lottie 자리 (웹엔 플레이어 없어 플레이스홀더)
* props:
* src* string — JSON 경로 (로드는 실제 플레이어 통합 시)
* shape 기본 "square"
* size 기본 "small"
* backgroundColor string
* acc 동일
* ariaLabel string
* ========================================================================== */
function ListRowAssetLottie({
src,
shape = "square",
size = "small",
backgroundColor,
acc,
ariaLabel,
className,
style,
...rest
}) {
if (!src) __lrWarn("AssetLottie: `src` 는 필수예요.");
// 웹 환경에선 Lottie 플레이어를 동적으로 붙일 자리만 확보.
const inner = (
);
return __lrAssetBase({
shape,
size,
backgroundColor,
acc,
ariaLabel,
className,
style,
children: inner,
...rest,
});
}
/* ==========================================================================
* ListRow.AssetText — 뱃지 형태 텍스트 라벨
* props:
* children* string
* shape "squircle" | "card" (기본 "squircle")
* size "small" | "medium" (기본 "small")
* color string — 텍스트 색
* backgroundColor string — 배경색
* acc 옵션
* ariaLabel string
* ========================================================================== */
function ListRowAssetText({
children,
shape = "squircle",
size = "small",
color,
backgroundColor,
acc,
ariaLabel,
className,
style,
...rest
}) {
if (children == null || children === "") __lrWarn("AssetText: children 이 비어 있어요.");
const safeShape = __lrEnum(shape, ["squircle", "card"], "squircle", "AssetText.shape");
const safeSize = __lrEnum(size, ["small", "medium"], "small", "AssetText.size");
const extraStyle = {};
if (color) extraStyle["--tds-listrow-assettext-color"] = color;
const inner =
{children};
return __lrAssetBase({
shape: safeShape,
size: safeSize,
backgroundColor,
acc,
ariaLabel,
className: __lrJoin("tds-listrow-asset--padx", className),
style,
extraAssetStyle: extraStyle,
children: inner,
...rest,
});
}
/* ==========================================================================
* ListRow.IconButton — 우측 클릭 가능한 아이콘 버튼
* props:
* src* string — 아이콘 (mono 권장)
* onClick* () => void
* variant "clear" | "fill" | "border" (기본 "clear")
* iconSize number (px, 기본 24)
* size number (px, 버튼 전체 크기 — 기본 36)
* color string — 아이콘 색
* backgroundColor string — variant=fill 때 배경색
* ariaLabel / label string — 접근성 라벨 (필수)
* disabled boolean
* ========================================================================== */
function ListRowIconButton({
src,
onClick,
variant = "clear",
iconSize = 24,
size = 36,
color,
backgroundColor,
ariaLabel,
label,
disabled = false,
className = "",
style,
...rest
}) {
if (!src) __lrWarn("IconButton: `src` 는 필수예요.");
if (typeof onClick !== "function") __lrWarn("IconButton: `onClick` 은 필수예요.");
const safeVariant = __lrEnum(variant, ["clear", "fill", "border"], "clear", "IconButton.variant");
const accLabel = ariaLabel || label;
if (!accLabel) __lrWarn("IconButton: `ariaLabel`(또는 label) 이 비어 있어요 — 접근성 라벨을 넣어주세요.");
const btnStyle = { ...(style || {}) };
btnStyle.width = __lrSizeCss(size, "36px");
btnStyle.height = __lrSizeCss(size, "36px");
if (color) btnStyle["--tds-listrow-iconbutton-color"] = color;
if (backgroundColor && safeVariant === "fill") btnStyle["--tds-listrow-iconbutton-bg"] = backgroundColor;
btnStyle["--tds-listrow-iconbutton-size"] = __lrSizeCss(iconSize, "24px");
const cls = __lrJoin(
"tds-listrow-iconbutton",
"tds-listrow-iconbutton--" + safeVariant,
className
);
return (
);
}
/* ==========================================================================
* ListRow.Texts — 제목/중간/보조 텍스트 타입 25종
* props:
* type* string — 1RowTypeA..C / Right1RowTypeA..E /
* 2RowTypeA..F / Right2RowTypeA..E /
* 3RowTypeA..F
* top ReactNode
* middle ReactNode
* bottom ReactNode
* marginTop number (px) — 전체 위 여백(콘텐츠 정렬용)
* color string — 기본 top 색 오버라이드
* subColor string — middle/bottom 색 오버라이드
* ========================================================================== */
const __LR_TEXTS_TYPES = [
"1RowTypeA", "1RowTypeB", "1RowTypeC",
"Right1RowTypeA", "Right1RowTypeB", "Right1RowTypeC", "Right1RowTypeD", "Right1RowTypeE",
"2RowTypeA", "2RowTypeB", "2RowTypeC", "2RowTypeD", "2RowTypeE", "2RowTypeF",
"Right2RowTypeA", "Right2RowTypeB", "Right2RowTypeC", "Right2RowTypeD", "Right2RowTypeE",
"3RowTypeA", "3RowTypeB", "3RowTypeC", "3RowTypeD", "3RowTypeE", "3RowTypeF",
];
function ListRowTexts({
type,
top,
middle,
bottom,
marginTop,
color,
subColor,
className = "",
style,
...rest
}) {
if (!type) __lrWarn("Texts.type 은 필수예요.");
const safeType = __LR_TEXTS_TYPES.indexOf(type) !== -1 ? type : "1RowTypeA";
const isRight = /^Right/.test(safeType);
const cls = __lrJoin(
"tds-listrow-texts",
"tds-listrow-texts--" + safeType,
isRight ? "tds-listrow-texts--right" : "",
className
);
const rootStyle = { ...(style || {}) };
if (typeof marginTop === "number" && Number.isFinite(marginTop)) rootStyle.marginTop = marginTop + "px";
const topStyle = color ? { color } : undefined;
const subStyle = subColor ? { color: subColor } : undefined;
return (
{top != null && {top}}
{middle != null && {middle}}
{bottom != null && {bottom}}
);
}
/* ==========================================================================
* 메인 ListRow — forwardRef 로 shine/blink ref API 노출
* ========================================================================== */
const ListRow = __lrForwardRef(function ListRowInner(props, ref) {
const {
left,
contents,
right,
withArrow = false,
border = "indented",
horizontalPadding = "medium",
verticalPadding = "medium",
align = "center",
onClick,
disabled = false,
disabledType = "type1",
className = "",
style,
...rest
} = props || {};
if (contents == null) __lrWarn("`contents` 는 필수예요. ListRow.Texts 또는 ReactNode 를 넣어주세요.");
const safeBorder = __lrEnum(border, ["indented", "none"], "indented", "border");
const safeHp = __lrEnum(horizontalPadding, ["small", "medium"], "medium", "horizontalPadding");
const safeVp = __lrEnum(verticalPadding, ["small", "medium", "large", "xlarge"], "medium", "verticalPadding");
const safeAlign = __lrEnum(align, ["center", "top"], "center", "align");
const safeDisabledType = __lrEnum(disabledType, ["type1", "type2"], "type1", "disabledType");
// Ref API: shine/blink — 클래스 토글 + setTimeout 해제
const rootRef = __lrUseRef(null);
const [shine, setShine] = __lrUseState(null); // duration(s) or null
const [blink, setBlink] = __lrUseState(null);
__lrUseEffect(function () {
if (shine == null) return;
const t = window.setTimeout(function () { setShine(null); }, shine * 1000 + 80);
return function () { window.clearTimeout(t); };
}, [shine]);
__lrUseEffect(function () {
if (blink == null) return;
const t = window.setTimeout(function () { setBlink(null); }, blink * 1000 + 80);
return function () { window.clearTimeout(t); };
}, [blink]);
__lrUseImperativeHandle(ref, function () {
return {
shine: function (seconds) {
const s = typeof seconds === "number" && seconds > 0 ? seconds : 1.6;
setShine(s);
},
blink: function (seconds) {
const s = typeof seconds === "number" && seconds > 0 ? seconds : 1.2;
setBlink(s);
},
el: function () { return rootRef.current; },
};
}, []);
const clickable = typeof onClick === "function" && !disabled;
const cls = __lrJoin(
"tds-listrow",
"tds-listrow--border-" + safeBorder,
"tds-listrow--hp-" + safeHp,
"tds-listrow--vp-" + safeVp,
clickable ? "tds-listrow--clickable" : "",
disabled ? "tds-listrow--disabled" : "",
disabled ? "tds-listrow--disabled-" + safeDisabledType : "",
shine != null ? "tds-listrow--shine" : "",
blink != null ? "tds-listrow--blink" : "",
className
);
const rootStyle = { ...(style || {}) };
if (shine != null) rootStyle["--tds-listrow-shine-dur"] = shine + "s";
if (blink != null) rootStyle["--tds-listrow-blink-dur"] = blink + "s";
const handleKey = function (e) {
if (!clickable) return;
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
try { onClick(e); } catch (err) {
if (window.console && console.error) console.error("[ListRow] onClick threw:", err);
}
}
};
const handleClick = function (e) {
if (!clickable) return;
try { onClick(e); } catch (err) {
if (window.console && console.error) console.error("[ListRow] onClick threw:", err);
}
};
const innerCls = __lrJoin(
"tds-listrow__inner",
"tds-listrow__align-" + safeAlign
);
return (
{left != null &&
{left}
}
{contents}
{right != null &&
{right}
}
{withArrow && (
{__lrChevronRight(14)}
)}
);
});
// 서브 컴포넌트 네임스페이스 부착
ListRow.Loader = ListRowLoader;
ListRow.AssetIcon = ListRowAssetIcon;
ListRow.AssetImage = ListRowAssetImage;
ListRow.AssetLottie = ListRowAssetLottie;
ListRow.AssetText = ListRowAssetText;
ListRow.IconButton = ListRowIconButton;
ListRow.Texts = ListRowTexts;
// 글로벌 등록
window.ListRow = ListRow;
window.List = List;