/* ============================================================================ * 서명전에 — 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 && ( )}
); }); // 서브 컴포넌트 네임스페이스 부착 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;