diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 32eb5df4..36adde52 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-10 + +### Added + +- 레이아웃 편집기의 페이지 상태 선택 항목(기본 / 모바일 메뉴 펼침) 일본어 번역 추가 — 관리자 베이스 레이아웃을 편집할 때 모바일 메뉴가 펼쳐진 상태를 미리 보며 편집할 수 있고, 그 선택지가 일본어 로케일에서도 올바르게 표시됩니다. + ## [1.0.1] - 2026-07-08 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json index 968f8074..68e0fe21 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json @@ -1393,7 +1393,9 @@ "page_detail_default": "基本", "page_detail_attachments_open": "添付を展開", "order_detail_default": "通常注文", - "order_detail_partially_cancelled": "部分キャンセル" + "order_detail_partially_cancelled": "部分キャンセル", + "admin_base_default": "基本", + "sidebar_open": "モバイルメニュー展開" }, "cell_border": { "side_all": "すべて", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json index fd8cc968..20e88edb 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-admin_basic", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 080efa7d..57f3eb09 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -8,7 +8,16 @@ ### Changed -- 휴대폰·태블릿 화면에서 통화·배송국가 선택이 드롭다운 대신 항상 펼쳐진 가로 버튼으로 표시됩니다. 좁은 화면의 메뉴 안에서 드롭다운이 잘려 보이던 문제가 함께 해소됩니다. 데스크톱 화면의 드롭다운은 그대로입니다. +- 휴대폰·태블릿 화면에서 통화·배송국가 선택이 메뉴 안의 접었다 펼치는 항목으로 바뀌었습니다. 접혀 있을 때도 현재 선택된 통화와 배송국가가 함께 표시되어 펼치지 않아도 확인할 수 있고, 펼치면 가로 버튼으로 나열됩니다. 좁은 화면의 메뉴 안에서 드롭다운이 잘려 보이던 문제가 함께 해소됩니다. 데스크톱 화면의 드롭다운은 그대로입니다. 관리자 화면도 동일하게 적용됩니다. +- 휴대폰 화면에서 주문 취소·주문상태 일괄변경 팝업의 상품 목록이 표 대신 상품별 카드 형태로 표시됩니다. + +### Fixed + +- 쇼핑몰 환경설정의 은행 관리에서 은행코드를 입력해도 입력한 값이 보이지 않던 문제를 수정했습니다 — 좁은 화면에서 입력칸이 글자가 들어갈 수 없을 만큼 줄어들고 있었습니다. +- 휴대폰 화면에서 주문 취소·주문상태 일괄변경 팝업의 금액이 "249,000원" 대신 "249,000" / "원" 처럼 숫자 중간에서 잘려 보이던 문제를 수정했습니다. 금액은 앞으로도 숫자 중간에서 줄바꿈되지 않으며, "결제완료" 같은 상태 이름도 "결제완" / "료" 로 쪼개지지 않습니다. +- 휴대폰 화면에서 쇼핑몰 환경설정 화면이 좌우로 넘쳐 가로 스크롤이 생기던 문제를 수정했습니다 (이메일 입력 줄, 쇼핑몰 이름·종목 입력칸, 탭 영역). +- 휴대폰 화면에서 쿠폰 발급기간 입력칸과 엑셀 다운로드 기간 검색 입력칸이 화면 밖으로 넘치던 문제를 수정했습니다. +- 은행 관리·도서산간 배송비 팝업의 표가 좁은 화면에서 가로로 스크롤되도록 명시해, 앞으로 표가 잘려 보이지 않도록 했습니다. ## [1.0.1] - 2026-07-08 diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json index 69277391..d258f85d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json @@ -44,8 +44,15 @@ "name": "Div", "slot": "header_currency", "props": { "className": "relative {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" }, + "responsive": { + "portable": { + "comment": "portable 에서는 이 조각이 관리자 템플릿 모바일 드로어(admin_drawer_prefs, 세로 블록) 안에 렌더된다. 펼친 목록이 컨테이너 폭을 채우도록 w-full. hidden 게이트 표현식은 그대로 유지(props 얕은 머지가 아니라 className 통째 교체이므로 표현식을 복제해야 함).", + "props": { "className": "relative w-full {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" } + } + }, "children": [ { + "comment": "트리거 버튼 — 데스크톱은 absolute 드롭다운의 토글, portable(드로어 안)에서는 접기/펼치기 아코디언의 트리거. 두 경우 모두 같은 플래그(_local.showCurrencyDropdown)를 토글하므로 기본은 접힘이다. portable 에서는 드로어 폭을 꽉 채우는 행 형태(좌: '통화' 라벨, 우: 현재 통화·배송국가 요약 + chevron)로 바뀐다.", "type": "basic", "name": "Button", "props": { @@ -54,6 +61,55 @@ "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", "aria-label": "$t:sirsoft-ecommerce.common.currency_label" }, + "responsive": { + "portable": { + "comment": "드로어 안에서는 언어 아코디언 트리거와 동일한 형태 — children 을 통째 교체해 '통화' 라벨 + 현재값 요약 + chevron 으로 구성한다(엔진은 responsive.children 을 완전 교체한다).", + "props": { + "className": "w-full flex items-center justify-between py-1 cursor-pointer", + "aria-haspopup": "listbox", + "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", + "aria-label": "$t:sirsoft-ecommerce.common.currency_label" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" }, + "text": "$t:sirsoft-ecommerce.common.currency_label" + }, + { + "type": "basic", + "name": "Span", + "props": { "className": "flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "comment": "접힘 상태 요약 ① 현재 통화 코드", + "type": "basic", + "name": "Span", + "props": { "className": "font-medium" }, + "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}" + }, + { + "comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만).", + "type": "basic", + "name": "Span", + "if": "{{(_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}", + "props": { "className": "text-gray-500 dark:text-gray-400" }, + "text": "· {{(_global.availableShippingCountries ?? []).find(c => c.code === (_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'))?.name ?? (_global.preferredShippingCountry ?? 'KR')}}" + }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "chevron-down", + "className": "{{_local.showCurrencyDropdown ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200" + } + } + ] + } + ] + } + }, "children": [ { "comment": "트리거 배송국가 코드 (해외배송 활성 시) — 관리자 헤더는 flag 없이 코드 병기", @@ -76,7 +132,7 @@ ] }, { - "comment": "드롭다운 메뉴", + "comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션). portable(0~1023px) 에서는 absolute 팝오버가 드로어 스크롤 경계에서 잘리므로 static 인라인 목록으로 전환한다. 표시 여부는 데스크톱과 동일하게 _local.showCurrencyDropdown 이 결정한다(responsive 에 if 를 두지 않으면 base if 를 그대로 상속) → 기본 접힘.", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", @@ -84,13 +140,34 @@ "className": "absolute right-0 top-full mt-2 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1 overflow-hidden", "role": "listbox" }, + "responsive": { + "portable": { + "comment": "드로어 안에서는 패널이 곧 본문이므로 카드 크롬(테두리/배경/좌우 패딩)을 벗긴다. 크롬을 남기면 드로어 패딩 안에 패딩이 중첩되어 칩 가용폭이 199px 로 줄고, 같은 드로어의 언어 칩(224px)과 줄바꿈 위치가 어긋난다.", + "props": { + "className": "static w-full mt-2 bg-transparent border-0 shadow-none py-0", + "role": "listbox" + } + } + }, "children": [ { + "comment": "섹션 라벨 '통화'. portable 에서는 아코디언 트리거 행이 이미 '통화' 라벨을 달고 있어 중복이므로 숨긴다(배송국가 라벨은 두 번째 섹션을 구분해야 하므로 유지).", "type": "basic", "name": "Div", "props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" }, + "responsive": { "portable": { "if": "{{false}}" } }, "text": "$t:sirsoft-ecommerce.common.currency_label" }, + { + "comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록이라 기존 세로 목록 그대로. iteration 은 아래 Div 자신에 걸려 있어 항목 수만큼 복제되므로, 복제본들이 이 래퍼의 flex item 이 된다.", + "type": "basic", + "name": "Div", + "responsive": { + "portable": { + "props": { "className": "flex flex-wrap gap-2 pb-1" } + } + }, + "children": [ { "type": "basic", "name": "Div", @@ -104,21 +181,33 @@ "role": "option", "aria-selected": "{{_global.preferredCurrency === currency.code}}" }, + "responsive": { + "portable": { + "props": { + "className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{_global.preferredCurrency === currency.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}", + "role": "option", + "aria-selected": "{{_global.preferredCurrency === currency.code}}" + } + } + }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-base" }, "text": "{{currency.flag}}" }, { "type": "basic", "name": "Span", "props": { "className": "flex-1" }, + "responsive": { "portable": { "props": { "className": "" } } }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{currency.code}}" }, { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400 ml-1" }, "text": "({{currency.symbol}})" } ] }, { + "comment": "portable(칩 나열)에서는 선택 상태가 배경/테두리 색으로 이미 표현되고 aria-selected 로도 전달되므로, 칩 폭을 아끼려 체크 아이콘을 숨긴다.", "type": "basic", "name": "Icon", "if": "{{_global.preferredCurrency === currency.code}}", + "responsive": { "portable": { "if": "{{false}}" } }, "props": { "name": "check", "className": "text-base" } } ], @@ -135,6 +224,8 @@ ] } ] + } + ] }, { "comment": "── 구분선 ──", @@ -153,8 +244,19 @@ "type": "basic", "name": "Div", "props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" }, + "responsive": { "portable": { "props": { "className": "py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" } } }, "text": "$t:sirsoft-ecommerce.common.shipping_country_label" }, + { + "comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록.", + "type": "basic", + "name": "Div", + "responsive": { + "portable": { + "props": { "className": "flex flex-wrap gap-2 pb-1" } + } + }, + "children": [ { "type": "basic", "name": "Div", @@ -168,12 +270,29 @@ "role": "option", "aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}" }, + "responsive": { + "portable": { + "props": { + "className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}", + "role": "option", + "aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}" + } + } + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "flex-1 font-medium" }, "text": "{{shipCountry.name ?? shipCountry.code}}" }, { + "type": "basic", + "name": "Span", + "props": { "className": "flex-1 font-medium" }, + "responsive": { "portable": { "props": { "className": "font-medium" } } }, + "text": "{{shipCountry.name ?? shipCountry.code}}" + }, + { + "comment": "portable(칩 나열)에서는 선택 상태를 배경/테두리 색 + aria-selected 로 전달하므로 체크 아이콘을 숨긴다.", "type": "basic", "name": "Icon", "if": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}", + "responsive": { "portable": { "if": "{{false}}" } }, "props": { "name": "check", "className": "text-base" } } ], @@ -191,15 +310,18 @@ } ] } + ] + } ] } ] }, { - "comment": "배경 클릭 시 닫기", + "comment": "배경 클릭 시 닫기 — 데스크톱 드롭다운 전용. portable 은 항상 펼친 칩 목록이라 백드롭이 불필요(있으면 화면 전체를 덮어 클릭을 가로챈다).", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", + "responsive": { "portable": { "if": "{{false}}" } }, "props": { "className": "fixed inset-0 z-40" }, "actions": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json index 95967281..1e2dbf97 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json @@ -20,7 +20,7 @@ }, "children": [ { - "comment": "트리거 버튼 — 데스크톱 전용. 모바일/태블릿(portable)에서는 이 조각이 드로어(overflow-y-auto) 안에 렌더되어 absolute 드롭다운이 잘리므로, 트리거를 숨기고 패널을 항상 펼친다.", + "comment": "트리거 버튼 — 데스크톱은 absolute 드롭다운의 토글, portable(드로어 안)에서는 접기/펼치기 아코디언의 트리거. 두 경우 모두 같은 플래그(_local.showCurrencyDropdown)를 토글하므로 기본은 접힘이다. portable 에서는 드로어 폭을 꽉 채우는 행 형태(좌: '통화' 라벨, 우: 현재 통화·배송국가 요약 + chevron)로 바뀐다.", "type": "basic", "name": "Button", "props": { @@ -31,7 +31,51 @@ }, "responsive": { "portable": { - "props": { "className": "hidden" } + "comment": "드로어 안에서는 언어 아코디언 트리거와 동일한 형태 — children 을 통째 교체해 '통화' 라벨 + 현재값 요약 + chevron 으로 구성한다(엔진은 responsive.children 을 완전 교체한다).", + "props": { + "className": "w-full flex items-center justify-between py-1 cursor-pointer", + "aria-haspopup": "listbox", + "aria-expanded": "{{_local.showCurrencyDropdown ?? false}}", + "aria-label": "$t:sirsoft-ecommerce.common.currency_label" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" }, + "text": "$t:sirsoft-ecommerce.common.currency_label" + }, + { + "type": "basic", + "name": "Span", + "props": { "className": "flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "comment": "접힘 상태 요약 ① 현재 통화 코드", + "type": "basic", + "name": "Span", + "props": { "className": "font-medium" }, + "text": "{{_global.preferredCurrency ?? _global.defaultCurrency ?? 'KRW'}}" + }, + { + "comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만). 통화와 시각적으로 구분되도록 옅은 색.", + "type": "basic", + "name": "Span", + "if": "{{(_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}", + "props": { "className": "text-gray-500 dark:text-gray-400" }, + "text": "· {{(_global.availableShippingCountries ?? []).find(c => c.code === (_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'))?.name ?? (_global.preferredShippingCountry ?? 'KR')}}" + }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "chevron-down", + "className": "{{_local.showCurrencyDropdown ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200" + } + } + ] + } + ] } }, "children": [ @@ -56,7 +100,7 @@ ] }, { - "comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션). portable(0~1023px) 에서는 트리거 없이 항상 펼쳐진 정적 목록 — 드로어 스크롤 경계에서 absolute 패널이 잘리는 문제 회피.", + "comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션). portable(0~1023px) 에서는 absolute 팝오버가 드로어 스크롤 경계에서 잘리므로 static 인라인 목록으로 전환한다. 표시 여부는 데스크톱과 동일하게 _local.showCurrencyDropdown 이 결정한다(responsive 에 if 를 두지 않으면 base if 를 그대로 상속) → 기본 접힘.", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", @@ -66,7 +110,6 @@ }, "responsive": { "portable": { - "if": "{{true}}", "props": { "className": "static w-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg py-1", "role": "listbox" @@ -81,10 +124,11 @@ "if": "{{(_global.availableCurrencies ?? []).length > 0}}", "children": [ { + "comment": "섹션 라벨 '통화'. portable 에서는 아코디언 트리거 행이 이미 '통화' 라벨을 달고 있어 중복이므로 숨긴다(배송국가 라벨은 두 번째 섹션을 구분해야 하므로 유지).", "type": "basic", "name": "Div", "props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" }, - "responsive": { "portable": { "props": { "className": "px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" } } }, + "responsive": { "portable": { "if": "{{false}}" } }, "text": "$t:sirsoft-ecommerce.common.currency_label" }, { @@ -298,10 +342,11 @@ ] }, { - "comment": "배경 클릭 시 닫기", + "comment": "배경 클릭 시 닫기 — 데스크톱 팝오버 전용. portable 에서는 패널이 드로어 안 인라인 아코디언이라 배경 개념이 없다. 여기서 if 를 끄지 않으면 모바일에서 펼치는 순간 fixed inset-0 오버레이가 드로어 전체를 덮어 모든 클릭을 가로챈다.", "type": "basic", "name": "Div", "if": "{{_local.showCurrencyDropdown}}", + "responsive": { "portable": { "if": "{{false}}" } }, "props": { "className": "fixed inset-0 z-40" }, "actions": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminHeaderCurrencySelectorPortable.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminHeaderCurrencySelectorPortable.test.tsx new file mode 100644 index 00000000..0d971302 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminHeaderCurrencySelectorPortable.test.tsx @@ -0,0 +1,134 @@ +/** + * @file adminHeaderCurrencySelectorPortable.test.tsx + * @description 관리자 헤더 통화/배송국가 주입 조각의 portable(모바일·태블릿) 칩 전환 회귀 테스트 + * + * 배경: 관리자 모바일 헤더 우측 그룹이 420px 를 요구해 폰 뷰포트에서 통화 셀렉터가 잘렸다. + * 템플릿이 통화 슬롯을 모바일 드로어(`admin_drawer_prefs`)로 옮겼고, 이 조각은 드로어 안에서 + * 접기/펼치기 아코디언(트리거 행 + static 가로 칩 목록)으로 렌더된다(사용자 조각 + * `header-currency-selector-user` 와 동일 사상). + * + * 접힘이 기본이며, 표시 여부는 데스크톱과 같은 `_local.showCurrencyDropdown` 이 결정한다 — + * portable 오버라이드에 `if` 를 두지 않으면 엔진이 base `if` 를 상속하기 때문(`override.if ?? baseDef.if`). + * 데스크톱(≥1024px)은 기존 absolute 드롭다운을 그대로 유지해야 한다. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const fragmentPath = path.resolve( + __dirname, + '../../../extensions/header-currency-selector-admin.json', +); + +const fragment = JSON.parse(fs.readFileSync(fragmentPath, 'utf8')); +const root = fragment.injections[0].components[0]; +const [trigger, panel, backdrop] = root.children; + +describe('관리자 통화 주입 조각 — portable 칩 전환', () => { + it('데스크톱 기본 경로는 absolute 드롭다운을 유지한다 (회귀 방지)', () => { + expect(root.props.className).toContain('relative'); + expect(root.props.className).not.toContain('w-full'); + + expect(panel.props.className).toContain('absolute'); + expect(panel.props.className).toContain('z-50'); + expect(panel.props.className).toContain('w-44'); + expect(panel.if).toBe('{{_local.showCurrencyDropdown}}'); + }); + + it('portable 에서 루트가 w-full 이 되고 hidden 게이트 표현식이 보존된다', () => { + const cls = root.responsive.portable.props.className; + expect(cls).toContain('w-full'); + // props 는 얕은 머지가 아니라 className 통째 교체이므로 표현식을 복제해야 한다. + expect(cls).toContain('availableCurrencies'); + expect(cls).toContain('international_shipping_enabled'); + }); + + it('portable 트리거는 아코디언 행이 되고 접힘 상태 요약(통화·배송국가)을 노출한다', () => { + expect(trigger.name).toBe('Button'); + + const t = trigger.responsive.portable; + // 더 이상 숨기지 않는다 — 드로어 폭을 채우는 트리거 행 + expect(t.props.className).not.toBe('hidden'); + expect(t.props.className).toContain('w-full'); + expect(t.props.className).toContain('justify-between'); + // 얕은 머지가 아니라 통째 교체이므로 aria-* 를 오버라이드에도 명시 + expect(t.props['aria-expanded']).toBe('{{_local.showCurrencyDropdown ?? false}}'); + + // 접힘 상태에서 현재 값을 알 수 있어야 한다 + const summary = JSON.stringify(t.children); + expect(summary).toContain('preferredCurrency'); + expect(summary).toContain('preferredShippingCountry'); + expect(summary).toContain('chevron-down'); + expect(summary).toContain("{{_local.showCurrencyDropdown ? 'rotate-180' : ''}}"); + }); + + it('portable 패널도 _local.showCurrencyDropdown 으로 게이트된다 (기본 접힘)', () => { + const p = panel.responsive.portable; + // 핵심: `{{true}}` 로 강제하면 드로어에서 항상 펼쳐져 아코디언이 성립하지 않는다. + // if 를 생략해야 base if 를 상속한다. + expect(p.if).toBeUndefined(); + expect(p.props.className).toContain('static'); + expect(p.props.className).not.toContain('absolute'); + expect(p.props.className).not.toContain('z-50'); + // 얕은 머지라 role 을 재선언하지 않으면 유실된다. + expect(p.props.role).toBe('listbox'); + }); + + it('portable 백드롭은 렌더되지 않는다 (fixed inset-0 이 드로어 전체 클릭을 가로챈다)', () => { + expect(backdrop.props.className).toContain('fixed'); + expect(backdrop.responsive.portable.if).toBe('{{false}}'); + }); + + it('칩 래퍼는 정적이고 iteration 은 그 자식에 걸린다', () => { + // 엔진은 iteration 을 가진 노드를 항목 수만큼 복제한다. flex 컨테이너에 걸면 + // 컨테이너가 복제되어 칩이 세로로 쌓인다. + const wrappers = panel.children.filter( + (c: any) => c.responsive?.portable?.props?.className?.includes('flex-wrap'), + ); + // 통화 섹션 래퍼 1 + 배송국가 섹션 래퍼 1 + const shippingSection = panel.children.find( + (c: any) => c.comment?.includes('배송국가 섹션'), + ); + const shippingWrapper = shippingSection.children.find( + (c: any) => c.responsive?.portable?.props?.className?.includes('flex-wrap'), + ); + + expect(wrappers.length).toBe(1); // 통화 섹션은 panel 직계 + expect(shippingWrapper).toBeTruthy(); + + for (const wrapper of [wrappers[0], shippingWrapper]) { + expect(wrapper.iteration).toBeUndefined(); + const iterating = wrapper.children[0]; + expect(iterating.iteration).toBeTruthy(); + } + }); + + it('portable 옵션은 rounded-full 칩이고 체크 아이콘은 숨는다', () => { + const currencyWrapper = panel.children.find( + (c: any) => c.responsive?.portable?.props?.className?.includes('flex-wrap'), + ); + const button = currencyWrapper.children[0].children[0]; + + const chipCls = button.responsive.portable.props.className; + expect(chipCls).toContain('rounded-full'); + expect(chipCls).toContain('inline-flex'); + expect(chipCls).toContain('whitespace-nowrap'); + // 얕은 머지 — role/aria-selected 재선언 필수 + expect(button.responsive.portable.props.role).toBe('option'); + expect(button.responsive.portable.props['aria-selected']).toBeTruthy(); + + const check = button.children.find((c: any) => c.name === 'Icon'); + expect(check.responsive.portable.if).toBe('{{false}}'); + }); + + it('portable 패널은 드로어 안에서 카드 크롬을 벗는다 (언어 칩과 줄바꿈 정렬)', () => { + const cls = panel.responsive.portable.props.className; + // 크롬을 남기면 드로어 패딩 안에 패딩이 중첩되어 칩 가용폭이 줄고 언어 칩과 어긋난다. + expect(cls).toContain('bg-transparent'); + expect(cls).toContain('border-0'); + expect(cls).toContain('shadow-none'); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminMobileViewportOverflow.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminMobileViewportOverflow.test.tsx new file mode 100644 index 00000000..9d0ff405 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminMobileViewportOverflow.test.tsx @@ -0,0 +1,247 @@ +/** + * @file adminMobileViewportOverflow.test.tsx + * @description 관리자 모바일 뷰포트 넘침/압착 회귀 테스트 (이커머스 소유분) + * + * 각 단언은 브라우저 실측(390px / 320px)으로 확인된 결함에 1:1 대응한다. + * 되돌리면 해당 결함이 그대로 재발하므로, 사유를 각 테스트에 남긴다. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const layoutsDir = path.resolve(__dirname, '../../../layouts/admin'); + +const load = (rel: string): any => + JSON.parse(fs.readFileSync(path.resolve(layoutsDir, rel), 'utf8')); + +/** 트리 전체를 순회하며 술어를 만족하는 노드를 모은다. */ +function collect(node: any, pred: (n: any) => boolean, acc: any[] = []): any[] { + if (!node || typeof node !== 'object') return acc; + if (Array.isArray(node)) { + for (const n of node) collect(n, pred, acc); + return acc; + } + if (pred(node)) acc.push(node); + for (const v of Object.values(node)) { + if (v && typeof v === 'object') collect(v, pred, acc); + } + return acc; +} + +const cls = (n: any): string => { + const c = n?.props?.className; + return typeof c === 'string' ? c : ''; +}; + +describe('B1 — 모달 좌우 여백 (max-w-full 트랩)', () => { + // .modal-container 의 max-w-[calc(100vw-2rem)] 와 .max-w-full 은 명시도가 같고(0,1,0) + // 빌드 CSS 에서 .max-w-full 이 뒤에 정의되므로 승리한다. 레이아웃에서 max-w-full 을 + // 주면 clamp 가 무효화되어 모달이 화면 좌우에 붙는다(여백 0). + it.each([ + 'partials/admin_ecommerce_order_detail/_modal_cancel_order.json', + 'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json', + ])('%s 는 max-w-full 을 갖지 않는다', (rel) => { + const raw = fs.readFileSync(path.resolve(layoutsDir, rel), 'utf8'); + expect(raw).not.toContain('max-w-full'); + }); +}); + +describe('B2 — 주문상세 모달 상품표 카드형 전환', () => { + it.each([ + ['_modal_cancel_order.json', 'partials/admin_ecommerce_order_detail/_modal_cancel_order.json'], + ['_modal_batch_change_confirm.json', 'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json'], + ])('%s: grid-cols-12 행이 portable 에서 1열로 스택된다', (_n, rel) => { + const layout = load(rel); + const grids = collect(layout, (n) => cls(n).includes('grid-cols-12')); + + // 헤더 행 1 + 본문(iteration) 행 1 + expect(grids.length).toBe(2); + + const header = grids.find((g) => cls(g).includes('bg-gray-50')); + const body = grids.find((g) => !cls(g).includes('bg-gray-50')); + + // 카드형에서는 열 라벨 행이 의미 없다 → 미렌더 + expect(header.responsive?.portable?.if).toBe('{{false}}'); + + // 390px 에서 12열이면 1열 = 16.4px → `249,000원`(56px) 같은 토큰이 숫자 내부에서 쪼개진다. + expect(body.responsive?.portable?.props?.className).toContain('grid-cols-1'); + expect(body.responsive?.portable?.props?.className).not.toContain('grid-cols-12'); + }); + + it.each([ + 'partials/admin_ecommerce_order_detail/_modal_cancel_order.json', + 'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json', + ])('%s: 모든 col-span 셀이 portable 에서 span 을 벗는다', (rel) => { + const layout = load(rel); + const spanCells = collect(layout, (n) => /col-span-\d+/.test(cls(n))); + expect(spanCells.length).toBeGreaterThan(0); + + for (const cell of spanCells) { + const portable = cell.responsive?.portable?.props?.className; + expect(portable).toBeDefined(); + // 1열 그리드에 col-span-5 가 남으면 암묵 열이 생겨 셀 폭이 들쭉날쭉해진다. + expect(portable).not.toMatch(/col-span-\d+/); + } + }); + + // 세로 스택으로 셀이 전폭(285px)이 되어 현재 데이터로는 절단이 없다(10억대 금액도 88px). + // 그러나 셀이 다시 좁아지면 즉시 재발하므로, 방어선을 코드에 선언해 둔다. + // 실측(16px 셀로 축소): + // - 금액 순수 토큰 + whitespace-nowrap → 1줄 유지, 숫자 내부 미분리 + // - `소계 249,000원`(한글+금액 혼합) + break-keep → `소계` / `249,000원` 2줄, 숫자 보존 + // (이 문자열에 nowrap 을 주면 셀을 65px 넘겨 더 나쁘다 — 그래서 break-keep) + it('금액(순수 숫자) 노드는 whitespace-nowrap 으로 숫자 내부 줄바꿈을 막는다', () => { + const cancel = load('partials/admin_ecommerce_order_detail/_modal_cancel_order.json'); + const batch = load('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json'); + + const unitPrice = collect( + cancel, + (n) => typeof n.text === 'string' && n.text.includes('unit_price') && n.text.includes('원'), + )[0]; + expect(cls(unitPrice)).toContain('whitespace-nowrap'); + + const batchAmounts = collect( + batch, + (n) => typeof n.text === 'string' && /Number\(confirmItem\.(original_price|unit_price)\)/.test(n.text), + ); + expect(batchAmounts.length).toBe(2); + for (const node of batchAmounts) { + expect(cls(node)).toContain('whitespace-nowrap'); + } + }); + + it('한글 라벨 노드는 break-keep 으로 음절 경계 절단을 막는다', () => { + const cancel = load('partials/admin_ecommerce_order_detail/_modal_cancel_order.json'); + const batch = load('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json'); + + // `소계 249,000원` — 한글 라벨 + 금액 혼합. nowrap 이 아니라 break-keep 이 정답. + const subtotal = collect( + cancel, + (n) => typeof n.text === 'string' && n.text.includes('product_subtotal'), + )[0]; + expect(cls(subtotal)).toContain('break-keep'); + expect(cls(subtotal)).not.toContain('whitespace-nowrap'); + + // 상태 배지 (`결제완료` → `결제완`/`료` 방지) + const cancelStatus = collect( + cancel, + (n) => typeof n.text === 'string' && n.text.includes('option_status_label'), + )[0]; + expect(cls(cancelStatus)).toContain('break-keep'); + + const batchStatus = collect( + batch, + (n) => typeof n.text === 'string' && n.text.includes('order.status.payment_complete'), + )[0]; + expect(cls(batchStatus)).toContain('break-keep'); + }); +}); + +describe('B3 — 은행 관리 모달', () => { + const rel = 'partials/admin_ecommerce_settings/_bank_management_modal.json'; + + it('은행코드 입력칸에 min-w-[80px] 하한이 있다', () => { + const layout = load(rel); + // 은행코드 input 은 code 오류 키를 참조하는 className 바인딩으로 식별한다. + const input = collect( + layout, + (n) => n.name === 'Input' && cls(n).includes("+ '.code'"), + )[0]; + + expect(input).toBeTruthy(); + // table-layout:auto + td{min-width:0} 이라 좁은 뷰포트에서 input 이 24px(=좌우 padding)로 + // 압착되어 텍스트 영역이 0px 이 된다. width 지정은 auto 레이아웃이 무시하므로 min-width 필요. + expect(cls(input)).toContain('min-w-[80px]'); + // 숫자 스케일 min-w-16 은 safelist 에 없어 CSS 가 생성되지 않는다(무효). + expect(cls(input)).not.toContain('min-w-16'); + }); + + it('표 래퍼가 overflow-x-auto 를 명시한다', () => { + const layout = load(rel); + const wrapper = collect(layout, (n) => cls(n).includes('max-h-96'))[0]; + expect(wrapper).toBeTruthy(); + // overflow-y 만 선언해도 computed 는 auto 가 되지만 암묵적 의존이다. + expect(cls(wrapper)).toContain('overflow-x-auto'); + }); +}); + +describe('C2 — 쿠폰 발급기간 입력 행', () => { + const rel = 'partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json'; + + it('portable 에서 행이 줄바꿈하고 datetime 입력이 전폭이 된다', () => { + const layout = load(rel); + const row = collect(layout, (n) => n.id === 'issue_period_range')[0]; + expect(row).toBeTruthy(); + // flex-center 는 nowrap. w-52(208px) 두 개가 좁은 뷰포트를 넘긴다. + expect(row.responsive.portable.props.className).toContain('flex-wrap'); + + const inputs = collect(row, (n) => n.name === 'Input'); + expect(inputs.length).toBe(2); + for (const i of inputs) { + expect(i.responsive.portable.props.className).toContain('w-full'); + expect(i.responsive.portable.props.className).not.toContain('w-52'); + } + }); +}); + +describe('C3 — 이커머스 환경설정 넘침', () => { + it('탭바는 sticky-tab-nav-responsive 를 쓴다 (단계적 패딩과 일치)', () => { + const layout = load('admin_ecommerce_settings.json'); + const nav = collect(layout, (n) => n.id === 'tab_navigation')[0]; + expect(nav).toBeTruthy(); + // 콘텐츠 래퍼가 p-4 sm:p-6 lg:p-8 이라 고정 24px 를 가정하는 sticky-tab-nav(-mx-6 px-6)는 + // 폰에서 좌우 8px 씩 과다 bleed 된다. + expect(nav.props.className).toBe('sticky-tab-nav-responsive'); + }); + + it('이메일 행이 portable 에서 줄바꿈한다 (rcaExcess 71px 의 진범)', () => { + const layout = load('partials/admin_ecommerce_settings/_tab_basic_info.json'); + const emailGroup = collect(layout, (n) => n.id === 'field_email')[0]; + expect(emailGroup).toBeTruthy(); + const row = collect(emailGroup, (n) => cls(n).startsWith('flex-center'))[0]; + expect(row.responsive.portable.props.className).toContain('flex-wrap'); + }); + + it('w-80 고정폭 입력은 mobile(0~767px) 에서만 전폭이 된다', () => { + const layout = load('partials/admin_ecommerce_settings/_tab_basic_info.json'); + const w80 = collect(layout, (n) => n.name === 'Input' && cls(n).includes('w-80')); + expect(w80.length).toBeGreaterThanOrEqual(2); + + const overridden = w80.filter((n) => n.responsive); + expect(overridden.length).toBe(2); // 쇼핑몰 이름 + 종목 + + for (const n of overridden) { + // 태블릿(768~1023px)은 w-80 이 넘치지 않으므로 portable 로 잡으면 불필요하게 늘어난다. + expect(n.responsive.mobile).toBeDefined(); + expect(n.responsive.portable).toBeUndefined(); + expect(n.responsive.mobile.props.className).toContain('w-full'); + } + }); +}); + +describe('C5 — 엑셀 다운로드 날짜 필터 행', () => { + it('부모가 flex-wrap 이어도 자식 행에 flex-wrap 이 필요하다', () => { + const layout = load('admin_ecommerce_excel_download_index.json'); + const parent = collect(layout, (n) => n.id === 'date_filter_row')[0]; + expect(parent).toBeTruthy(); + expect(cls(parent)).toContain('flex-wrap'); + + // 부모만 wrap 이면 nowrap 인 자식 행(입력 2개 × w-36)이 11px 넘친다. + const child = (parent.children ?? []).find((c: any) => cls(c).startsWith('flex-center')); + expect(child).toBeTruthy(); + expect(cls(child)).toContain('flex-wrap'); + }); +}); + +describe('C6 — 도서산간 템플릿 모달 표', () => { + it('표 컨테이너가 overflow-x-auto 를 명시한다', () => { + const layout = load('partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json'); + const wrapper = collect(layout, (n) => n.id === 'template_list')[0]; + expect(wrapper).toBeTruthy(); + // 현재는 computed 가 우연히 auto 라 동작하지만, overflow-y-visible 로 바뀌면 즉시 표가 잘린다. + expect(cls(wrapper)).toContain('overflow-x-auto'); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx index b354a3f9..afb93f28 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx @@ -120,19 +120,38 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션 * 사용자 템플릿(sirsoft-basic)은 390px 뷰포트 오버플로(+11px) 해소를 위해 이 슬롯을 * 헤더 우측 그룹에서 모바일 드로어(overflow-y-auto + w-80)로 옮겼다. 드로어에서는 * absolute 드롭다운 패널이 스크롤 경계에서 잘리므로, 주입 노드 자체가 responsive.portable - * (0~1023px) 오버라이드로 "트리거 숨김 + 패널 항상 펼침(static)" 을 선언한다. + * (0~1023px) 오버라이드로 static 인라인 목록으로 전환한다. + * + * 표시 여부는 데스크톱과 동일하게 `_local.showCurrencyDropdown` 이 결정한다 — portable 에 + * `if` 를 두지 않으면 엔진이 base `if` 를 그대로 상속하므로(`override.if ?? baseDef.if`) + * 드로어에서도 기본 접힘이 되고, 트리거가 접기/펼치기 아코디언 역할을 한다. * * SlotContainer 는 주입 자식에게 variant/context prop 을 전달하지 않으므로 컨테이너별로 * 다르게 그릴 방법이 이것뿐이다. 데스크톱(≥1024px)은 기존 드롭다운을 그대로 유지한다. */ - it('트리거 버튼은 portable(0~1023px)에서 숨겨진다 (모바일 드로어 = 펼친 목록)', () => { + it('트리거 버튼은 portable 에서 아코디언 행이 되고 접힘 상태 요약(통화·배송국가)을 노출한다', () => { const trigger = walk(root, (n) => n.name === 'Button' && Array.isArray(n.actions) && n.actions.some((a: any) => a.handler === 'setState' && a.params?.showCurrencyDropdown !== undefined)); expect(trigger).toBeTruthy(); - expect(trigger.responsive?.portable?.props?.className).toBe('hidden'); + + const portable = trigger.responsive?.portable; + expect(portable).toBeTruthy(); + // 더 이상 숨기지 않는다 — 드로어 폭을 채우는 트리거 행 + expect(portable.props.className).not.toBe('hidden'); + expect(portable.props.className).toContain('w-full'); + expect(portable.props.className).toContain('justify-between'); + // 접근성: 얕은 머지가 아니라 통째 교체이므로 aria-* 를 오버라이드에도 명시 + expect(portable.props['aria-expanded']).toBe('{{_local.showCurrencyDropdown ?? false}}'); + + // 접힘 상태에서 현재 값을 알 수 있어야 한다 (요약 = 통화 + 배송국가) + const summary = JSON.stringify(portable.children); + expect(summary).toContain('preferredCurrency'); + expect(summary).toContain('preferredShippingCountry'); + expect(summary).toContain('chevron-down'); + expect(summary).toContain("{{_local.showCurrencyDropdown ? 'rotate-180' : ''}}"); }); - it('패널은 portable 에서 if 해제 + static 흐름 (데스크톱 absolute/z-50 은 보존)', () => { + it('패널은 portable 에서도 _local.showCurrencyDropdown 으로 게이트된다 (기본 접힘)', () => { const panel = (root.children ?? []).find( (n: any) => n.name === 'Div' && n.props?.role === 'listbox' ); @@ -143,10 +162,11 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션 expect(panel.props.className).toContain('absolute'); expect(panel.props.className).toContain('z-50'); - // portable 오버라이드 — 엔진은 override.if 로 base if 를 완전 교체한다 const portable = panel.responsive?.portable; expect(portable).toBeTruthy(); - expect(portable.if).toBe('{{true}}'); + // 핵심: portable 에 if 를 두지 않아야 base if 를 상속해 기본 접힘이 된다. + // `{{true}}` 로 강제하면 드로어에서 항상 펼쳐져 아코디언이 성립하지 않는다. + expect(portable.if).toBeUndefined(); expect(portable.props.className).toContain('static'); expect(portable.props.className).not.toContain('absolute'); expect(portable.props.className).not.toContain('z-50'); @@ -154,6 +174,15 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션 expect(portable.props.role).toBe('listbox'); }); + it('배경 클릭 오버레이는 portable 에서 꺼진다 (드로어 전체를 덮어 클릭을 가로채는 회귀 방지)', () => { + const backdrop = (root.children ?? []).find( + (n: any) => n.name === 'Div' && (n.props?.className ?? '').includes('fixed inset-0') + ); + expect(backdrop).toBeTruthy(); + expect(backdrop.if).toBe('{{_local.showCurrencyDropdown}}'); + expect(backdrop.responsive?.portable?.if).toBe('{{false}}'); + }); + /** * 모바일(portable)에서는 통화·배송국가를 세로 목록이 아니라 가로 나열 칩으로 그린다. * 세로 목록은 좁은 드로어에서 세로 공간을 과하게 먹는다. diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json index 5ead3af5..3a0031f7 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_excel_download_index.json @@ -50,7 +50,7 @@ "name": "Icon", "props": { "name": "file-spreadsheet", - "className": "w-5 h-5 icon-muted" + "className": "text-xl icon-muted" } }, { @@ -157,7 +157,7 @@ "name": "Icon", "props": { "name": "refresh-cw", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -230,7 +230,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -262,7 +262,7 @@ "name": "Icon", "props": { "name": "loader", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -294,7 +294,7 @@ "name": "Icon", "props": { "name": "alert-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -326,7 +326,7 @@ "name": "Icon", "props": { "name": "file-text", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -439,10 +439,11 @@ } }, { + "comment": "부모(date_filter_row)는 flex-wrap 이지만 이 행 자신은 nowrap 이라, 시작일~종료일(각 input w-36 = 144px)이 좁은 뷰포트에서 콘텐츠 영역을 11px 넘긴다. 자식에도 flex-wrap 을 준다.", "type": "basic", "name": "Div", "props": { - "className": "flex-center gap-2" + "className": "flex-center flex-wrap gap-2" }, "children": [ { @@ -457,7 +458,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 icon-muted" + "className": "text-base icon-muted" } }, { @@ -502,7 +503,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 icon-muted" + "className": "text-base icon-muted" } }, { @@ -781,7 +782,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -994,7 +995,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1161,7 +1162,7 @@ "name": "Icon", "props": { "name": "loader", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -1299,7 +1300,7 @@ "name": "Icon", "props": { "name": "alert-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1437,7 +1438,7 @@ "name": "Icon", "props": { "name": "file-text", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1581,7 +1582,7 @@ "name": "Icon", "props": { "name": "check-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1789,7 +1790,7 @@ "name": "Icon", "props": { "name": "loader", - "className": "w-3 h-3 animate-spin" + "className": "text-xs animate-spin" } }, { @@ -1979,7 +1980,7 @@ "name": "Icon", "props": { "name": "alert-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -2169,7 +2170,7 @@ "name": "Icon", "props": { "name": "file-text", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -2379,7 +2380,7 @@ "name": "Icon", "props": { "name": "chevrons-left", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2398,7 +2399,7 @@ "name": "Icon", "props": { "name": "chevron-left", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2434,7 +2435,7 @@ "name": "Icon", "props": { "name": "chevron-right", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2452,7 +2453,7 @@ "name": "Icon", "props": { "name": "chevrons-right", - "className": "w-4 h-4" + "className": "text-base" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json index 4ab60a40..3c65cf33 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json @@ -268,6 +268,7 @@ ] }, { + "comment": "className 은 sticky-tab-nav-responsive 를 쓴다 — 이 화면의 콘텐츠 래퍼가 admin-page-content-responsive(p-4 sm:p-6 lg:p-8) 라 뷰포트마다 좌우 패딩이 다르기 때문. 고정 24px 를 가정하는 sticky-tab-nav(-mx-6 px-6)를 쓰면 폰(p-4=16px)에서 좌우로 8px 씩 과다 bleed 되어 콘텐츠 영역을 넘친다.", "id": "tab_navigation", "type": "composite", "name": "TabNavigation", @@ -316,7 +317,7 @@ ], "activeTabId": "{{_global.activeEcommerceSettingsTab || query.tab || 'basic_info'}}", "variant": "default", - "className": "sticky-tab-nav" + "className": "sticky-tab-nav-responsive" }, "actions": [ { @@ -380,7 +381,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } }, { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json index 8e778d99..c51773b9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json @@ -8,8 +8,7 @@ "name": "Modal", "props": { "title": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.title", - "width": "750px", - "className": "max-w-full" + "width": "750px" }, "children": [ { @@ -48,7 +47,14 @@ "props": { "className": "col-span-5" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.product_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.product_col", + "responsive": { + "portable": { + "props": { + "className": "" + } + } + } }, { "type": "basic", @@ -56,7 +62,14 @@ "props": { "className": "col-span-2 text-right" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.price_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.price_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -64,7 +77,14 @@ "props": { "className": "col-span-2 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.change_quantity_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.change_quantity_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -72,9 +92,21 @@ "props": { "className": "col-span-3 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.current_status_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.batch_change.current_status_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } } - ] + ], + "responsive": { + "portable": { + "if": "{{false}}" + } + } }, { "type": "basic", @@ -165,7 +197,14 @@ } ] } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex-center gap-2 min-w-0" + } + } + } }, { "type": "basic", @@ -175,11 +214,12 @@ }, "children": [ { + "comment": "whitespace-nowrap — 금액은 순수 숫자 토큰이라 숫자 내부에서 절대 줄바꿈되지 않아야 한다(`1,249,000` → `1,249` / `,000`). portable 세로 스택으로 셀이 전폭이라 현재는 절단이 없으나, 셀이 다시 좁아지면 즉시 재발하므로 의도를 코드에 고정한다.", "type": "basic", "name": "P", "if": "{{confirmItem.original_price && Number(confirmItem.original_price) !== Number(confirmItem.unit_price)}}", "props": { - "className": "text-hint line-through" + "className": "text-hint line-through whitespace-nowrap" }, "text": "{{Number(confirmItem.original_price).toLocaleString()}}" }, @@ -187,11 +227,18 @@ "type": "basic", "name": "P", "props": { - "className": "text-sm font-medium text-gray-900 dark:text-gray-100" + "className": "text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap" }, "text": "{{Number(confirmItem.unit_price).toLocaleString()}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -230,7 +277,14 @@ }, "text": "/ {{confirmItem.quantity}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex justify-start" + } + } + } }, { "type": "basic", @@ -243,13 +297,27 @@ "type": "basic", "name": "Span", "props": { - "className": "{{({'pending_order':'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300','pending_payment':'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300','payment_complete':'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300','shipping_hold':'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300','preparing':'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300','shipping_ready':'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300','shipping':'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300','delivered':'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300','cancelled':'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300','confirmed':'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300'})[confirmItem.option_status] ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}} inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" + "className": "{{({'pending_order':'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300','pending_payment':'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300','payment_complete':'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300','shipping_hold':'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300','preparing':'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300','shipping_ready':'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300','shipping':'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300','delivered':'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300','cancelled':'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300','confirmed':'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300'})[confirmItem.option_status] ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}} inline-flex items-center px-2 py-0.5 rounded text-xs font-medium break-keep" }, "text": "{{({'pending_order':'$t:sirsoft-ecommerce.admin.order.status.waiting','pending_payment':'$t:sirsoft-ecommerce.admin.order.status.payment_pending','payment_complete':'$t:sirsoft-ecommerce.admin.order.status.payment_complete','shipping_hold':'$t:sirsoft-ecommerce.admin.order.status.shipping_hold','preparing':'$t:sirsoft-ecommerce.admin.order.status.preparing','shipping_ready':'$t:sirsoft-ecommerce.admin.order.status.shipping_ready','shipping':'$t:sirsoft-ecommerce.admin.order.status.shipping','delivered':'$t:sirsoft-ecommerce.admin.order.status.delivered','cancelled':'$t:sirsoft-ecommerce.admin.order.status.cancelled','confirmed':'$t:sirsoft-ecommerce.admin.order.status.confirmed'})[confirmItem.option_status] ?? confirmItem.option_status}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex justify-start" + } + } + } } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "grid grid-cols-1 gap-2 px-3 py-2 items-center" + } + } + } } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json index d3c81786..dab3bbe9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json @@ -8,8 +8,7 @@ "name": "Modal", "props": { "title": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.title", - "width": "1125px", - "className": "max-w-full" + "width": "1125px" }, "children": [ { @@ -154,7 +153,14 @@ "props": { "className": "col-span-5" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_col", + "responsive": { + "portable": { + "props": { + "className": "" + } + } + } }, { "type": "basic", @@ -162,7 +168,14 @@ "props": { "className": "col-span-2 text-right" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.unit_price_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.unit_price_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -170,7 +183,14 @@ "props": { "className": "col-span-3 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_quantity_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_quantity_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "type": "basic", @@ -178,9 +198,21 @@ "props": { "className": "col-span-2 text-center" }, - "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.status_col" + "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.status_col", + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } } - ] + ], + "responsive": { + "portable": { + "if": "{{false}}" + } + } }, { "comment": "취소 항목 반복", @@ -238,7 +270,14 @@ } ] } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex-center gap-2" + } + } + } }, { "comment": "단가 + 소계", @@ -249,22 +288,31 @@ }, "children": [ { + "comment": "whitespace-nowrap — 금액 토큰은 숫자 내부에서 절대 줄바꿈되지 않아야 한다(`249,000원` → `249,000` / `원`). portable 세로 스택으로 셀이 전폭이라 현재는 절단이 없으나, 셀이 다시 좁아지면 즉시 재발하므로 의도를 코드에 고정한다.", "type": "basic", "name": "Span", "props": { - "className": "text-body" + "className": "text-body whitespace-nowrap" }, "text": "{{(cancelItem.unit_price ?? 0).toLocaleString()}}원 × {{cancelItem.quantity ?? 1}}" }, { + "comment": "break-keep + whitespace-nowrap 조합 대신 break-keep 만 — `소계`(한글 라벨) + 금액이 한 문자열이라 nowrap 을 주면 좁은 셀에서 넘친다. break-keep 은 어절 경계에서만 줄바꿈해 `소계` / `249,000원` 으로 끊고 숫자 내부는 보존한다.", "type": "basic", "name": "P", "props": { - "className": "text-tertiary" + "className": "text-tertiary break-keep" }, "text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_subtotal {{((cancelItem.unit_price ?? 0) * (cancelItem.quantity ?? 1)).toLocaleString()}}원" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "text-left" + } + } + } }, { "comment": "취소수량 입력", @@ -297,7 +345,14 @@ } ] } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex justify-start" + } + } + } }, { "comment": "현재상태 Badge", @@ -310,13 +365,31 @@ }, "children": [ { + "comment": "break-keep — 한글 상태명(`결제완료`)이 음절 경계에서 `결제완`/`료` 로 쪼개지지 않게 어절 단위 줄바꿈을 강제한다. 현재 Badge 는 inline-flex 라 콘텐츠 폭으로 자라 줄바꿈이 없지만, 배지 폭이 제한되면 즉시 재발한다.", "type": "basic", "name": "Span", + "props": { + "className": "break-keep" + }, "text": "{{cancelItem.option_status_label ?? cancelItem.option_status ?? ''}}" } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "" + } + } + } } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "grid grid-cols-1 gap-2 px-3 py-2 border-t border-gray-100 dark:border-gray-700 items-center" + } + } + } } ] }, @@ -1675,8 +1748,7 @@ ] } ], - "props": { - } + "props": {} } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json index bab9f421..7bb7c9ad 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_issue_settings.json @@ -194,7 +194,7 @@ ] }, { - "comment": "기간설정 시: 시작일 ~ 종료일", + "comment": "기간설정 시: 시작일 ~ 종료일. flex-center 는 nowrap 이고 datetime-local 입력이 각 208px(w-52) 이라 좁은 뷰포트에서 행이 콘텐츠 영역을 넘친다 → portable 에서 줄바꿈 + 입력 전폭.", "id": "issue_period_range", "type": "basic", "name": "Div", @@ -202,6 +202,13 @@ "props": { "className": "flex-center gap-2" }, + "responsive": { + "portable": { + "props": { + "className": "flex-center flex-wrap gap-2" + } + } + }, "children": [ { "type": "basic", @@ -210,6 +217,15 @@ "type": "datetime-local", "name": "issue_from", "className": "input w-52" + }, + "responsive": { + "portable": { + "props": { + "type": "datetime-local", + "name": "issue_from", + "className": "input w-full" + } + } } }, { @@ -227,6 +243,15 @@ "type": "datetime-local", "name": "issue_to", "className": "input w-52" + }, + "responsive": { + "portable": { + "props": { + "type": "datetime-local", + "name": "issue_to", + "className": "input w-full" + } + } } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json index 3139f488..0b2bd420 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_bank_management_modal.json @@ -119,11 +119,11 @@ ] }, { - "comment": "은행 목록 테이블 (스크롤 영역)", + "comment": "은행 목록 테이블 (스크롤 영역). overflow-x-auto 명시 필수 — 좁은 뷰포트에서 표(413px)가 컨테이너(356px)를 넘는다. overflow-y 만 선언해도 CSS Overflow 명세상 다른 축이 visible 로 남을 수 없어 computed 값이 auto 가 되지만, 그 의존은 암묵적이다. 누군가 overflow-y-visible 로 바꾸는 순간 표가 잘리므로 의도를 코드에 고정한다.", "type": "basic", "name": "Div", "props": { - "className": "max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg" + "className": "max-h-96 overflow-y-auto overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg" }, "children": [ { @@ -207,13 +207,14 @@ }, "children": [ { + "comment": "min-w-[80px] 필수 — 표가 table-layout:auto 이고 td 의 min-width 가 0 이라, 좁은 뷰포트에서 은행명 열이 은행코드 열을 56px 로 압착하면 input 이 24px(= 좌우 padding 12+12)로 줄어 텍스트 영역이 0px 이 된다(입력한 값이 보이지 않음). width 지정은 auto 레이아웃이 무시하므로 하한(min-width)을 고정해야 한다. 숫자 스케일 min-w-16 등은 safelist 에 없어 CSS 가 생성되지 않으므로 쓰면 무효다.", "type": "basic", "name": "Input", "props": { "type": "text", "value": "{{bank.code ?? ''}}", "placeholder": "$t:sirsoft-ecommerce.admin.settings.order_settings.bank_management.code_placeholder", - "className": "{{$parent._local.bankModalErrors?.['banks.' + bank._idx + '.code'] ? 'input input-error' : 'input'}}", + "className": "{{$parent._local.bankModalErrors?.['banks.' + bank._idx + '.code'] ? 'input input-error min-w-[80px]' : 'input min-w-[80px]'}}", "disabled": "{{$parent._computed.isReadOnly}}" }, "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json index ca5ae84c..e2c39841 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json @@ -74,6 +74,7 @@ ] }, { + "comment": "w-80(320px) 고정폭은 폰(<768px)의 콘텐츠 영역을 넘긴다(320px 뷰포트에서 32px 초과). 태블릿(768~1023px)은 넘치지 않으므로 portable 이 아니라 mobile(0~767px) 로 한정한다 — portable 로 잡으면 태블릿에서 입력칸이 불필요하게 늘어난다. className 오버라이드는 통째 교체라 에러 분기 표현식을 복제한다.", "type": "basic", "name": "Input", "props": { @@ -82,6 +83,13 @@ "placeholder": "$t:sirsoft-ecommerce.admin.settings.basic_info.shop_info.shop_name_placeholder", "className": "{{_local.errors?.['basic_info.shop_name'] ? 'input input-error w-80' : 'input w-80'}}", "disabled": "{{_computed.isReadOnly}}" + }, + "responsive": { + "mobile": { + "props": { + "className": "{{_local.errors?.['basic_info.shop_name'] ? 'input input-error w-full' : 'input w-full'}}" + } + } } }, { @@ -453,6 +461,7 @@ "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.business_info.business_category" }, { + "comment": "w-80(320px) 고정폭은 폰(<768px)의 콘텐츠 영역을 넘긴다(320px 뷰포트에서 32px 초과). 태블릿(768~1023px)은 넘치지 않으므로 portable 이 아니라 mobile(0~767px) 로 한정한다 — portable 로 잡으면 태블릿에서 입력칸이 불필요하게 늘어난다. className 오버라이드는 통째 교체라 에러 분기 표현식을 복제한다.", "type": "basic", "name": "Input", "props": { @@ -461,6 +470,13 @@ "placeholder": "$t:sirsoft-ecommerce.admin.settings.basic_info.business_info.business_category_placeholder", "className": "{{_local.errors?.['basic_info.business_category'] ? 'input input-error w-80' : 'input w-80'}}", "disabled": "{{_computed.isReadOnly}}" + }, + "responsive": { + "mobile": { + "props": { + "className": "{{_local.errors?.['basic_info.business_category'] ? 'input input-error w-full' : 'input w-full'}}" + } + } } }, { @@ -778,11 +794,19 @@ "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.business_info.email" }, { + "comment": "flex-center 는 nowrap 이고 자식이 고정폭(아이디 w-36 + @ + 도메인 입력 + '직접입력' 드롭다운 104px)이라 좁은 뷰포트에서 이 행이 콘텐츠 영역을 71px 넘긴다 → portable 에서 줄바꿈.", "type": "basic", "name": "Div", "props": { "className": "flex-center gap-2" }, + "responsive": { + "portable": { + "props": { + "className": "flex-center flex-wrap gap-2" + } + } + }, "children": [ { "type": "basic", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json index 93b3b81b..997d7caf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_modal_extra_fee_template.json @@ -394,11 +394,12 @@ }, "children": [ { + "comment": "overflow-x-auto 명시 — 6열 표(615px)가 좁은 뷰포트 컨테이너를 넘는다. overflow-y 만 선언해도 CSS Overflow 명세상 다른 축이 visible 로 남을 수 없어 computed 가 auto 로 계산되지만 그 의존은 암묵적이다. 누군가 overflow-y-visible 로 바꾸면 즉시 표가 잘리므로 의도를 코드에 고정한다. 표는 whitespace-nowrap 이라 절단 없이 가로 스크롤된다(세로 스택 전환은 과잉 대응).", "id": "template_list", "type": "basic", "name": "Div", "props": { - "className": "max-h-96 overflow-y-auto" + "className": "max-h-96 overflow-y-auto overflow-x-auto" }, "actions": [ { diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 4fadd3e7..59aebca2 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -4,6 +4,25 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-09 + +### Added + +- 레이아웃 편집기에서 관리자 기본 레이아웃을 편집할 때 "모바일 메뉴 펼침" 상태를 선택할 수 있습니다 — 모바일 메뉴 안의 언어·통화·배송국가 버튼을 캔버스에서 바로 편집할 수 있습니다. + +### Changed + +- 모바일에서 언어·통화·배송국가 선택을 관리자 메뉴 안으로 옮기고, 사용자 화면과 동일하게 가로로 나열된 버튼 형태로 통일했습니다. 상단 헤더에는 테마 전환과 알림만 남습니다. +- 모바일 메뉴 안의 언어와 통화·배송국가가 각각 접었다 펼치는 항목으로 바뀌어, 메뉴를 열었을 때 곧바로 관리자 메뉴가 보입니다. 접혀 있을 때도 현재 선택된 언어와 통화·배송국가가 표시되므로 펼치지 않아도 확인할 수 있으며, 두 항목은 서로 독립적으로 여닫힙니다. + +### Fixed + +- 모바일에서 상단 헤더의 통화 선택 버튼이 화면 밖으로 잘려 보이던 문제를 수정했습니다. 화면이 좁을수록 심해져 320px 기기에서는 대부분이 가려졌습니다. +- 관리자 메뉴에서 언어를 선택해도 언어가 바뀌지 않던 문제를 수정했습니다. 로그인·비밀번호 찾기·비밀번호 재설정 화면의 언어 선택도 함께 정리했습니다. +- 좁은 화면에서 팝업 창이 화면 좌우에 빈틈 없이 붙어 보이던 문제를 수정했습니다 — 이제 양옆에 여백이 생깁니다. +- 좁은 화면에서 목록 아래 페이지 번호가 한 줄에 다 들어가지 않아 화면 밖으로 넘치던 문제를 수정했습니다 — 이제 여러 줄로 나뉘어 표시됩니다. +- 좁은 화면에서 다국어 입력란의 언어 탭(한국어·English·日本語)이 한 줄에 다 들어가지 않아 넘치던 문제를 수정했습니다. 상품·게시판·페이지 등 다국어 입력이 있는 모든 화면에 적용됩니다. + ## [1.0.1] - 2026-07-08 ### Added diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-auth-setlocale-target.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-auth-setlocale-target.test.tsx new file mode 100644 index 00000000..b52c1df5 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-auth-setlocale-target.test.tsx @@ -0,0 +1,77 @@ +/** + * @file admin-auth-setlocale-target.test.tsx + * @description 비인증 화면(로그인·비밀번호찾기·비밀번호재설정) 언어 선택기의 setLocale 액션 형태 회귀 테스트 + * + * 배경: 관리자 템플릿은 자체 `setLocaleHandler` 를 등록해 엔진 빌트인 `setLocale`(ActionDispatcher) + * 을 덮어쓰고 있었다. 그 사본에는 두 결함이 있었다. + * ① `\n \n {fileName || placeholder}\n \n {fileName && !disabled && (\n \n \n \n \n \n )}\n \n );\n});\n\nFileInput.displayName = 'FileInput';","// e2e:allow basic Input 디폴트 시맨틱(.input/.checkbox/.radio) 머지 — 동작 무변, 외형 통일 시범. 패턴 확정 후 다음 사이클에 E2E spec 일괄 작성 예정.\nimport React, { useRef, useCallback, useState, useEffect, forwardRef, useImperativeHandle } from 'react';\n\nexport interface InputProps extends React.InputHTMLAttributes {\n label?: string;\n error?: string;\n}\n\n// 타입별 디폴트 시맨틱 클래스 (admin main.css 정의)\n// 호출처 className 은 뒤에 머지되어 덮어쓸 수 있음 (의미 토큰/override 모두 지원)\nconst resolveDefaultSemanticClass = (type: string): string => {\n if (type === 'checkbox') return 'checkbox';\n if (type === 'radio') return 'radio';\n return 'input';\n};\n\n// 호출처 className 에 이미 시맨틱 토큰이 포함된 경우 prepend 를 스킵하여\n// \"input input\" 같은 중복 발생을 방지. 기존 호출처가 명시적으로 시맨틱을 적던 패턴 호환.\nconst composeSemanticClassName = (type: string, className: string): string => {\n const semantic = resolveDefaultSemanticClass(type);\n if (!className) return semantic;\n const tokens = className.split(/\\s+/).filter(Boolean);\n if (tokens.includes(semantic)) return className;\n return `${semantic} ${className}`;\n};\n\n/**\n * 기본 입력 필드 컴포넌트\n *\n * IME(한글 등) 조합 중에는 외부 onChange 이벤트를 발생시키지 않습니다.\n * 내부 로컬 상태를 사용하여 입력을 처리하고, 조합이 완료되면 외부에 알립니다.\n *\n * forwardRef를 사용하여 외부에서 ref로 input 요소에 접근할 수 있습니다.\n * 예: indeterminate 상태 설정 시 ref.current.indeterminate = true\n */\nexport const Input = forwardRef(({\n label,\n error,\n className = '',\n onChange,\n onKeyPress,\n value,\n defaultValue,\n type = 'text',\n checked,\n defaultChecked,\n ...props\n}, ref) => {\n // radio/checkbox 타입인지 확인\n const isCheckableType = type === 'radio' || type === 'checkbox';\n\n // 디폴트 시맨틱 + 호출처 className 머지 (호출처가 의미 토큰만 명시해도 시맨틱은 자동 적용)\n // 호출처가 이미 시맨틱 토큰을 명시한 경우 중복 prepend 방지.\n const mergedClassName = composeSemanticClassName(type, className);\n\n // IME 조합 중인지 추적 (text input에만 필요)\n const isComposingRef = useRef(false);\n // input 요소 참조\n const internalRef = useRef(null);\n\n // 외부 ref와 내부 ref를 연결\n useImperativeHandle(ref, () => internalRef.current as HTMLInputElement);\n\n // 내부 로컬 값 (IME 조합 중에도 화면에 표시됨) - text input에만 사용\n const [localValue, setLocalValue] = useState(\n (value as string) ?? (defaultValue as string) ?? ''\n );\n\n // 외부 value prop이 변경되면 로컬 값도 동기화 (text input에만 적용)\n // 단, 사용자가 입력 중(포커스 상태)이면 동기화 건너뜀 (debounce 대기 중 stale value 플리커 방지)\n useEffect(() => {\n if (!isCheckableType && !isComposingRef.current && value !== undefined) {\n const isFocused = internalRef.current && document.activeElement === internalRef.current;\n if (isFocused) {\n return;\n }\n setLocalValue(value as string);\n }\n }, [value, isCheckableType]);\n\n const handleCompositionStart = useCallback(() => {\n isComposingRef.current = true;\n }, []);\n\n const handleCompositionEnd = useCallback(\n (e: React.CompositionEvent) => {\n isComposingRef.current = false;\n // 조합 완료 후 onChange 이벤트 발생\n if (onChange) {\n // 현재 input의 실제 값을 사용\n const currentValue = (e.target as HTMLInputElement).value;\n setLocalValue(currentValue);\n\n // 새로운 ChangeEvent 생성\n const changeEvent = new Event('change', { bubbles: true }) as unknown as React.ChangeEvent;\n Object.defineProperty(changeEvent, 'target', {\n writable: false,\n value: { value: currentValue },\n });\n Object.defineProperty(changeEvent, 'currentTarget', {\n writable: false,\n value: { value: currentValue },\n });\n\n onChange(changeEvent);\n }\n },\n [onChange]\n );\n\n const handleChange = useCallback(\n (e: React.ChangeEvent) => {\n const newValue = e.target.value;\n // 항상 로컬 값은 업데이트 (화면에 입력이 보이도록)\n setLocalValue(newValue);\n\n // IME 조합 중이면 외부 onChange를 호출하지 않음\n if (isComposingRef.current) {\n return;\n }\n if (onChange) {\n onChange(e);\n }\n },\n [onChange]\n );\n\n const handleKeyPress = useCallback(\n (e: React.KeyboardEvent) => {\n // IME 조합 중이면 keypress 무시 (Enter 등)\n if (isComposingRef.current) {\n return;\n }\n if (onKeyPress) {\n onKeyPress(e);\n }\n },\n [onKeyPress]\n );\n\n // HTML 표준 속성이 아닌 것들을 필터링 (템플릿 엔진 내부 속성)\n const { loadingactions, formdata, ...validProps } = props as any;\n\n // radio/checkbox: checked 속성 사용\n // text input: value 속성 사용 (IME 처리 포함)\n if (isCheckableType) {\n return (\n \n );\n }\n\n // text input: controlled/uncontrolled 구분\n const inputProps = value !== undefined\n ? { value: localValue }\n : { defaultValue };\n\n return (\n \n );\n});\n\n// displayName 설정 (React DevTools에서 컴포넌트 이름 표시)\nInput.displayName = 'Input';","// e2e:allow basic Textarea 디폴트 시맨틱(.textarea) 머지 — 외형만 변경, 동작 무변. 다음 사이클에 다른 composite 와 함께 E2E spec 일괄 작성 예정.\nimport React, { useRef, useCallback, useState, useEffect } from 'react';\n\nexport interface TextareaProps extends React.TextareaHTMLAttributes {\n label?: string;\n error?: string;\n}\n\n/**\n * 기본 텍스트 영역 컴포넌트\n *\n * IME(한글 등) 조합 중에는 외부 onChange 이벤트를 발생시키지 않습니다.\n * 내부 로컬 상태를 사용하여 입력을 처리하고, 조합이 완료되면 외부에 알립니다.\n */\nexport const Textarea: React.FC = ({\n label,\n error,\n className = '',\n onChange,\n value,\n defaultValue,\n ...props\n}) => {\n // IME 조합 중인지 추적\n const isComposingRef = useRef(false);\n // textarea 요소 참조\n const textareaRef = useRef(null);\n\n // 내부 로컬 값 (IME 조합 중에도 화면에 표시됨)\n const [localValue, setLocalValue] = useState(\n (value as string) ?? (defaultValue as string) ?? ''\n );\n\n // 외부 value prop이 변경되면 로컬 값도 동기화\n useEffect(() => {\n if (!isComposingRef.current && value !== undefined) {\n setLocalValue(value as string);\n }\n }, [value]);\n\n const handleCompositionStart = useCallback(() => {\n isComposingRef.current = true;\n }, []);\n\n const handleCompositionEnd = useCallback(\n (e: React.CompositionEvent) => {\n isComposingRef.current = false;\n // 조합 완료 후 onChange 이벤트 발생\n if (onChange) {\n // 현재 textarea의 실제 값을 사용\n const currentValue = (e.target as HTMLTextAreaElement).value;\n setLocalValue(currentValue);\n\n // 새로운 ChangeEvent 생성\n const changeEvent = new Event('change', { bubbles: true }) as unknown as React.ChangeEvent;\n Object.defineProperty(changeEvent, 'target', {\n writable: false,\n value: { value: currentValue },\n });\n Object.defineProperty(changeEvent, 'currentTarget', {\n writable: false,\n value: { value: currentValue },\n });\n\n onChange(changeEvent);\n }\n },\n [onChange]\n );\n\n const handleChange = useCallback(\n (e: React.ChangeEvent) => {\n const newValue = e.target.value;\n // 항상 로컬 값은 업데이트 (화면에 입력이 보이도록)\n setLocalValue(newValue);\n\n // IME 조합 중이면 외부 onChange를 호출하지 않음\n if (isComposingRef.current) {\n return;\n }\n if (onChange) {\n onChange(e);\n }\n },\n [onChange]\n );\n\n // HTML 표준 속성이 아닌 것들을 필터링 (템플릿 엔진 내부 속성)\n const { loadingactions, formdata, ...validProps } = props as any;\n\n // controlled/uncontrolled 구분\n const textareaValueProps = value !== undefined\n ? { value: localValue }\n : { defaultValue };\n\n // .textarea 시맨틱(admin main.css)을 디폴트로 머지 → 호출처가 className 미지정해도 표준 외형\n // 호출처가 이미 'textarea' 토큰을 명시한 경우 중복 prepend 방지.\n const textareaTokens = (className ?? '').split(/\\s+/).filter(Boolean);\n const mergedClassName = textareaTokens.includes('textarea')\n ? (className ?? '')\n : ['textarea', className].filter(Boolean).join(' ');\n\n return (\n \n );\n};\n","import React from 'react';\n\nexport interface LabelProps extends React.LabelHTMLAttributes {}\n\n/**\n * 기본 레이블 컴포넌트\n */\nexport const Label: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface DivProps extends React.HTMLAttributes {\n ref?: React.Ref;\n}\n\n/**\n * 기본 div 컴포넌트\n */\nexport const Div = React.forwardRef(({\n children,\n className = '',\n ...props\n}, ref) => {\n return (\n \n {children}\n \n );\n});\n","import React from 'react';\n\nexport interface SpanProps extends React.HTMLAttributes {}\n\n/**\n * 기본 span 컴포넌트\n */\nexport const Span: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface PProps extends React.HTMLAttributes {}\n\n/**\n * 기본 p(단락) 컴포넌트\n */\nexport const P: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n

\n );\n};\n","import React from 'react';\n\nexport interface ImgProps extends React.ImgHTMLAttributes {}\n\n/**\n * 기본 이미지 컴포넌트\n */\nexport const Img: React.FC = ({\n className = '',\n alt = '',\n ...props\n}) => {\n return (\n \n );\n};\n","import React from 'react';\n\nexport interface H1Props extends React.HTMLAttributes {}\n\n/**\n * 기본 h1 제목 컴포넌트\n */\nexport const H1: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface H2Props extends React.HTMLAttributes {}\n\n/**\n * 기본 h2 제목 컴포넌트\n */\nexport const H2: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface H3Props extends React.HTMLAttributes {}\n\n/**\n * 기본 h3 제목 컴포넌트\n */\nexport const H3: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface H4Props extends React.HTMLAttributes {}\n\n/**\n * 기본 h4 제목 컴포넌트\n */\nexport const H4: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface UlProps extends React.HTMLAttributes {}\n\n/**\n * 기본 ul(순서없는 목록) 컴포넌트\n */\nexport const Ul: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface OlProps extends React.HTMLAttributes {}\n\n/**\n * 기본 ol(순서있는 목록) 컴포넌트\n */\nexport const Ol: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface LiProps extends React.LiHTMLAttributes {}\n\n/**\n * 기본 li(목록 항목) 컴포넌트\n */\nexport const Li: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface AProps extends React.AnchorHTMLAttributes {}\n\n/**\n * 기본 a(링크) 컴포넌트\n */\nexport const A: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface FormProps extends React.FormHTMLAttributes {}\n\n/**\n * 기본 form 컴포넌트\n */\nexport const Form: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface SvgProps extends React.SVGAttributes {}\n\n/**\n * 기본 svg 컴포넌트\n */\nexport const Svg: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","// e2e:allow편집기 passthrough 결함#1 수정(커스텀 드롭다운 루트 data-editor-* spread). 라이브 검증은 Chrome MCP T1~T7 매트릭스(에디터 추가/선택/저장 200/reload/게스트 사용자화면)로 수행, 단위 회귀는 Select.test.tsx passthrough describe.\nimport React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Div } from './Div';\nimport { Button } from './Button';\nimport { Span } from './Span';\nimport { Svg } from './Svg';\nimport { Input } from './Input';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:Select')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:Select]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:Select]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:Select]', ...args),\n};\n\nexport interface SelectOption {\n value: string | number;\n label: string;\n disabled?: boolean;\n}\n\nexport interface SelectProps extends Omit, 'onChange'> {\n label?: string;\n error?: string;\n options?: SelectOption[] | string[];\n onChange?: (e: React.ChangeEvent | { target: { value: string | number } }) => void;\n /** 드롭다운 내 검색 input 활성화 (engine-v1.40.0+) */\n searchable?: boolean;\n /** 검색 input placeholder */\n searchPlaceholder?: string;\n /**\n * 레이아웃 편집기 주입 속성 (편집 모드 전용). Select 는 커스텀 드롭다운 루트에\n * 개별 data-editor-* 키를 spread 해야 선택/편집이 닿는다(커스텀 루트\n * passthrough). basic 컴포넌트라 `{...props}` 로 DOM 표준 속성(id 등)도 함께 흐른다.\n */\n editorAttrs?: Record;\n}\n\n/**\n * 로케일 코드를 사람이 읽을 수 있는 이름으로 변환\n */\nfunction getLocaleName(locale: string): string {\n const fromAppConfig = (window as any).G7Core?.state?.get?.('_global.appConfig.localeNames')?.[locale];\n if (fromAppConfig) {\n return fromAppConfig;\n }\n const fallback: Record = {\n ko: '한국어',\n en: 'English',\n ja: '日本語',\n zh: '中文',\n es: 'Español',\n fr: 'Français',\n de: 'Deutsch',\n };\n return fallback[locale] || locale;\n}\n\n/**\n * 커스텀 Select 컴포넌트\n *\n * options prop을 사용하여 드롭다운 메뉴를 생성합니다.\n * 둥근 모서리, 그림자, 체크마크가 있는 커스텀 스타일을 지원합니다.\n */\nexport const Select: React.FC = ({\n children,\n label,\n error,\n options,\n className = '',\n value,\n onChange,\n disabled,\n searchable = false,\n searchPlaceholder,\n editorAttrs,\n ...props\n}) => {\n const [isOpen, setIsOpen] = useState(false);\n const [searchTerm, setSearchTerm] = useState('');\n const [dropdownPos, setDropdownPos] = useState<{ top?: number; bottom?: number; left: number; width: number } | null>(null);\n const containerRef = useRef(null);\n const buttonRef = useRef(null);\n const searchInputRef = useRef(null);\n const dropdownRef = useRef(null);\n\n // options를 SelectOption[] 형식으로 정규화\n const normalizedOptions = useMemo((): SelectOption[] | null => {\n if (!options) return null;\n\n // 배열이 아닌 경우 (바인딩 에러 또는 잘못된 prop)\n if (!Array.isArray(options)) {\n logger.warn('options prop is not an array:', options);\n return null;\n }\n\n // 빈 배열인 경우\n if (options.length === 0) return [];\n\n // string[] 배열인 경우\n if (typeof options[0] === 'string') {\n return (options as string[]).map((locale): SelectOption => ({\n value: locale,\n label: getLocaleName(locale),\n }));\n }\n\n // SelectOption[] 배열인 경우 (기존 동작)\n return options as SelectOption[];\n }, [options]);\n\n // 현재 선택된 옵션의 라벨 가져오기\n const selectedLabel = useMemo(() => {\n if (!normalizedOptions) return '';\n const selected = normalizedOptions.find(opt => String(opt.value) === String(value));\n return selected?.label || '';\n }, [normalizedOptions, value]);\n\n // searchable 모드에서 검색어로 필터링된 옵션\n const visibleOptions = useMemo((): SelectOption[] | null => {\n if (!normalizedOptions) return null;\n if (!searchable || searchTerm.trim() === '') return normalizedOptions;\n const needle = searchTerm.trim().toLowerCase();\n return normalizedOptions.filter(opt =>\n opt.label.toLowerCase().includes(needle) ||\n String(opt.value).toLowerCase().includes(needle)\n );\n }, [normalizedOptions, searchable, searchTerm]);\n\n // 드롭다운 열릴 때 검색어 초기화 및 input 포커스\n useEffect(() => {\n if (isOpen && searchable) {\n setSearchTerm('');\n // 다음 tick에 포커스 (DOM 렌더 이후)\n const timer = setTimeout(() => searchInputRef.current?.focus(), 0);\n return () => clearTimeout(timer);\n }\n if (!isOpen) {\n setSearchTerm('');\n }\n }, [isOpen, searchable]);\n\n /**\n * 드롭다운 위치 계산 (버튼 기준 fixed positioning)\n * Modal 등 overflow:hidden 컨테이너 내부에서도 정상 표시\n */\n const updateDropdownPos = useCallback(() => {\n if (!buttonRef.current) return;\n const rect = buttonRef.current.getBoundingClientRect();\n const spaceBelow = window.innerHeight - rect.bottom;\n const dropdownMaxH = 280; // max-h-60(240px) + search(~40px)\n // 아래 공간 부족 시 위로 열기\n const openUpward = spaceBelow < dropdownMaxH && rect.top > spaceBelow;\n if (openUpward) {\n setDropdownPos({\n bottom: window.innerHeight - rect.top + 4,\n left: rect.left,\n width: rect.width,\n });\n } else {\n setDropdownPos({\n top: rect.bottom + 4,\n left: rect.left,\n width: rect.width,\n });\n }\n }, []);\n\n // 드롭다운 열릴 때 위치 계산 + 스크롤/리사이즈 추적\n useEffect(() => {\n if (!isOpen) {\n setDropdownPos(null);\n return;\n }\n updateDropdownPos();\n window.addEventListener('scroll', updateDropdownPos, true);\n window.addEventListener('resize', updateDropdownPos);\n return () => {\n window.removeEventListener('scroll', updateDropdownPos, true);\n window.removeEventListener('resize', updateDropdownPos);\n };\n }, [isOpen, updateDropdownPos]);\n\n // 외부 클릭 시 드롭다운 닫기 (Portal 영역 포함)\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n const target = event.target as Node;\n if (\n containerRef.current && !containerRef.current.contains(target) &&\n (!dropdownRef.current || !dropdownRef.current.contains(target))\n ) {\n setIsOpen(false);\n }\n };\n\n if (isOpen) {\n document.addEventListener('mousedown', handleClickOutside);\n }\n\n return () => {\n document.removeEventListener('mousedown', handleClickOutside);\n };\n }, [isOpen]);\n\n // ESC 키로 드롭다운 닫기\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && isOpen) {\n setIsOpen(false);\n buttonRef.current?.focus();\n }\n };\n\n document.addEventListener('keydown', handleKeyDown);\n return () => document.removeEventListener('keydown', handleKeyDown);\n }, [isOpen]);\n\n const handleToggle = () => {\n if (!disabled) {\n setIsOpen(!isOpen);\n }\n };\n\n const handleSelect = (optionValue: string | number) => {\n if (onChange) {\n // ActionDispatcher가 이벤트를 인식할 수 있도록 preventDefault 메서드 포함\n const syntheticEvent = {\n target: { value: optionValue },\n preventDefault: () => {},\n stopPropagation: () => {},\n type: 'change',\n };\n onChange(syntheticEvent as any);\n }\n setIsOpen(false);\n buttonRef.current?.focus();\n };\n\n // options가 없으면 기본 select 사용 (children 지원)\n // .select 시맨틱(admin main.css)을 디폴트로 머지 → 호출처가 className 미지정해도 표준 외형\n // 호출처가 이미 'select' 토큰을 명시한 경우 중복 prepend 방지.\n if (!normalizedOptions) {\n const tokens = (className ?? '').split(/\\s+/).filter(Boolean);\n const mergedClassName = tokens.includes('select')\n ? (className ?? '')\n : ['select', className].filter(Boolean).join(' ');\n return (\n }\n disabled={disabled}\n {...editorAttrs}\n {...props}\n >\n {children}\n \n );\n }\n\n // 기본 스타일 (className이 없을 때 적용)\n // variant 접두사가 붙은 유틸(disabled:bg-, dark:bg-, hover:bg- 등)은 베이스 외형이 아니므로\n // hasCustomStyle/hasTextColor 판정에서 제외한다. 베이스 토큰만으로 커스텀 외형 여부를 판단해야\n // 호출처가 disabled:bg-gray-100 만 추가했을 때 기본 크기 클래스(w-full px-4 py-2.5)가 유실되지 않는다.\n const baseTokens = (className ?? '')\n .split(/\\s+/)\n .filter((t) => t && !t.includes(':')) // variant 접두사(`disabled:`, `dark:`, `hover:` 등) 토큰 제외\n .join(' ');\n const hasCustomStyle = baseTokens.includes('bg-');\n // 커스텀 스타일에 텍스트 색상이 없으면 기본 텍스트 색상 보충\n const hasTextColor = /text-(gray|red|blue|green|yellow|white|black|indigo|purple|pink|orange|amber|emerald|teal|cyan|slate)-\\d+|text-white|text-black/.test(baseTokens);\n const baseButtonClass = hasCustomStyle\n ? `${className}${hasTextColor ? '' : ' text-gray-700 dark:text-gray-200'}`\n : `w-full px-4 py-2.5 bg-gray-100 dark:bg-gray-700 border-0 rounded-xl text-sm text-gray-700 dark:text-gray-200 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none ${className}`;\n\n // props 는 SelectHTMLAttributes 기반이라 Div(HTMLDivElement) 에 직접 spread 시 타입 불일치.\n // 런타임 동작(편집기 data-editor-* / id 등 DOM 표준 속성 passthrough)은 동일하므로\n // DOM-safe 레코드로 캐스팅해 커스텀 드롭다운 루트에 전달한다.\n const rootDomProps = { ...editorAttrs, ...props } as Record;\n\n return (\n
\n \n {selectedLabel || '\\u00A0'}\n \n \n \n \n\n {isOpen && dropdownPos && createPortal(\n \n {searchable && (\n
\n ) => setSearchTerm(e.target.value)}\n placeholder={searchPlaceholder ?? 'Search...'}\n className=\"w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 border-0 rounded-lg text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none\"\n role=\"searchbox\"\n aria-label={searchPlaceholder ?? 'Search'}\n />\n
\n )}\n
\n {visibleOptions && visibleOptions.length === 0 && (\n
\n {searchTerm ? 'No results' : ''}\n
\n )}\n {(visibleOptions ?? []).map((option) => {\n const isSelected = String(option.value) === String(value);\n return (\n !option.disabled && handleSelect(option.value)}\n disabled={option.disabled}\n className={`w-full px-4 py-3 text-left text-sm flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${\n isSelected ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-700 dark:text-gray-200'\n } ${option.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}\n role=\"option\"\n aria-selected={isSelected}\n >\n {option.label}\n {isSelected && (\n \n \n \n )}\n \n );\n })}\n
\n
,\n document.body\n )}\n \n );\n};\n","import React from 'react';\n\nexport interface OptionProps extends React.OptionHTMLAttributes {}\n\n/**\n * 기본 option 컴포넌트 (Select 내부에서 사용)\n */\nexport const Option: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface OptgroupProps extends React.OptgroupHTMLAttributes {}\n\n/**\n * 기본 optgroup 컴포넌트 (Select 내부에서 옵션 그룹화)\n */\nexport const Optgroup: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React, { forwardRef, useEffect, useRef } from 'react';\n\nexport interface CheckboxProps extends React.InputHTMLAttributes {\n label?: string;\n /**\n * indeterminate 상태 (부분 선택 상태)\n *\n * 부모-자식 체크박스 연동에서 일부 자식만 선택되었을 때 사용합니다.\n * HTML checkbox의 indeterminate 속성을 JavaScript로 설정합니다.\n */\n indeterminate?: boolean;\n}\n\n/**\n * 기본 체크박스 컴포넌트\n *\n * forwardRef를 사용하여 ref 전달을 지원합니다.\n * indeterminate prop을 통해 부분 선택 상태를 표시할 수 있습니다.\n */\nexport const Checkbox = forwardRef(\n ({ label, className = '', indeterminate = false, ...props }, ref) => {\n const internalRef = useRef(null);\n\n // 외부 ref와 내부 ref를 병합\n const combinedRef = (node: HTMLInputElement | null) => {\n internalRef.current = node;\n if (typeof ref === 'function') {\n ref(node);\n } else if (ref) {\n ref.current = node;\n }\n };\n\n // indeterminate 상태 설정 (DOM 속성이므로 JavaScript로 설정해야 함)\n useEffect(() => {\n if (internalRef.current) {\n internalRef.current.indeterminate = indeterminate;\n }\n }, [indeterminate]);\n\n return (\n \n );\n }\n);\n\nCheckbox.displayName = 'Checkbox';\n","import React from 'react';\n\nexport interface TableProps extends React.TableHTMLAttributes {}\n\n/**\n * 기본 table 컴포넌트\n */\nexport const Table: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface TheadProps extends React.HTMLAttributes {}\n\n/**\n * 기본 thead 컴포넌트\n */\nexport const Thead: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface TbodyProps extends React.HTMLAttributes {}\n\n/**\n * 기본 tbody 컴포넌트\n */\nexport const Tbody: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface TfootProps extends React.HTMLAttributes {}\n\n/**\n * 기본 tfoot 컴포넌트\n */\nexport const Tfoot: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface TrProps extends React.HTMLAttributes {}\n\n/**\n * 기본 tr(테이블 행) 컴포넌트\n */\nexport const Tr: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface ThProps extends React.ThHTMLAttributes {}\n\n/**\n * 기본 th(테이블 헤더 셀) 컴포넌트\n */\nexport const Th: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface TdProps extends React.TdHTMLAttributes {}\n\n/**\n * 기본 td(테이블 데이터 셀) 컴포넌트\n */\nexport const Td: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface NavProps extends React.HTMLAttributes {}\n\n/**\n * 기본 nav 컴포넌트\n */\nexport const Nav: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface SectionProps extends React.HTMLAttributes {}\n\n/**\n * 기본 section 컴포넌트\n */\nexport const Section: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\nimport { IconName, IconStyle, IconSize } from './IconTypes';\n\nexport interface IconProps extends Omit, 'style'> {\n /**\n * 아이콘 이름 (Font Awesome 클래스명 또는 IconName enum)\n *\n * @example\n * // 문자열로 직접 지정\n * \n *\n * // enum 사용 (타입 안전)\n * \n */\n name: string | IconName;\n\n /**\n * 아이콘 스타일 (Font Awesome 5/6)\n *\n * - solid: fas (기본값)\n * - regular: far\n * - light: fal\n * - duotone: fad\n * - brands: fab\n */\n iconStyle?: IconStyle;\n\n /**\n * CSS 스타일 객체 (HTML style 속성)\n */\n style?: React.CSSProperties;\n\n /**\n * 아이콘 크기\n *\n * @default 'md'\n */\n size?: IconSize;\n\n /**\n * 색상 (Tailwind 클래스 또는 CSS 색상)\n */\n color?: string;\n\n /**\n * 회전 애니메이션 활성화\n */\n spin?: boolean;\n\n /**\n * 펄스 애니메이션 활성화 (8단계 회전)\n */\n pulse?: boolean;\n\n /**\n * 고정 너비 (아이콘 정렬 시 유용)\n */\n fixedWidth?: boolean;\n\n /**\n * 접근성 레이블 (자동 생성 가능)\n */\n ariaLabel?: string;\n}\n\n/**\n * Font Awesome 아이콘 컴포넌트\n *\n * Font Awesome 5/6 버전과 호환되며 다양한 props를 지원합니다.\n *\n * @example\n * // 기본 사용\n * \n *\n * // enum 사용 (타입 안전)\n * \n *\n * // 스타일 및 크기 지정\n * \n *\n * // 애니메이션\n * \n * \n *\n * // 색상 지정\n * \n *\n * // 접근성\n * \n */\nexport const Icon: React.FC = ({\n name,\n iconStyle = 'solid',\n size,\n color,\n spin = false,\n pulse = false,\n fixedWidth = false,\n ariaLabel,\n className = '',\n style,\n ...props\n}) => {\n // Font Awesome 스타일 클래스 매핑\n const styleClassMap: Record = {\n solid: 'fas',\n regular: 'far',\n light: 'fal',\n duotone: 'fad',\n brands: 'fab',\n };\n\n // 스타일 접두사를 IconStyle로 변환하는 매핑\n const stylePrefixMap: Record = {\n 'fa-solid': 'solid',\n 'fa-regular': 'regular',\n 'fa-light': 'light',\n 'fa-duotone': 'duotone',\n 'fa-brands': 'brands',\n };\n\n const nameStr = typeof name === 'string' ? name : String(name);\n\n // \"fa-regular fa-pen-to-square\" 형식 파싱\n let resolvedStyle = iconStyle;\n let iconName = nameStr;\n\n // 스타일 접두사가 포함된 경우 파싱 (예: \"fa-regular fa-pen-to-square\")\n for (const [prefix, styleValue] of Object.entries(stylePrefixMap)) {\n if (nameStr.startsWith(prefix + ' ')) {\n resolvedStyle = styleValue;\n iconName = nameStr.substring(prefix.length + 1); // 스타일 접두사와 공백 제거\n break;\n }\n }\n\n // 아이콘 이름에서 'fa-' 접두사 제거 (이미 있으면)\n iconName = iconName.replace(/^fa-/, '');\n\n // 클래스 조합\n const classes = [\n styleClassMap[resolvedStyle],\n `fa-${iconName}`,\n size && `fa-${size}`,\n spin && 'fa-spin',\n pulse && 'fa-pulse',\n fixedWidth && 'fa-fw',\n color,\n className,\n ]\n .filter(Boolean)\n .join(' ');\n\n // 접근성: ariaLabel이 없으면 name에서 생성\n const accessibilityLabel = ariaLabel || iconName.replace(/-/g, ' ');\n\n return (\n \n );\n};\n","import React from 'react';\nimport { Icon } from './Icon';\nimport { IconSize } from './IconTypes';\n\n/**\n * UnifiedIcon 크기 타입\n */\nexport type UnifiedIconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n\n/**\n * UnifiedIcon Props\n */\nexport interface UnifiedIconProps {\n /**\n * 아이콘 지정자\n *\n * @example\n * // Font Awesome (기본)\n * \"fa:cart-shopping\"\n * \"fa-solid fa-cart-shopping\"\n * \"cart-shopping\" // 접두사 없으면 FA 기본\n *\n * // 외부 SVG 파일\n * \"svg:/modules/my-module/assets/icon.svg\"\n *\n * // 이미지 파일\n * \"img:/images/custom-icon.png\"\n */\n icon: string;\n\n /**\n * 아이콘 크기\n * - xs: 12px (w-3 h-3)\n * - sm: 16px (w-4 h-4)\n * - md: 20px (w-5 h-5) - 기본값\n * - lg: 24px (w-6 h-6)\n * - xl: 32px (w-8 h-8)\n *\n * @default 'md'\n */\n size?: UnifiedIconSize;\n\n /**\n * 추가 CSS 클래스\n */\n className?: string;\n\n /**\n * 색상 (Tailwind 클래스)\n *\n * @example\n * \"text-blue-500\"\n * \"text-slate-600 dark:text-slate-300\"\n */\n color?: string;\n\n /**\n * 접근성 레이블\n */\n ariaLabel?: string;\n}\n\n/**\n * 크기별 Tailwind 클래스 매핑\n */\nconst sizeClassMap: Record = {\n xs: 'w-3 h-3', // 12px\n sm: 'w-4 h-4', // 16px\n md: 'w-5 h-5', // 20px\n lg: 'w-6 h-6', // 24px\n xl: 'w-8 h-8', // 32px\n};\n\n/**\n * Icon 컴포넌트용 크기 매핑 (Font Awesome size prop)\n */\nconst iconSizeMap: Record = {\n xs: 'xs',\n sm: 'sm',\n md: undefined, // 기본값\n lg: 'lg',\n xl: '2x',\n};\n\n/**\n * UnifiedIcon 컴포넌트\n *\n * 다양한 아이콘 소스(Font Awesome, SVG, 이미지)를 통합하여 렌더링하는 컴포넌트입니다.\n *\n * @example\n * // Font Awesome 아이콘 (기본)\n * \n * // 접두사 없으면 FA 기본\n *\n * // 외부 SVG 파일\n * \n *\n * // 이미지 파일\n * \n */\nexport const UnifiedIcon: React.FC = ({\n icon,\n size = 'md',\n className = '',\n color,\n ariaLabel,\n}) => {\n // 타입 파싱: \"type:value\" 형식 분리\n const colonIndex = icon.indexOf(':');\n\n // \"fa-solid fa-xxx\" 또는 \"fas fa-xxx\" 형식인 경우 Font Awesome으로 처리\n const isFontAwesomeClass =\n icon.startsWith('fa-') ||\n icon.startsWith('fas ') ||\n icon.startsWith('far ') ||\n icon.startsWith('fab ') ||\n icon.startsWith('fal ') ||\n icon.startsWith('fad ');\n\n let type: string;\n let name: string;\n\n if (isFontAwesomeClass) {\n // Font Awesome 클래스 형식\n type = 'fa';\n name = icon;\n } else if (colonIndex > 0) {\n // \"type:name\" 형식\n type = icon.substring(0, colonIndex);\n name = icon.substring(colonIndex + 1);\n } else {\n // 접두사 없음 = Font Awesome 기본\n type = 'fa';\n name = icon;\n }\n\n const sizeClass = sizeClassMap[size];\n const colorClass = color || '';\n\n switch (type) {\n case 'fa':\n // Font Awesome: Icon 컴포넌트 위임\n return (\n \n );\n\n case 'svg':\n // 외부 SVG 파일 (img 태그로 렌더링)\n return (\n \n );\n\n case 'img':\n // 이미지 파일\n return (\n \n );\n\n default:\n // 기본: Font Awesome\n return (\n \n );\n }\n};\n","import React from 'react';\n\nexport interface CodeProps extends React.HTMLAttributes {}\n\n/**\n * 기본 code 컴포넌트\n *\n * 인라인 코드 또는 코드 스니펫을 표시하기 위한 기본 컴포넌트입니다.\n * HTML 태그를 래핑합니다.\n */\nexport const Code: React.FC = ({\n children,\n className = '',\n ...props\n}) => {\n return (\n \n {children}\n \n );\n};\n","import React from 'react';\n\nexport interface PreProps extends React.HTMLAttributes {}\n\n/**\n * 기본 pre 컴포넌트\n *\n * 서식이 지정된 텍스트를 표시하기 위한 기본 컴포넌트입니다.\n * HTML
 태그를 래핑합니다.\n */\nexport const Pre: React.FC = ({\n  children,\n  className = '',\n  ...props\n}) => {\n  return (\n    \n      {children}\n    
\n );\n};\n","import React from 'react';\n\nexport interface FragmentProps {\n children?: React.ReactNode;\n className?: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Fragment 기본 컴포넌트\n *\n * React.Fragment와 유사하게 DOM 요소를 추가하지 않고 children만 렌더링합니다.\n * iterator와 함께 사용하여 불필요한 wrapper div 없이 반복 렌더링할 때 유용합니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"type\": \"basic\",\n * \"name\": \"Fragment\",\n * \"iterator\": {\n * \"source\": \"{{items}}\",\n * \"item\": \"item\"\n * },\n * \"children\": [\n * {\n * \"type\": \"basic\",\n * \"name\": \"Button\",\n * \"text\": \"{{item.label}}\"\n * }\n * ]\n * }\n */\nexport const Fragment: React.FC = ({ children }) => {\n return <>{children};\n};\n","import React from 'react';\n\nexport interface HrProps extends React.HTMLAttributes {}\n\n/**\n * 기본 hr(수평선) 컴포넌트\n */\nexport const Hr: React.FC = ({\n className = '',\n ...props\n}) => {\n return (\n \n );\n};\n","import React from 'react';\n\nexport interface IProps extends React.HTMLAttributes {\n className?: string;\n children?: React.ReactNode;\n style?: React.CSSProperties;\n}\n\n/**\n * I 기본 컴포넌트\n *\n * HTML 태그를 래핑한 기본 컴포넌트입니다.\n * 주로 아이콘 폰트 (FontAwesome 등)와 함께 사용됩니다.\n *\n * @example\n * ```tsx\n * \n * \n * ```\n */\nexport const I: React.FC = ({ className = '', children, style, ...props }) => {\n return (\n \n {children}\n \n );\n};","/**\n * Font Awesome 아이콘 스타일\n */\nexport type IconStyle = 'solid' | 'regular' | 'light' | 'duotone' | 'brands';\n\n/**\n * Font Awesome 아이콘 크기\n */\nexport type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2x' | '3x' | '4x' | '5x';\n\n/**\n * 자주 사용되는 Font Awesome 아이콘 이름\n *\n * @example\n * // 사용 예시\n * \n * \n * \n */\nexport enum IconName {\n // 사용자 및 계정\n User = 'fa-user',\n Users = 'fa-users',\n UserCircle = 'fa-user-circle',\n UserPlus = 'fa-user-plus',\n UserMinus = 'fa-user-minus',\n UserShield = 'fa-user-shield',\n\n // 액션 아이콘\n Search = 'fa-search',\n Edit = 'fa-edit',\n Pencil = 'fa-pencil-alt',\n Trash = 'fa-trash',\n Save = 'fa-save',\n Plus = 'fa-plus',\n Minus = 'fa-minus',\n Check = 'fa-check',\n Times = 'fa-times',\n Download = 'fa-download',\n Upload = 'fa-upload',\n Copy = 'fa-copy',\n ExternalLink = 'fa-external-link-alt',\n\n // 네비게이션\n Home = 'fa-home',\n CaretRight = 'fa-caret-right',\n CaretDown = 'fa-caret-down',\n ChevronLeft = 'fa-chevron-left',\n ChevronRight = 'fa-chevron-right',\n ChevronUp = 'fa-chevron-up',\n ChevronDown = 'fa-chevron-down',\n AngleLeft = 'fa-angle-left',\n AngleRight = 'fa-angle-right',\n AngleUp = 'fa-angle-up',\n AngleDown = 'fa-angle-down',\n ArrowUp = 'fa-arrow-up',\n ArrowDown = 'fa-arrow-down',\n ArrowLeft = 'fa-arrow-left',\n ArrowRight = 'fa-arrow-right',\n Bars = 'fa-bars',\n EllipsisVertical = 'fa-ellipsis-v',\n EllipsisHorizontal = 'fa-ellipsis-h',\n\n // 상태 아이콘\n CheckCircle = 'fa-check-circle',\n TimesCircle = 'fa-times-circle',\n ExclamationCircle = 'fa-exclamation-circle',\n ExclamationTriangle = 'fa-exclamation-triangle',\n InfoCircle = 'fa-info-circle',\n QuestionCircle = 'fa-question-circle',\n XCircle = 'fa-times-circle',\n\n // 이커머스\n ShoppingCart = 'fa-shopping-cart',\n ShoppingBag = 'fa-shopping-bag',\n CreditCard = 'fa-credit-card',\n Tag = 'fa-tag',\n Tags = 'fa-tags',\n\n // 파일 및 문서\n File = 'fa-file',\n FileAlt = 'fa-file-alt',\n Folder = 'fa-folder',\n FolderOpen = 'fa-folder-open',\n\n // 통신\n Envelope = 'fa-envelope',\n Phone = 'fa-phone',\n Comment = 'fa-comment',\n Comments = 'fa-comments',\n\n // 설정\n Cog = 'fa-cog',\n Cogs = 'fa-cogs',\n Settings = 'fa-cog',\n Wrench = 'fa-wrench',\n Sliders = 'fa-sliders-h',\n\n // 미디어\n Image = 'fa-image',\n Images = 'fa-images',\n Video = 'fa-video',\n Camera = 'fa-camera',\n\n // 소셜\n Heart = 'fa-heart',\n Star = 'fa-star',\n ThumbsUp = 'fa-thumbs-up',\n ThumbsDown = 'fa-thumbs-down',\n Share = 'fa-share',\n\n // 시간\n Clock = 'fa-clock',\n Calendar = 'fa-calendar',\n CalendarAlt = 'fa-calendar-alt',\n\n // 기타\n Lock = 'fa-lock',\n Unlock = 'fa-unlock',\n Eye = 'fa-eye',\n EyeSlash = 'fa-eye-slash',\n Bell = 'fa-bell',\n Flag = 'fa-flag',\n Map = 'fa-map',\n MapMarker = 'fa-map-marker-alt',\n Globe = 'fa-globe',\n Link = 'fa-link',\n Unlink = 'fa-unlink',\n Sun = 'fa-sun',\n Moon = 'fa-moon',\n\n // 차트 및 통계\n ChartBar = 'fa-chart-bar',\n ChartLine = 'fa-chart-line',\n ChartPie = 'fa-chart-pie',\n ChartArea = 'fa-chart-area',\n\n // 스피너 (로딩)\n Spinner = 'fa-spinner',\n CircleNotch = 'fa-circle-notch',\n\n // 배지 아이콘\n CheckBadge = 'fa-badge-check',\n\n // 확장 시스템 (모듈/플러그인/템플릿)\n Cube = 'fa-cube',\n Cubes = 'fa-cubes',\n Plug = 'fa-plug',\n PuzzlePiece = 'fa-puzzle-piece',\n Palette = 'fa-palette',\n}\n\n/**\n * 문자열 아이콘 이름을 IconName enum으로 매핑합니다.\n * JSON 레이아웃에서 문자열로 아이콘 이름을 지정할 때 사용합니다.\n */\nexport const iconNameMap: Record = {\n // 사용자 및 계정\n 'user': IconName.User,\n 'users': IconName.Users,\n 'user-circle': IconName.UserCircle,\n 'user-plus': IconName.UserPlus,\n 'user-minus': IconName.UserMinus,\n 'user-shield': IconName.UserShield,\n\n // 액션 아이콘\n 'search': IconName.Search,\n 'edit': IconName.Edit,\n 'pencil': IconName.Pencil,\n 'trash': IconName.Trash,\n 'save': IconName.Save,\n 'plus': IconName.Plus,\n 'minus': IconName.Minus,\n 'check': IconName.Check,\n 'times': IconName.Times,\n 'download': IconName.Download,\n 'upload': IconName.Upload,\n 'copy': IconName.Copy,\n 'external-link': IconName.ExternalLink,\n\n // 네비게이션\n 'home': IconName.Home,\n 'caret-right': IconName.CaretRight,\n 'caret-down': IconName.CaretDown,\n 'chevron-left': IconName.ChevronLeft,\n 'chevron-right': IconName.ChevronRight,\n 'chevron-up': IconName.ChevronUp,\n 'chevron-down': IconName.ChevronDown,\n 'angle-left': IconName.AngleLeft,\n 'angle-right': IconName.AngleRight,\n 'angle-up': IconName.AngleUp,\n 'angle-down': IconName.AngleDown,\n 'arrow-up': IconName.ArrowUp,\n 'arrow-down': IconName.ArrowDown,\n 'arrow-left': IconName.ArrowLeft,\n 'arrow-right': IconName.ArrowRight,\n 'bars': IconName.Bars,\n 'ellipsis-vertical': IconName.EllipsisVertical,\n 'ellipsis-v': IconName.EllipsisVertical,\n 'ellipsis-horizontal': IconName.EllipsisHorizontal,\n 'ellipsis-h': IconName.EllipsisHorizontal,\n\n // 상태 아이콘\n 'check-circle': IconName.CheckCircle,\n 'times-circle': IconName.TimesCircle,\n 'x-circle': IconName.XCircle,\n 'exclamation-circle': IconName.ExclamationCircle,\n 'exclamation-triangle': IconName.ExclamationTriangle,\n 'info-circle': IconName.InfoCircle,\n 'question-circle': IconName.QuestionCircle,\n\n // 이커머스\n 'shopping-cart': IconName.ShoppingCart,\n 'shopping-bag': IconName.ShoppingBag,\n 'credit-card': IconName.CreditCard,\n 'tag': IconName.Tag,\n 'tags': IconName.Tags,\n\n // 파일 및 문서\n 'file': IconName.File,\n 'file-alt': IconName.FileAlt,\n 'folder': IconName.Folder,\n 'folder-open': IconName.FolderOpen,\n\n // 통신\n 'envelope': IconName.Envelope,\n 'phone': IconName.Phone,\n 'comment': IconName.Comment,\n 'comments': IconName.Comments,\n\n // 설정\n 'cog': IconName.Cog,\n 'cogs': IconName.Cogs,\n 'settings': IconName.Settings,\n 'wrench': IconName.Wrench,\n 'sliders': IconName.Sliders,\n\n // 미디어\n 'image': IconName.Image,\n 'images': IconName.Images,\n 'video': IconName.Video,\n 'camera': IconName.Camera,\n\n // 소셜\n 'heart': IconName.Heart,\n 'star': IconName.Star,\n 'thumbs-up': IconName.ThumbsUp,\n 'thumbs-down': IconName.ThumbsDown,\n 'share': IconName.Share,\n\n // 시간\n 'clock': IconName.Clock,\n 'calendar': IconName.Calendar,\n 'calendar-alt': IconName.CalendarAlt,\n\n // 기타\n 'lock': IconName.Lock,\n 'unlock': IconName.Unlock,\n 'eye': IconName.Eye,\n 'eye-slash': IconName.EyeSlash,\n 'bell': IconName.Bell,\n 'flag': IconName.Flag,\n 'map': IconName.Map,\n 'map-marker': IconName.MapMarker,\n 'globe': IconName.Globe,\n 'link': IconName.Link,\n 'unlink': IconName.Unlink,\n 'sun': IconName.Sun,\n 'moon': IconName.Moon,\n\n // 차트 및 통계\n 'chart-bar': IconName.ChartBar,\n 'chart-line': IconName.ChartLine,\n 'chart-pie': IconName.ChartPie,\n 'chart-area': IconName.ChartArea,\n\n // 스피너 (로딩)\n 'spinner': IconName.Spinner,\n 'circle-notch': IconName.CircleNotch,\n\n // 배지 아이콘\n 'check-badge': IconName.CheckBadge,\n 'badge-check': IconName.CheckBadge,\n\n // 확장 시스템 (모듈/플러그인/템플릿)\n 'cube': IconName.Cube,\n 'cubes': IconName.Cubes,\n 'plug': IconName.Plug,\n 'puzzle-piece': IconName.PuzzlePiece,\n 'palette': IconName.Palette,\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { H2 } from '../basic/H2';\nimport { P } from '../basic/P';\nimport { Img } from '../basic/Img';\nimport type { EditorAttrs } from '../../types';\n\nexport interface CardProps {\n title?: string;\n content?: string;\n imageUrl?: string;\n imageAlt?: string;\n className?: string;\n onClick?: () => void;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * Card 집합 컴포넌트\n *\n * 기본 컴포넌트를 조합하여 카드 UI를 구성합니다.\n * 레이아웃 JSON에서 title, content, imageUrl 등의 props만 전달하면\n * 내부적으로 Div, H2, P, Img 컴포넌트를 조합하여 완성된 카드를 렌더링합니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"Card\",\n * \"props\": {\n * \"title\": \"사용자 정보\",\n * \"content\": \"홍길동 (hong@example.com)\",\n * \"imageUrl\": \"/avatar.png\"\n * }\n * }\n */\nexport const Card: React.FC = ({\n title,\n content,\n imageUrl,\n imageAlt = '',\n className = '',\n onClick,\n style,\n id,\n editorAttrs,\n}) => {\n return (\n \n {imageUrl && (\n
\n \n
\n )}\n\n
\n {title && (\n

\n {title}\n

\n )}\n\n {content && (\n

\n {content}\n

\n )}\n
\n \n );\n};\n","import React, { useState, useRef, useEffect, useCallback } from 'react';\nimport ReactDOM from 'react-dom';\nimport { Button } from '../basic/Button';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\nexport interface ActionMenuItem {\n id: string | number;\n label?: string;\n iconName?: IconName;\n onClick?: () => void;\n disabled?: boolean;\n variant?: 'default' | 'danger';\n /** 구분선 여부 (true면 구분선으로 렌더링) */\n divider?: boolean;\n /** 행 데이터의 필드 경로로 disabled 여부를 결정 (값이 falsy면 disabled) */\n disabledField?: string;\n /** 조건부 표시 (false면 아이템 숨김, 엔진이 표현식 평가 후 boolean 전달) */\n if?: boolean;\n}\n\nexport interface ActionMenuProps {\n items: ActionMenuItem[];\n triggerLabel?: string;\n triggerIconName?: IconName;\n position?: 'left' | 'right';\n className?: string;\n style?: React.CSSProperties;\n /**\n * 커스텀 트리거 콘텐츠\n *\n * children이 제공되면 기본 Button 트리거 대신 children을 클릭 트리거로 사용합니다.\n * 레이아웃 JSON에서 ActionMenu의 children으로 정의한 컴포넌트가 트리거가 됩니다.\n */\n children?: React.ReactNode;\n /**\n * 메뉴 아이템 클릭 시 호출되는 콜백\n * JSON 레이아웃의 actions에서 onItemClick 이벤트로 바인딩됨\n *\n * @param itemId - 클릭된 아이템의 id\n * @param item - 클릭된 아이템 전체 객체\n */\n onItemClick?: (itemId: string | number, item: ActionMenuItem) => void;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * ActionMenu 집합 컴포넌트\n *\n * 드롭다운 형태의 액션 메뉴 컴포넌트입니다.\n * 트리거 버튼과 메뉴 아이템 목록을 제공합니다.\n *\n * 기본 컴포넌트 조합: Button + Div + Span + Icon\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"ActionMenu\",\n * \"props\": {\n * \"triggerLabel\": \"작업\",\n * \"items\": [\n * {\"id\": 1, \"label\": \"수정\", \"iconName\": \"pencil\"},\n * {\"id\": 2, \"label\": \"삭제\", \"iconName\": \"trash\", \"variant\": \"danger\"}\n * ]\n * }\n * }\n */\nexport const ActionMenu: React.FC = ({\n items,\n triggerLabel = '작업',\n triggerIconName = IconName.EllipsisVertical,\n position = 'right',\n className = '',\n style,\n children,\n onItemClick,\n id,\n editorAttrs,\n}) => {\n const [isOpen, setIsOpen] = useState(false);\n const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });\n const triggerRef = useRef(null);\n const menuRef = useRef(null);\n\n // 메뉴 위치 계산\n const updateMenuPosition = useCallback(() => {\n if (!triggerRef.current) return;\n\n const rect = triggerRef.current.getBoundingClientRect();\n const menuWidth = 224; // w-56 = 14rem = 224px\n\n let left = position === 'right' ? rect.right - menuWidth : rect.left;\n const top = rect.bottom + 8; // mt-2 = 8px\n\n // 화면 왼쪽 경계 체크\n if (left < 8) {\n left = 8;\n }\n\n // 화면 오른쪽 경계 체크\n if (left + menuWidth > window.innerWidth - 8) {\n left = window.innerWidth - menuWidth - 8;\n }\n\n setMenuPosition({ top, left });\n }, [position]);\n\n // 메뉴 열릴 때 위치 계산\n useEffect(() => {\n if (isOpen) {\n updateMenuPosition();\n }\n }, [isOpen, updateMenuPosition]);\n\n // 스크롤/리사이즈 시 메뉴 닫기\n useEffect(() => {\n if (!isOpen) return;\n\n const handleScrollOrResize = () => {\n setIsOpen(false);\n };\n\n window.addEventListener('scroll', handleScrollOrResize, true);\n window.addEventListener('resize', handleScrollOrResize);\n\n return () => {\n window.removeEventListener('scroll', handleScrollOrResize, true);\n window.removeEventListener('resize', handleScrollOrResize);\n };\n }, [isOpen]);\n\n // 외부 클릭 감지\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n const target = event.target as Node;\n const isClickInsideTrigger = triggerRef.current?.contains(target);\n const isClickInsideMenu = menuRef.current?.contains(target);\n\n if (!isClickInsideTrigger && !isClickInsideMenu) {\n setIsOpen(false);\n }\n };\n\n if (isOpen) {\n document.addEventListener('mousedown', handleClickOutside);\n }\n\n return () => {\n document.removeEventListener('mousedown', handleClickOutside);\n };\n }, [isOpen]);\n\n const handleItemClick = (item: ActionMenuItem) => {\n if (item.disabled) return;\n\n // 1. 개별 아이템의 onClick 콜백 호출 (우선)\n if (item.onClick) {\n item.onClick();\n }\n\n // 2. 통합 onItemClick 콜백 호출 (JSON 레이아웃 actions 바인딩)\n if (onItemClick) {\n onItemClick(item.id, item);\n }\n\n setIsOpen(false);\n };\n\n // Portal로 렌더링할 드롭다운 메뉴\n const dropdownMenu = isOpen\n ? ReactDOM.createPortal(\n \n {items.filter((item) => item.if !== false).map((item) => {\n // 구분선인 경우\n if (item.divider) {\n return (\n \n );\n }\n\n const variantClasses =\n item.variant === 'danger'\n ? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'\n : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700';\n\n const disabledClasses = item.disabled\n ? 'opacity-50 cursor-not-allowed'\n : 'cursor-pointer';\n\n return (\n handleItemClick(item)}\n className={`flex items-center gap-3 px-4 py-3 transition-colors ${variantClasses} ${disabledClasses}`}\n >\n {item.iconName && (\n \n )}\n {item.label}\n \n );\n })}\n ,\n document.body\n )\n : null;\n\n return (\n
\n {/* 트리거: children이 있으면 커스텀 트리거, 없으면 기본 Button */}\n {children ? (\n }\n onClick={() => setIsOpen(!isOpen)}\n className=\"cursor-pointer\"\n >\n {children}\n
\n ) : (\n }\n onClick={() => setIsOpen(!isOpen)}\n className=\"flex items-center gap-2 px-2 py-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors\"\n >\n {triggerLabel && (\n \n {triggerLabel}\n \n )}\n {triggerIconName && (\n \n )}\n \n )}\n\n {/* Portal로 렌더링되는 드롭다운 메뉴 */}\n {dropdownMenu}\n \n );\n};\n","import React, { useMemo } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { Span } from '../basic/Span';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\nexport interface PaginationProps {\n currentPage: number;\n totalPages: number;\n onPageChange: (page: number) => void;\n maxVisiblePages?: number;\n showFirstLast?: boolean;\n className?: string;\n style?: React.CSSProperties;\n prevText?: string;\n nextText?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * Pagination 집합 컴포넌트\n *\n * Button + Span 기본 컴포넌트를 조합하여 페이지네이션 UI를 구성합니다.\n * 페이지 번호 생성 알고리즘을 포함하며, 많은 페이지 수에 대해 효율적으로 렌더링합니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"Pagination\",\n * \"props\": {\n * \"currentPage\": \"{{pagination.current}}\",\n * \"totalPages\": \"{{pagination.total}}\",\n * \"maxVisiblePages\": 5,\n * \"showFirstLast\": true\n * }\n * }\n */\nexport const Pagination: React.FC = ({\n currentPage,\n totalPages,\n onPageChange,\n maxVisiblePages = 5,\n showFirstLast = true,\n className = '',\n style,\n prevText,\n nextText,\n id,\n editorAttrs,\n}) => {\n\n // 페이지 번호 생성 알고리즘\n const pageNumbers = useMemo(() => {\n const pages: (number | string)[] = [];\n\n if (totalPages <= maxVisiblePages + 2) {\n // 페이지가 적으면 모두 표시\n for (let i = 1; i <= totalPages; i++) {\n pages.push(i);\n }\n } else {\n // 페이지가 많으면 축약 표시\n const halfVisible = Math.floor(maxVisiblePages / 2);\n\n let startPage = Math.max(2, currentPage - halfVisible);\n let endPage = Math.min(totalPages - 1, currentPage + halfVisible);\n\n // 시작 또는 끝 근처일 때 조정\n if (currentPage <= halfVisible + 1) {\n endPage = maxVisiblePages;\n } else if (currentPage >= totalPages - halfVisible) {\n startPage = totalPages - maxVisiblePages + 1;\n }\n\n // 첫 페이지\n pages.push(1);\n\n // 시작 생략 부호\n if (startPage > 2) {\n pages.push('...');\n }\n\n // 중간 페이지들\n for (let i = startPage; i <= endPage; i++) {\n pages.push(i);\n }\n\n // 끝 생략 부호\n if (endPage < totalPages - 1) {\n pages.push('...');\n }\n\n // 마지막 페이지\n if (totalPages > 1) {\n pages.push(totalPages);\n }\n }\n\n return pages;\n }, [currentPage, totalPages, maxVisiblePages]);\n\n const handlePageClick = (page: number) => {\n if (page < 1 || page > totalPages || page === currentPage) return;\n onPageChange(page);\n };\n\n const handlePrevious = () => {\n if (currentPage > 1) {\n onPageChange(currentPage - 1);\n }\n };\n\n const handleNext = () => {\n if (currentPage < totalPages) {\n onPageChange(currentPage + 1);\n }\n };\n\n return (\n \n {/* First Button */}\n {showFirstLast && (\n handlePageClick(1)}\n disabled={currentPage === 1}\n className=\"pagination-btn\"\n aria-label={t('common.first_page')}\n >\n «\n \n )}\n\n {/* Previous Button */}\n \n {prevText || <>‹}\n \n\n {/* Page Numbers */}\n
\n {pageNumbers.map((page, index) => {\n if (page === '...') {\n return (\n \n ...\n \n );\n }\n\n const pageNum = page as number;\n const isActive = pageNum === currentPage;\n\n return (\n handlePageClick(pageNum)}\n className={isActive ? 'pagination-btn-active' : 'pagination-btn'}\n aria-label={t('common.page_n', { n: pageNum })}\n aria-current={isActive ? 'page' : undefined}\n >\n {pageNum}\n \n );\n })}\n
\n\n {/* Next Button */}\n \n {nextText || <>›}\n \n\n {/* Last Button */}\n {showFirstLast && (\n handlePageClick(totalPages)}\n disabled={currentPage === totalPages}\n className=\"pagination-btn\"\n aria-label={t('common.last_page')}\n >\n »\n \n )}\n \n );\n};\n","// e2e:allow 외형 시맨틱 통일 시범 작업 — 동작 변화 없음. 패턴 확정 후 다음 사이클에 E2E spec 일괄 작성 예정.\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';\nimport { Input } from '../basic/Input';\nimport { Textarea } from '../basic/Textarea';\nimport { Button } from '../basic/Button';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n// G7Core에서 지원 언어 목록 가져오기\nconst getSupportedLocales = (): string[] => {\n return (window as any).G7Core?.locale?.supported?.() ?? ['ko', 'en'];\n};\n\n// G7Core에서 현재 언어 가져오기\nconst getCurrentLocale = (): string => {\n return (window as any).G7Core?.locale?.current?.() ?? 'ko';\n};\n\n// 언어 코드로 언어명 가져오기 (다국어 키 사용)\nconst getLocaleNameByCode = (code: string): string => {\n // 다국어 키에서 언어명 가져오기 (common.language_ko, common.language_en 등)\n const translatedName = t(`common.language_${code}`);\n\n // 번역이 키 그대로 반환되면 (번역 없음) fallback 맵 사용\n if (translatedName === `common.language_${code}`) {\n const fallbackNames: Record = {\n ko: '한국어',\n en: 'English',\n ja: '日本語',\n zh: '中文',\n };\n return fallbackNames[code] || code.toUpperCase();\n }\n\n return translatedName;\n};\n\n// 시스템 언어 목록을 LocaleOption[]으로 변환\nconst getSystemLocaleOptions = (): LocaleOption[] => {\n const supportedLocales = getSupportedLocales();\n return supportedLocales.map(code => ({\n code,\n name: getLocaleNameByCode(code),\n nativeName: getLocaleNameByCode(code),\n }));\n};\n\nexport interface MultilingualValue {\n [locale: string]: string;\n}\n\nexport interface LocaleOption {\n code: string;\n name: string;\n nativeName?: string;\n}\n\nexport type MultilingualInputType = 'text' | 'textarea';\nexport type MultilingualInputLayout = 'inline' | 'tabs' | 'compact';\n\nexport interface MultilingualInputProps {\n /** 다국어 값 객체 */\n value?: MultilingualValue;\n /** 값 변경 콜백 */\n onChange?: (event: any) => void;\n /** 입력 타입 (text | textarea) */\n inputType?: MultilingualInputType;\n /** 레이아웃 타입 (inline: 모든 언어 수직 표시 | tabs: 탭 전환 방식 | compact: 탭과 입력이 한 줄) */\n layout?: MultilingualInputLayout;\n /** 사용 가능한 언어 목록 */\n availableLocales?: LocaleOption[];\n /** 기본 언어 코드 */\n defaultLocale?: string;\n /** 입력창 placeholder */\n placeholder?: string;\n /** 필수 입력 여부 */\n required?: boolean;\n /** 비활성화 여부 */\n disabled?: boolean;\n /** 최대 길이 */\n maxLength?: number;\n /** textarea 행 수 (inputType이 'textarea'일 때만 사용) */\n rows?: number;\n /** 추가 className */\n className?: string;\n /** input name */\n name?: string;\n /** 모바일에서 언어 코드로 표시 (기본값: false) */\n showCodeOnMobile?: boolean;\n /** 에러 메시지 (입력 필드에 적색 테두리 표시) */\n error?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * 다국어 입력 컴포넌트\n *\n * 두 가지 레이아웃 모드를 지원합니다:\n * - inline (기본): 카드 형태로 모든 언어를 수직으로 쌓아서 표시\n * - tabs: 탭 방식으로 언어 전환 (카드 형태)\n */\nexport const MultilingualInput: React.FC = ({\n value = {},\n onChange,\n inputType = 'text',\n layout = 'inline',\n availableLocales,\n defaultLocale,\n placeholder = '',\n required = false,\n disabled = false,\n maxLength,\n rows = 4,\n className = '',\n name = 'multilingual_input',\n showCodeOnMobile = false,\n error,\n id,\n editorAttrs,\n}) => {\n\n // 에러 상태 여부\n const hasError = !!error;\n // G7Core에서 실제 사용할 언어 목록과 기본 언어 계산\n const actualAvailableLocales = useMemo(() => {\n return availableLocales ?? getSystemLocaleOptions();\n }, [availableLocales]);\n\n const actualDefaultLocale = useMemo(() => {\n return defaultLocale ?? getCurrentLocale();\n }, [defaultLocale]);\n\n // ── 로컬 상태 버퍼 (debounce 대기 중 사용자 입력값 보존) ──\n // props.value는 debounce가 fire된 후에야 갱신되므로,\n // locale 탭 전환 시 stale props.value로 렌더링되어 입력값이 유실됨.\n // localValue는 사용자 입력을 즉시 반영하고, props.value 갱신 시 동기화함.\n const localValueRef = useRef(value);\n const [localValue, setLocalValue] = useState(value);\n\n // props.value의 이전 내용을 추적 — 내용 기반 비교로 불필요한 동기화 방지\n const lastExternalValueRef = useRef(JSON.stringify(value));\n\n // props.value 외부 변경 시 로컬 상태 동기화\n // 동일 내용의 새 객체 참조(부모 rerender)는 무시하여 로컬 편집분 보존\n useEffect(() => {\n const valueStr = JSON.stringify(value);\n if (valueStr !== lastExternalValueRef.current) {\n lastExternalValueRef.current = valueStr;\n localValueRef.current = value;\n setLocalValue(value);\n }\n }, [value]);\n\n // 공통 onChange 호출 헬퍼\n const emitChange = useCallback((newValue: MultilingualValue, changedKeys?: string[]) => {\n const event: any = {\n target: {\n name,\n value: newValue,\n },\n };\n // _changedKeys: 엔진 디바운스 병합에서 stale closure로 인한 키 유실 방지\n if (changedKeys) {\n event._changedKeys = changedKeys;\n }\n onChange?.(event);\n }, [onChange, name]);\n\n // 활성화된 언어 탭 목록 (기본 언어는 항상 포함)\n const [activeLocales, setActiveLocales] = useState(() => {\n // tabs/compact 레이아웃일 때는 모든 지원 로케일을 표시\n if (layout === 'tabs' || layout === 'compact') {\n const supportedCodes = actualAvailableLocales.map(l => l.code);\n // 기본 언어를 맨 앞에 배치\n const sorted = [actualDefaultLocale, ...supportedCodes.filter(c => c !== actualDefaultLocale)];\n return sorted;\n }\n\n const valueKeys = Object.keys(value).filter(key => value[key] !== undefined);\n\n if (valueKeys.length === 0) {\n return [actualDefaultLocale];\n }\n\n // 기본 언어를 맨 앞에 배치\n const locales: string[] = [];\n\n // 1. 기본 언어가 있으면 맨 앞에 추가\n if (valueKeys.includes(actualDefaultLocale)) {\n locales.push(actualDefaultLocale);\n }\n\n // 2. 나머지 언어들 추가 (기본 언어 제외)\n valueKeys.forEach(key => {\n if (key !== actualDefaultLocale) {\n locales.push(key);\n }\n });\n\n return locales;\n });\n\n // 현재 선택된 언어 탭 (tabs 레이아웃용)\n const [currentLocale, setCurrentLocale] = useState(actualDefaultLocale);\n\n // 추가 가능한 언어 목록 (이미 활성화된 언어 제외)\n const addableLocales = useMemo(() => {\n return actualAvailableLocales.filter(locale => !activeLocales.includes(locale.code));\n }, [actualAvailableLocales, activeLocales]);\n\n // 언어 추가 드롭다운 표시 여부\n const [showAddMenu, setShowAddMenu] = useState(false);\n\n // value prop이 변경될 때 activeLocales 동기화\n useEffect(() => {\n // tabs/compact 레이아웃일 때는 항상 모든 지원 로케일 유지\n if (layout === 'tabs' || layout === 'compact') {\n const supportedCodes = actualAvailableLocales.map(l => l.code);\n const sorted = [actualDefaultLocale, ...supportedCodes.filter(c => c !== actualDefaultLocale)];\n const currentStr = activeLocales.join(',');\n const newStr = sorted.join(',');\n if (currentStr !== newStr) {\n setActiveLocales(sorted);\n }\n return;\n }\n\n const valueKeys = Object.keys(value).filter(key => value[key] !== undefined);\n\n // value가 비어있거나 모든 값이 빈 문자열이면 기본 언어만 표시\n if (valueKeys.length === 0) {\n setActiveLocales([actualDefaultLocale]);\n return;\n }\n\n // value에 있는 언어들로 activeLocales 구성\n // 기본 언어를 맨 앞에 배치\n const newLocales: string[] = [];\n\n // 1. 기본 언어가 있으면 맨 앞에 추가\n if (valueKeys.includes(actualDefaultLocale)) {\n newLocales.push(actualDefaultLocale);\n }\n\n // 2. 나머지 언어들 추가 (기본 언어 제외)\n valueKeys.forEach(key => {\n if (key !== actualDefaultLocale) {\n newLocales.push(key);\n }\n });\n\n // 순서를 유지하면서 비교하여 변경되었을 때만 업데이트\n const currentStr = activeLocales.join(',');\n const newStr = newLocales.join(',');\n\n if (currentStr !== newStr) {\n setActiveLocales(newLocales);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, actualDefaultLocale, layout, actualAvailableLocales]);\n\n // 언어 입력값 변경 핸들러\n // localValueRef 사용: debounce 대기 중에도 최신 입력값 기반으로 병합\n const handleInputChange = useCallback((locale: string, inputValue: string) => {\n const newValue = {\n ...localValueRef.current,\n [locale]: inputValue,\n };\n localValueRef.current = newValue;\n setLocalValue(newValue);\n emitChange(newValue, [locale]);\n }, [emitChange]);\n\n // 언어 탭 추가\n const handleAddLocale = useCallback((localeCode: string) => {\n setActiveLocales(prev => [...prev, localeCode]);\n setCurrentLocale(localeCode);\n setShowAddMenu(false);\n\n // 새 언어 필드를 빈 문자열로 초기화\n const newValue = {\n ...localValueRef.current,\n [localeCode]: '',\n };\n localValueRef.current = newValue;\n setLocalValue(newValue);\n emitChange(newValue);\n }, [emitChange]);\n\n // 언어 탭 제거\n const handleRemoveLocale = useCallback((localeCode: string) => {\n // 기본 언어는 제거 불가\n if (localeCode === actualDefaultLocale) return;\n\n setActiveLocales(prev => prev.filter(code => code !== localeCode));\n\n // 제거된 언어가 현재 선택된 언어면 기본 언어로 전환\n if (currentLocale === localeCode) {\n setCurrentLocale(actualDefaultLocale);\n }\n\n // value에서 해당 언어 제거\n const newValue = { ...localValueRef.current };\n delete newValue[localeCode];\n localValueRef.current = newValue;\n setLocalValue(newValue);\n emitChange(newValue);\n }, [currentLocale, actualDefaultLocale, emitChange]);\n\n // 언어명 표시\n const getLocaleName = useCallback((localeCode: string) => {\n const locale = actualAvailableLocales.find(l => l.code === localeCode);\n return locale?.nativeName || locale?.name || localeCode.toUpperCase();\n }, [actualAvailableLocales]);\n\n // 입력 필드가 채워져 있는지 확인\n const hasValue = useCallback((localeCode: string) => {\n return Boolean(localValue[localeCode]?.trim());\n }, [localValue]);\n\n // 언어 추가 버튼 렌더링 (Pill Badge 스타일)\n const renderAddLocaleButtonCompact = useCallback(() => {\n if (addableLocales.length === 0) return null;\n\n return (\n
\n setShowAddMenu(!showAddMenu)}\n disabled={disabled}\n >\n \n {t('common.add_language')}\n \n\n {showAddMenu && (\n
\n {addableLocales.map(locale => (\n handleAddLocale(locale.code)}\n >\n \n {locale.nativeName || locale.name}\n \n ))}\n
\n )}\n
\n );\n }, [addableLocales, showAddMenu, disabled, handleAddLocale]);\n\n // 입력 필드 렌더링 (라벨 위에 표시) - Pill Badge 스타일\n const renderInputFieldWithTopLabel = useCallback((localeCode: string, showAddButton: boolean = false) => {\n const inputSemanticClassName = hasError ? 'input input-error' : 'input';\n const textareaSemanticClassName = hasError ? 'textarea input-error' : 'textarea';\n const isDefault = localeCode === actualDefaultLocale;\n const localizedPlaceholder = `${placeholder}${placeholder ? ' (' : ''}${getLocaleName(localeCode)}${placeholder ? ')' : ''}`;\n\n return (\n
\n {/* 언어 배지 (Pill Badge 스타일) */}\n
\n \n {/* 다국어 아이콘 */}\n \n {getLocaleName(localeCode)}\n {isDefault && *}\n {/* 비기본 언어 삭제 버튼 - 배지 안에 */}\n {!isDefault && (\n handleRemoveLocale(localeCode)}\n disabled={disabled}\n >\n \n \n )}\n \n {/* 기본 언어 옆에 추가 버튼 */}\n {isDefault && showAddButton && renderAddLocaleButtonCompact()}\n
\n\n {/* 입력 필드 */}\n {inputType === 'textarea' ? (\n handleInputChange(localeCode, e.target.value)}\n placeholder={localizedPlaceholder}\n required={required && isDefault}\n disabled={disabled}\n maxLength={maxLength}\n rows={rows}\n className={`${textareaSemanticClassName} resize-vertical`}\n />\n ) : (\n handleInputChange(localeCode, e.target.value)}\n placeholder={localizedPlaceholder}\n required={required && isDefault}\n disabled={disabled}\n maxLength={maxLength}\n className={inputSemanticClassName}\n />\n )}\n
\n );\n }, [name, localValue, handleInputChange, placeholder, required, disabled, maxLength, rows, inputType, getLocaleName, actualDefaultLocale, handleRemoveLocale, renderAddLocaleButtonCompact, hasError]);\n\n // 입력 필드 렌더링 (라벨 없음 버전 - tabs용)\n const renderInputField = useCallback((localeCode: string) => {\n const inputSemanticClassName = hasError ? 'input input-error' : 'input';\n const textareaSemanticClassName = hasError ? 'textarea input-error' : 'textarea';\n const localizedPlaceholder = `${placeholder}${placeholder ? ' (' : ''}${getLocaleName(localeCode)}${placeholder ? ')' : ''}`;\n\n return (\n
\n {inputType === 'textarea' ? (\n handleInputChange(localeCode, e.target.value)}\n placeholder={localizedPlaceholder}\n required={required && localeCode === actualDefaultLocale}\n disabled={disabled}\n maxLength={maxLength}\n rows={rows}\n className={`${textareaSemanticClassName} resize-vertical`}\n />\n ) : (\n handleInputChange(localeCode, e.target.value)}\n placeholder={localizedPlaceholder}\n required={required && localeCode === actualDefaultLocale}\n disabled={disabled}\n maxLength={maxLength}\n className={inputSemanticClassName}\n />\n )}\n
\n );\n }, [name, localValue, handleInputChange, placeholder, required, disabled, maxLength, rows, inputType, actualDefaultLocale, getLocaleName, hasError]);\n\n // Inline 레이아웃 렌더링\n const renderInlineLayout = () => (\n
\n {/* 입력 필드들 */}\n
\n {activeLocales.map((localeCode, index) =>\n renderInputFieldWithTopLabel(localeCode, index === 0)\n )}\n
\n
\n );\n\n // Tabs 레이아웃 렌더링 - Pill Badge 스타일\n const renderTabsLayout = () => (\n
\n {/* 언어 탭들 + 추가 버튼 (Pill Badge 스타일) */}\n
\n {activeLocales.map(localeCode => (\n setCurrentLocale(localeCode)}\n >\n {/* 다국어 아이콘 */}\n \n {/* showCodeOnMobile: 모바일에서는 언어 코드, 데스크탑에서는 전체 언어명 표시 */}\n {showCodeOnMobile ? (\n <>\n {getLocaleName(localeCode)}\n {localeCode.toUpperCase()}\n \n ) : (\n getLocaleName(localeCode)\n )}\n {localeCode === actualDefaultLocale && (\n *\n )}\n {/* 입력 완료 표시 */}\n {hasValue(localeCode) && currentLocale !== localeCode && (\n \n )}\n {/* 삭제 버튼 (기본 언어 제외) - 배지 안에 */}\n {localeCode !== actualDefaultLocale && (\n {\n e.stopPropagation();\n handleRemoveLocale(localeCode);\n }}\n disabled={disabled}\n >\n \n \n )}\n \n ))}\n\n {/* 언어 추가 버튼 */}\n {renderAddLocaleButtonCompact()}\n
\n\n {/* 입력 필드 */}\n {activeLocales.map(localeCode => (\n \n {renderInputField(localeCode)}\n
\n ))}\n \n );\n\n // Compact 레이아웃 렌더링 - 언어 선택과 입력 필드가 한 줄에 배치\n const renderCompactLayout = () => {\n const inputSemanticClassName = hasError ? 'input input-error' : 'input';\n const textareaSemanticClassName = hasError ? 'textarea input-error' : 'textarea';\n\n return (\n
\n
\n {/* 언어 선택 버튼들 - 컴팩트 스타일 */}\n
\n {activeLocales.map(localeCode => (\n setCurrentLocale(localeCode)}\n title={getLocaleName(localeCode)}\n >\n {localeCode.toUpperCase()}\n {localeCode === actualDefaultLocale && (\n *\n )}\n \n ))}\n
\n\n {/* 입력 필드 - flex-1로 나머지 공간 차지 */}\n
\n {inputType === 'textarea' ? (\n handleInputChange(currentLocale, e.target.value)}\n placeholder={placeholder}\n required={required && currentLocale === actualDefaultLocale}\n disabled={disabled}\n maxLength={maxLength}\n rows={rows}\n className={textareaSemanticClassName}\n />\n ) : (\n handleInputChange(currentLocale, e.target.value)}\n placeholder={placeholder}\n required={required && currentLocale === actualDefaultLocale}\n disabled={disabled}\n maxLength={maxLength}\n className={inputSemanticClassName}\n />\n )}\n
\n
\n
\n );\n };\n\n // 레이아웃에 따라 렌더링\n return (\n
\n {layout === 'compact' ? renderCompactLayout() : layout === 'tabs' ? renderTabsLayout() : renderInlineLayout()}\n {/* 에러 메시지 표시 */}\n {hasError && error && (\n \n {error}\n \n )}\n
\n );\n};\n\nMultilingualInput.displayName = 'MultilingualInput';\n","import React, { useState, useEffect } from 'react';\nimport type { EditorAttrs } from '../../types';\n\n/**\n * G7Core 전역 객체 접근 헬퍼\n * 런타임에 접근하여 테스트 환경에서도 모킹이 적용되도록 함\n */\nconst getG7Core = () => (window as any).G7Core;\n\nexport interface ToggleProps {\n /** 체크 상태 */\n checked?: boolean;\n /** 체크 상태 변경 콜백 (boolean 또는 ChangeEvent 받음) */\n onChange?: ((checked: boolean) => void) | ((e: React.ChangeEvent) => void);\n /** 비활성화 여부 */\n disabled?: boolean;\n /** 라벨 텍스트 */\n label?: string;\n /** 설명 텍스트 */\n description?: string;\n /** 토글 크기 */\n size?: 'sm' | 'md' | 'lg';\n /** 추가 className */\n className?: string;\n /** name 속성 */\n name?: string;\n /** 값 (checked의 대체) */\n value?: boolean;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * Toggle 스위치 컴포넌트\n *\n * Flowbite 스타일의 토글 스위치입니다.\n * 라이트/다크 모드를 모두 지원합니다.\n * 반응형으로 prop 변경을 추적합니다.\n */\nexport const Toggle: React.FC = ({\n checked: checkedProp,\n value: valueProp,\n onChange,\n disabled = false,\n label,\n description,\n size = 'md',\n className = '',\n name,\n id,\n editorAttrs,\n}) => {\n // 내부 상태로 관리\n const [checked, setChecked] = useState(() => checkedProp ?? valueProp ?? false);\n\n // prop 변경 시 내부 상태 동기화\n useEffect(() => {\n const newValue = checkedProp ?? valueProp ?? false;\n setChecked(newValue);\n }, [checkedProp, valueProp]);\n\n const handleChange = (e: React.ChangeEvent) => {\n const newChecked = e.target.checked;\n\n // 내부 상태 즉시 업데이트\n setChecked(newChecked);\n\n if (onChange && !disabled) {\n // 템플릿 엔진의 actions는 이벤트 객체를 받으므로 항상 이벤트로 전달\n (onChange as any)(e);\n }\n };\n\n // 크기별 클래스\n const sizeClasses = {\n sm: {\n track: 'w-9 h-5',\n thumb: 'after:h-4 after:w-4',\n },\n md: {\n track: 'w-11 h-6',\n thumb: 'after:h-5 after:w-5',\n },\n lg: {\n track: 'w-14 h-7',\n thumb: 'after:h-6 after:w-6',\n },\n };\n\n const currentSize = sizeClasses[size];\n\n const handleToggleClick = () => {\n if (!disabled) {\n const newChecked = !checked;\n setChecked(newChecked);\n if (onChange) {\n const G7Core = getG7Core();\n if (G7Core?.createChangeEvent) {\n (onChange as any)(G7Core.createChangeEvent({ checked: newChecked, name }));\n }\n }\n }\n };\n\n return (\n
\n {/* 토글 스위치 */}\n {\n e.preventDefault();\n e.stopPropagation();\n handleToggleClick();\n }}\n >\n \n
\n
\n\n {/* 라벨 (토글 옆) */}\n {label && (\n \n {label}\n \n )}\n\n {/* 디스크립션 (다음 줄) */}\n {description &&

{description}

}\n
\n );\n};\n","import React from 'react';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\nexport type StatusType = 'success' | 'warning' | 'error' | 'info' | 'pending' | 'default';\n\nexport interface StatusBadgeProps {\n status: StatusType;\n label?: string;\n showIcon?: boolean;\n iconName?: IconName;\n className?: string;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * StatusBadge 집합 컴포넌트\n *\n * 상태별 색상 매핑을 지원하는 뱃지 컴포넌트입니다.\n * 상태에 따라 색상과 아이콘이 자동으로 매핑됩니다.\n *\n * 기본 컴포넌트 조합: Span + Icon\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"StatusBadge\",\n * \"props\": {\n * \"status\": \"success\",\n * \"label\": \"완료\",\n * \"showIcon\": true\n * }\n * }\n */\nexport const StatusBadge: React.FC = ({\n status,\n label,\n showIcon = true,\n iconName,\n className = '',\n style,\n id,\n editorAttrs,\n}) => {\n // 상태별 스타일 매핑\n const statusStyles: Record = {\n success: 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',\n warning: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',\n error: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800',\n info: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',\n pending: 'bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',\n default: 'bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-600',\n };\n\n // 상태별 기본 아이콘 매핑\n const statusIcons: Record = {\n success: IconName.CheckCircle,\n warning: IconName.ExclamationTriangle,\n error: IconName.XCircle,\n info: IconName.InfoCircle,\n pending: IconName.Clock,\n default: IconName.QuestionCircle,\n };\n\n // 상태별 기본 라벨 매핑\n const statusLabels: Record = {\n success: '성공',\n warning: '경고',\n error: '오류',\n info: '정보',\n pending: '대기 중',\n default: '기본',\n };\n\n const displayIcon = iconName || statusIcons[status];\n const displayLabel = label || statusLabels[status];\n const styleClasses = statusStyles[status];\n\n return (\n \n {showIcon && (\n \n )}\n {displayLabel}\n \n );\n};\n","import React from 'react';\nimport { Span } from '../basic/Span';\nimport type { EditorAttrs } from '../../types';\n\nexport interface BadgeProps {\n /** 색상명 직접 지정 (blue/green/red/gray/yellow/... ). variant 보다 우선 적용 */\n color?: string;\n /** 의미 기반 변형 (success/warning/danger/info/primary/secondary/text). 백엔드 Enum variant() 출력과 매핑 */\n variant?: string;\n text?: string;\n size?: 'sm' | 'md' | 'lg';\n className?: string;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\nconst colorStyles: Record = {\n blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',\n green: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',\n red: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',\n gray: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',\n yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',\n purple: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',\n orange: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',\n teal: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300',\n cyan: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300',\n};\n\n/**\n * 의미 기반 variant → 색상명 매핑.\n *\n * 백엔드 Enum 의 variant() 메서드(success/warning/danger/info/primary/secondary 등)가\n * 반환하는 semantic 값을 Badge 색상으로 해석한다. StatusBadge 의 색상 규약과 일관.\n */\nconst variantToColor: Record = {\n success: 'green',\n warning: 'yellow',\n danger: 'red',\n error: 'red',\n info: 'blue',\n primary: 'blue',\n secondary: 'gray',\n text: 'gray',\n default: 'gray',\n};\n\nconst sizeStyles: Record = {\n sm: 'px-1.5 py-0.5 text-[10px]',\n md: 'px-2 py-0.5 text-xs',\n lg: 'px-2.5 py-1 text-sm',\n};\n\n/**\n * Badge 집합 컴포넌트\n *\n * 색상 기반의 라벨 뱃지입니다. 상태, 타입 등의 분류를 시각적으로 표현합니다.\n *\n * 기본 컴포넌트 조합: Span\n *\n * @example\n * // 레이아웃 JSON 사용 예시 — 색상 직접 지정\n * {\n * \"name\": \"Badge\",\n * \"props\": { \"color\": \"blue\", \"text\": \"활성\" }\n * }\n * @example\n * // 의미 기반 variant (백엔드 Enum variant() 출력 바인딩)\n * {\n * \"name\": \"Badge\",\n * \"props\": { \"variant\": \"{{row.option_status_variant}}\", \"text\": \"{{row.option_status_label}}\" }\n * }\n */\nexport const Badge: React.FC> = ({\n color,\n variant,\n text,\n size = 'md',\n className = '',\n style,\n children,\n id,\n editorAttrs,\n}) => {\n // 우선순위: color(명시적 색상명) > variant(의미 기반) > gray(기본)\n const resolvedColor = color || (variant ? variantToColor[variant] : undefined) || 'gray';\n const colorClass = colorStyles[resolvedColor] || colorStyles.gray;\n const sizeClass = sizeStyles[size] || sizeStyles.md;\n\n return (\n \n {text}\n {children}\n \n );\n};\n","/*! @license DOMPurify 3.1.7 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.1.7/LICENSE */\n\nconst {\n entries,\n setPrototypeOf,\n isFrozen,\n getPrototypeOf,\n getOwnPropertyDescriptor\n} = Object;\nlet {\n freeze,\n seal,\n create\n} = Object; // eslint-disable-line import/no-mutable-exports\nlet {\n apply,\n construct\n} = typeof Reflect !== 'undefined' && Reflect;\nif (!freeze) {\n freeze = function freeze(x) {\n return x;\n };\n}\nif (!seal) {\n seal = function seal(x) {\n return x;\n };\n}\nif (!apply) {\n apply = function apply(fun, thisValue, args) {\n return fun.apply(thisValue, args);\n };\n}\nif (!construct) {\n construct = function construct(Func, args) {\n return new Func(...args);\n };\n}\nconst arrayForEach = unapply(Array.prototype.forEach);\nconst arrayPop = unapply(Array.prototype.pop);\nconst arrayPush = unapply(Array.prototype.push);\nconst stringToLowerCase = unapply(String.prototype.toLowerCase);\nconst stringToString = unapply(String.prototype.toString);\nconst stringMatch = unapply(String.prototype.match);\nconst stringReplace = unapply(String.prototype.replace);\nconst stringIndexOf = unapply(String.prototype.indexOf);\nconst stringTrim = unapply(String.prototype.trim);\nconst objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty);\nconst regExpTest = unapply(RegExp.prototype.test);\nconst typeErrorCreate = unconstruct(TypeError);\n\n/**\n * Creates a new function that calls the given function with a specified thisArg and arguments.\n *\n * @param {Function} func - The function to be wrapped and called.\n * @returns {Function} A new function that calls the given function with a specified thisArg and arguments.\n */\nfunction unapply(func) {\n return function (thisArg) {\n for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {\n args[_key - 1] = arguments[_key];\n }\n return apply(func, thisArg, args);\n };\n}\n\n/**\n * Creates a new function that constructs an instance of the given constructor function with the provided arguments.\n *\n * @param {Function} func - The constructor function to be wrapped and called.\n * @returns {Function} A new function that constructs an instance of the given constructor function with the provided arguments.\n */\nfunction unconstruct(func) {\n return function () {\n for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {\n args[_key2] = arguments[_key2];\n }\n return construct(func, args);\n };\n}\n\n/**\n * Add properties to a lookup table\n *\n * @param {Object} set - The set to which elements will be added.\n * @param {Array} array - The array containing elements to be added to the set.\n * @param {Function} transformCaseFunc - An optional function to transform the case of each element before adding to the set.\n * @returns {Object} The modified set with added elements.\n */\nfunction addToSet(set, array) {\n let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase;\n if (setPrototypeOf) {\n // Make 'in' and truthy checks like Boolean(set.constructor)\n // independent of any properties defined on Object.prototype.\n // Prevent prototype setters from intercepting set as a this value.\n setPrototypeOf(set, null);\n }\n let l = array.length;\n while (l--) {\n let element = array[l];\n if (typeof element === 'string') {\n const lcElement = transformCaseFunc(element);\n if (lcElement !== element) {\n // Config presets (e.g. tags.js, attrs.js) are immutable.\n if (!isFrozen(array)) {\n array[l] = lcElement;\n }\n element = lcElement;\n }\n }\n set[element] = true;\n }\n return set;\n}\n\n/**\n * Clean up an array to harden against CSPP\n *\n * @param {Array} array - The array to be cleaned.\n * @returns {Array} The cleaned version of the array\n */\nfunction cleanArray(array) {\n for (let index = 0; index < array.length; index++) {\n const isPropertyExist = objectHasOwnProperty(array, index);\n if (!isPropertyExist) {\n array[index] = null;\n }\n }\n return array;\n}\n\n/**\n * Shallow clone an object\n *\n * @param {Object} object - The object to be cloned.\n * @returns {Object} A new object that copies the original.\n */\nfunction clone(object) {\n const newObject = create(null);\n for (const [property, value] of entries(object)) {\n const isPropertyExist = objectHasOwnProperty(object, property);\n if (isPropertyExist) {\n if (Array.isArray(value)) {\n newObject[property] = cleanArray(value);\n } else if (value && typeof value === 'object' && value.constructor === Object) {\n newObject[property] = clone(value);\n } else {\n newObject[property] = value;\n }\n }\n }\n return newObject;\n}\n\n/**\n * This method automatically checks if the prop is function or getter and behaves accordingly.\n *\n * @param {Object} object - The object to look up the getter function in its prototype chain.\n * @param {String} prop - The property name for which to find the getter function.\n * @returns {Function} The getter function found in the prototype chain or a fallback function.\n */\nfunction lookupGetter(object, prop) {\n while (object !== null) {\n const desc = getOwnPropertyDescriptor(object, prop);\n if (desc) {\n if (desc.get) {\n return unapply(desc.get);\n }\n if (typeof desc.value === 'function') {\n return unapply(desc.value);\n }\n }\n object = getPrototypeOf(object);\n }\n function fallbackValue() {\n return null;\n }\n return fallbackValue;\n}\n\nconst html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']);\n\n// SVG\nconst svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']);\nconst svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']);\n\n// List of SVG elements that are disallowed by default.\n// We still need to know them so that we can do namespace\n// checks properly in case one wants to add them to\n// allow-list.\nconst svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']);\nconst mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']);\n\n// Similarly to SVG, we want to know all MathML elements,\n// even those that we disallow by default.\nconst mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']);\nconst text = freeze(['#text']);\n\nconst html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns', 'slot']);\nconst svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']);\nconst mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']);\nconst xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);\n\n// eslint-disable-next-line unicorn/better-regex\nconst MUSTACHE_EXPR = seal(/\\{\\{[\\w\\W]*|[\\w\\W]*\\}\\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode\nconst ERB_EXPR = seal(/<%[\\w\\W]*|[\\w\\W]*%>/gm);\nconst TMPLIT_EXPR = seal(/\\${[\\w\\W]*}/gm);\nconst DATA_ATTR = seal(/^data-[\\-\\w.\\u00B7-\\uFFFF]/); // eslint-disable-line no-useless-escape\nconst ARIA_ATTR = seal(/^aria-[\\-\\w]+$/); // eslint-disable-line no-useless-escape\nconst IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i // eslint-disable-line no-useless-escape\n);\nconst IS_SCRIPT_OR_DATA = seal(/^(?:\\w+script|data):/i);\nconst ATTR_WHITESPACE = seal(/[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]/g // eslint-disable-line no-control-regex\n);\nconst DOCTYPE_NAME = seal(/^html$/i);\nconst CUSTOM_ELEMENT = seal(/^[a-z][.\\w]*(-[.\\w]+)+$/i);\n\nvar EXPRESSIONS = /*#__PURE__*/Object.freeze({\n __proto__: null,\n MUSTACHE_EXPR: MUSTACHE_EXPR,\n ERB_EXPR: ERB_EXPR,\n TMPLIT_EXPR: TMPLIT_EXPR,\n DATA_ATTR: DATA_ATTR,\n ARIA_ATTR: ARIA_ATTR,\n IS_ALLOWED_URI: IS_ALLOWED_URI,\n IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA,\n ATTR_WHITESPACE: ATTR_WHITESPACE,\n DOCTYPE_NAME: DOCTYPE_NAME,\n CUSTOM_ELEMENT: CUSTOM_ELEMENT\n});\n\n// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\nconst NODE_TYPE = {\n element: 1,\n attribute: 2,\n text: 3,\n cdataSection: 4,\n entityReference: 5,\n // Deprecated\n entityNode: 6,\n // Deprecated\n progressingInstruction: 7,\n comment: 8,\n document: 9,\n documentType: 10,\n documentFragment: 11,\n notation: 12 // Deprecated\n};\nconst getGlobal = function getGlobal() {\n return typeof window === 'undefined' ? null : window;\n};\n\n/**\n * Creates a no-op policy for internal use only.\n * Don't export this function outside this module!\n * @param {TrustedTypePolicyFactory} trustedTypes The policy factory.\n * @param {HTMLScriptElement} purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix).\n * @return {TrustedTypePolicy} The policy created (or null, if Trusted Types\n * are not supported or creating the policy failed).\n */\nconst _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) {\n if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') {\n return null;\n }\n\n // Allow the callers to control the unique policy name\n // by adding a data-tt-policy-suffix to the script element with the DOMPurify.\n // Policy creation with duplicate names throws in Trusted Types.\n let suffix = null;\n const ATTR_NAME = 'data-tt-policy-suffix';\n if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) {\n suffix = purifyHostElement.getAttribute(ATTR_NAME);\n }\n const policyName = 'dompurify' + (suffix ? '#' + suffix : '');\n try {\n return trustedTypes.createPolicy(policyName, {\n createHTML(html) {\n return html;\n },\n createScriptURL(scriptUrl) {\n return scriptUrl;\n }\n });\n } catch (_) {\n // Policy creation failed (most likely another DOMPurify script has\n // already run). Skip creating the policy, as this will only cause errors\n // if TT are enforced.\n console.warn('TrustedTypes policy ' + policyName + ' could not be created.');\n return null;\n }\n};\nfunction createDOMPurify() {\n let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();\n const DOMPurify = root => createDOMPurify(root);\n\n /**\n * Version label, exposed for easier checks\n * if DOMPurify is up to date or not\n */\n DOMPurify.version = '3.1.7';\n\n /**\n * Array of elements that DOMPurify removed during sanitation.\n * Empty if nothing was removed.\n */\n DOMPurify.removed = [];\n if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document) {\n // Not running in a browser, provide a factory function\n // so that you can pass your own Window\n DOMPurify.isSupported = false;\n return DOMPurify;\n }\n let {\n document\n } = window;\n const originalDocument = document;\n const currentScript = originalDocument.currentScript;\n const {\n DocumentFragment,\n HTMLTemplateElement,\n Node,\n Element,\n NodeFilter,\n NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap,\n HTMLFormElement,\n DOMParser,\n trustedTypes\n } = window;\n const ElementPrototype = Element.prototype;\n const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');\n const remove = lookupGetter(ElementPrototype, 'remove');\n const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');\n const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');\n const getParentNode = lookupGetter(ElementPrototype, 'parentNode');\n\n // As per issue #47, the web-components registry is inherited by a\n // new document created via createHTMLDocument. As per the spec\n // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)\n // a new empty registry is used when creating a template contents owner\n // document, so we use that as our parent document to ensure nothing\n // is inherited.\n if (typeof HTMLTemplateElement === 'function') {\n const template = document.createElement('template');\n if (template.content && template.content.ownerDocument) {\n document = template.content.ownerDocument;\n }\n }\n let trustedTypesPolicy;\n let emptyHTML = '';\n const {\n implementation,\n createNodeIterator,\n createDocumentFragment,\n getElementsByTagName\n } = document;\n const {\n importNode\n } = originalDocument;\n let hooks = {};\n\n /**\n * Expose whether this browser supports running the full DOMPurify.\n */\n DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined;\n const {\n MUSTACHE_EXPR,\n ERB_EXPR,\n TMPLIT_EXPR,\n DATA_ATTR,\n ARIA_ATTR,\n IS_SCRIPT_OR_DATA,\n ATTR_WHITESPACE,\n CUSTOM_ELEMENT\n } = EXPRESSIONS;\n let {\n IS_ALLOWED_URI: IS_ALLOWED_URI$1\n } = EXPRESSIONS;\n\n /**\n * We consider the elements and attributes below to be safe. Ideally\n * don't add any new ones but feel free to remove unwanted ones.\n */\n\n /* allowed element names */\n let ALLOWED_TAGS = null;\n const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]);\n\n /* Allowed attribute names */\n let ALLOWED_ATTR = null;\n const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]);\n\n /*\n * Configure how DOMPUrify should handle custom elements and their attributes as well as customized built-in elements.\n * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements)\n * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list)\n * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`.\n */\n let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, {\n tagNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null\n },\n attributeNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null\n },\n allowCustomizedBuiltInElements: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: false\n }\n }));\n\n /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */\n let FORBID_TAGS = null;\n\n /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */\n let FORBID_ATTR = null;\n\n /* Decide if ARIA attributes are okay */\n let ALLOW_ARIA_ATTR = true;\n\n /* Decide if custom data attributes are okay */\n let ALLOW_DATA_ATTR = true;\n\n /* Decide if unknown protocols are okay */\n let ALLOW_UNKNOWN_PROTOCOLS = false;\n\n /* Decide if self-closing tags in attributes are allowed.\n * Usually removed due to a mXSS issue in jQuery 3.0 */\n let ALLOW_SELF_CLOSE_IN_ATTR = true;\n\n /* Output should be safe for common template engines.\n * This means, DOMPurify removes data attributes, mustaches and ERB\n */\n let SAFE_FOR_TEMPLATES = false;\n\n /* Output should be safe even for XML used within HTML and alike.\n * This means, DOMPurify removes comments when containing risky content.\n */\n let SAFE_FOR_XML = true;\n\n /* Decide if document with ... should be returned */\n let WHOLE_DOCUMENT = false;\n\n /* Track whether config is already set on this instance of DOMPurify. */\n let SET_CONFIG = false;\n\n /* Decide if all elements (e.g. style, script) must be children of\n * document.body. By default, browsers might move them to document.head */\n let FORCE_BODY = false;\n\n /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported).\n * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead\n */\n let RETURN_DOM = false;\n\n /* Decide if a DOM `DocumentFragment` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported) */\n let RETURN_DOM_FRAGMENT = false;\n\n /* Try to return a Trusted Type object instead of a string, return a string in\n * case Trusted Types are not supported */\n let RETURN_TRUSTED_TYPE = false;\n\n /* Output should be free from DOM clobbering attacks?\n * This sanitizes markups named with colliding, clobberable built-in DOM APIs.\n */\n let SANITIZE_DOM = true;\n\n /* Achieve full DOM Clobbering protection by isolating the namespace of named\n * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules.\n *\n * HTML/DOM spec rules that enable DOM Clobbering:\n * - Named Access on Window (§7.3.3)\n * - DOM Tree Accessors (§3.1.5)\n * - Form Element Parent-Child Relations (§4.10.3)\n * - Iframe srcdoc / Nested WindowProxies (§4.8.5)\n * - HTMLCollection (§4.2.10.2)\n *\n * Namespace isolation is implemented by prefixing `id` and `name` attributes\n * with a constant string, i.e., `user-content-`\n */\n let SANITIZE_NAMED_PROPS = false;\n const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-';\n\n /* Keep element content when removing element? */\n let KEEP_CONTENT = true;\n\n /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead\n * of importing it into a new Document and returning a sanitized copy */\n let IN_PLACE = false;\n\n /* Allow usage of profiles like html, svg and mathMl */\n let USE_PROFILES = {};\n\n /* Tags to ignore content of when KEEP_CONTENT is true */\n let FORBID_CONTENTS = null;\n const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);\n\n /* Tags that are safe for data: URIs */\n let DATA_URI_TAGS = null;\n const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']);\n\n /* Attributes safe for values like \"javascript:\" */\n let URI_SAFE_ATTRIBUTES = null;\n const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']);\n const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';\n const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';\n const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';\n /* Document namespace */\n let NAMESPACE = HTML_NAMESPACE;\n let IS_EMPTY_INPUT = false;\n\n /* Allowed XHTML+XML namespaces */\n let ALLOWED_NAMESPACES = null;\n const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString);\n\n /* Parsing of strict XHTML documents */\n let PARSER_MEDIA_TYPE = null;\n const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html'];\n const DEFAULT_PARSER_MEDIA_TYPE = 'text/html';\n let transformCaseFunc = null;\n\n /* Keep a reference to config to pass to hooks */\n let CONFIG = null;\n\n /* Ideally, do not touch anything below this line */\n /* ______________________________________________ */\n\n const formElement = document.createElement('form');\n const isRegexOrFunction = function isRegexOrFunction(testValue) {\n return testValue instanceof RegExp || testValue instanceof Function;\n };\n\n /**\n * _parseConfig\n *\n * @param {Object} cfg optional config literal\n */\n // eslint-disable-next-line complexity\n const _parseConfig = function _parseConfig() {\n let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n if (CONFIG && CONFIG === cfg) {\n return;\n }\n\n /* Shield configuration object from tampering */\n if (!cfg || typeof cfg !== 'object') {\n cfg = {};\n }\n\n /* Shield configuration object from prototype pollution */\n cfg = clone(cfg);\n PARSER_MEDIA_TYPE =\n // eslint-disable-next-line unicorn/prefer-includes\n SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE;\n\n // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is.\n transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase;\n\n /* Set configuration parameters */\n ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS;\n ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR;\n ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES;\n URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES),\n // eslint-disable-line indent\n cfg.ADD_URI_SAFE_ATTR,\n // eslint-disable-line indent\n transformCaseFunc // eslint-disable-line indent\n ) // eslint-disable-line indent\n : DEFAULT_URI_SAFE_ATTRIBUTES;\n DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS),\n // eslint-disable-line indent\n cfg.ADD_DATA_URI_TAGS,\n // eslint-disable-line indent\n transformCaseFunc // eslint-disable-line indent\n ) // eslint-disable-line indent\n : DEFAULT_DATA_URI_TAGS;\n FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS;\n FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {};\n FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {};\n USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false;\n ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true\n ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true\n ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false\n ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true\n SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false\n SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true\n WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false\n RETURN_DOM = cfg.RETURN_DOM || false; // Default false\n RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false\n RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false\n FORCE_BODY = cfg.FORCE_BODY || false; // Default false\n SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true\n SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false\n KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true\n IN_PLACE = cfg.IN_PLACE || false; // Default false\n IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI;\n NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE;\n CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {};\n if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) {\n CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck;\n }\n if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) {\n CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck;\n }\n if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') {\n CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements;\n }\n if (SAFE_FOR_TEMPLATES) {\n ALLOW_DATA_ATTR = false;\n }\n if (RETURN_DOM_FRAGMENT) {\n RETURN_DOM = true;\n }\n\n /* Parse profile info */\n if (USE_PROFILES) {\n ALLOWED_TAGS = addToSet({}, text);\n ALLOWED_ATTR = [];\n if (USE_PROFILES.html === true) {\n addToSet(ALLOWED_TAGS, html$1);\n addToSet(ALLOWED_ATTR, html);\n }\n if (USE_PROFILES.svg === true) {\n addToSet(ALLOWED_TAGS, svg$1);\n addToSet(ALLOWED_ATTR, svg);\n addToSet(ALLOWED_ATTR, xml);\n }\n if (USE_PROFILES.svgFilters === true) {\n addToSet(ALLOWED_TAGS, svgFilters);\n addToSet(ALLOWED_ATTR, svg);\n addToSet(ALLOWED_ATTR, xml);\n }\n if (USE_PROFILES.mathMl === true) {\n addToSet(ALLOWED_TAGS, mathMl$1);\n addToSet(ALLOWED_ATTR, mathMl);\n addToSet(ALLOWED_ATTR, xml);\n }\n }\n\n /* Merge configuration parameters */\n if (cfg.ADD_TAGS) {\n if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {\n ALLOWED_TAGS = clone(ALLOWED_TAGS);\n }\n addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);\n }\n if (cfg.ADD_ATTR) {\n if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) {\n ALLOWED_ATTR = clone(ALLOWED_ATTR);\n }\n addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);\n }\n if (cfg.ADD_URI_SAFE_ATTR) {\n addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);\n }\n if (cfg.FORBID_CONTENTS) {\n if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {\n FORBID_CONTENTS = clone(FORBID_CONTENTS);\n }\n addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);\n }\n\n /* Add #text in case KEEP_CONTENT is set to true */\n if (KEEP_CONTENT) {\n ALLOWED_TAGS['#text'] = true;\n }\n\n /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */\n if (WHOLE_DOCUMENT) {\n addToSet(ALLOWED_TAGS, ['html', 'head', 'body']);\n }\n\n /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */\n if (ALLOWED_TAGS.table) {\n addToSet(ALLOWED_TAGS, ['tbody']);\n delete FORBID_TAGS.tbody;\n }\n if (cfg.TRUSTED_TYPES_POLICY) {\n if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {\n throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a \"createHTML\" hook.');\n }\n if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') {\n throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a \"createScriptURL\" hook.');\n }\n\n // Overwrite existing TrustedTypes policy.\n trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;\n\n // Sign local variables required by `sanitize`.\n emptyHTML = trustedTypesPolicy.createHTML('');\n } else {\n // Uninitialized policy, attempt to initialize the internal dompurify policy.\n if (trustedTypesPolicy === undefined) {\n trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);\n }\n\n // If creating the internal policy succeeded sign internal variables.\n if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') {\n emptyHTML = trustedTypesPolicy.createHTML('');\n }\n }\n\n // Prevent further manipulation of configuration.\n // Not available in IE8, Safari 5, etc.\n if (freeze) {\n freeze(cfg);\n }\n CONFIG = cfg;\n };\n const MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']);\n const HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']);\n\n // Certain elements are allowed in both SVG and HTML\n // namespace. We need to specify them explicitly\n // so that they don't get erroneously deleted from\n // HTML namespace.\n const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']);\n\n /* Keep track of all possible SVG and MathML tags\n * so that we can perform the namespace checks\n * correctly. */\n const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]);\n const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]);\n\n /**\n * @param {Element} element a DOM element whose namespace is being checked\n * @returns {boolean} Return false if the element has a\n * namespace that a spec-compliant parser would never\n * return. Return true otherwise.\n */\n const _checkValidNamespace = function _checkValidNamespace(element) {\n let parent = getParentNode(element);\n\n // In JSDOM, if we're inside shadow DOM, then parentNode\n // can be null. We just simulate parent in this case.\n if (!parent || !parent.tagName) {\n parent = {\n namespaceURI: NAMESPACE,\n tagName: 'template'\n };\n }\n const tagName = stringToLowerCase(element.tagName);\n const parentTagName = stringToLowerCase(parent.tagName);\n if (!ALLOWED_NAMESPACES[element.namespaceURI]) {\n return false;\n }\n if (element.namespaceURI === SVG_NAMESPACE) {\n // The only way to switch from HTML namespace to SVG\n // is via . If it happens via any other tag, then\n // it should be killed.\n if (parent.namespaceURI === HTML_NAMESPACE) {\n return tagName === 'svg';\n }\n\n // The only way to switch from MathML to SVG is via`\n // svg if parent is either or MathML\n // text integration points.\n if (parent.namespaceURI === MATHML_NAMESPACE) {\n return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]);\n }\n\n // We only allow elements that are defined in SVG\n // spec. All others are disallowed in SVG namespace.\n return Boolean(ALL_SVG_TAGS[tagName]);\n }\n if (element.namespaceURI === MATHML_NAMESPACE) {\n // The only way to switch from HTML namespace to MathML\n // is via . If it happens via any other tag, then\n // it should be killed.\n if (parent.namespaceURI === HTML_NAMESPACE) {\n return tagName === 'math';\n }\n\n // The only way to switch from SVG to MathML is via\n // and HTML integration points\n if (parent.namespaceURI === SVG_NAMESPACE) {\n return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName];\n }\n\n // We only allow elements that are defined in MathML\n // spec. All others are disallowed in MathML namespace.\n return Boolean(ALL_MATHML_TAGS[tagName]);\n }\n if (element.namespaceURI === HTML_NAMESPACE) {\n // The only way to switch from SVG to HTML is via\n // HTML integration points, and from MathML to HTML\n // is via MathML text integration points\n if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) {\n return false;\n }\n if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) {\n return false;\n }\n\n // We disallow tags that are specific for MathML\n // or SVG and should never appear in HTML namespace\n return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]);\n }\n\n // For XHTML and XML documents that support custom namespaces\n if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) {\n return true;\n }\n\n // The code should never reach this place (this means\n // that the element somehow got namespace that is not\n // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES).\n // Return false just in case.\n return false;\n };\n\n /**\n * _forceRemove\n *\n * @param {Node} node a DOM node\n */\n const _forceRemove = function _forceRemove(node) {\n arrayPush(DOMPurify.removed, {\n element: node\n });\n try {\n // eslint-disable-next-line unicorn/prefer-dom-node-remove\n getParentNode(node).removeChild(node);\n } catch (_) {\n remove(node);\n }\n };\n\n /**\n * _removeAttribute\n *\n * @param {String} name an Attribute name\n * @param {Node} node a DOM node\n */\n const _removeAttribute = function _removeAttribute(name, node) {\n try {\n arrayPush(DOMPurify.removed, {\n attribute: node.getAttributeNode(name),\n from: node\n });\n } catch (_) {\n arrayPush(DOMPurify.removed, {\n attribute: null,\n from: node\n });\n }\n node.removeAttribute(name);\n\n // We void attribute values for unremovable \"is\"\" attributes\n if (name === 'is' && !ALLOWED_ATTR[name]) {\n if (RETURN_DOM || RETURN_DOM_FRAGMENT) {\n try {\n _forceRemove(node);\n } catch (_) {}\n } else {\n try {\n node.setAttribute(name, '');\n } catch (_) {}\n }\n }\n };\n\n /**\n * _initDocument\n *\n * @param {String} dirty a string of dirty markup\n * @return {Document} a DOM, filled with the dirty markup\n */\n const _initDocument = function _initDocument(dirty) {\n /* Create a HTML document */\n let doc = null;\n let leadingWhitespace = null;\n if (FORCE_BODY) {\n dirty = '' + dirty;\n } else {\n /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */\n const matches = stringMatch(dirty, /^[\\r\\n\\t ]+/);\n leadingWhitespace = matches && matches[0];\n }\n if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && NAMESPACE === HTML_NAMESPACE) {\n // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict)\n dirty = '' + dirty + '';\n }\n const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty;\n /*\n * Use the DOMParser API by default, fallback later if needs be\n * DOMParser not work for svg when has multiple root element.\n */\n if (NAMESPACE === HTML_NAMESPACE) {\n try {\n doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE);\n } catch (_) {}\n }\n\n /* Use createHTMLDocument in case DOMParser is not available */\n if (!doc || !doc.documentElement) {\n doc = implementation.createDocument(NAMESPACE, 'template', null);\n try {\n doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload;\n } catch (_) {\n // Syntax error if dirtyPayload is invalid xml\n }\n }\n const body = doc.body || doc.documentElement;\n if (dirty && leadingWhitespace) {\n body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null);\n }\n\n /* Work on whole document or just its body */\n if (NAMESPACE === HTML_NAMESPACE) {\n return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0];\n }\n return WHOLE_DOCUMENT ? doc.documentElement : body;\n };\n\n /**\n * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document.\n *\n * @param {Node} root The root element or node to start traversing on.\n * @return {NodeIterator} The created NodeIterator\n */\n const _createNodeIterator = function _createNodeIterator(root) {\n return createNodeIterator.call(root.ownerDocument || root, root,\n // eslint-disable-next-line no-bitwise\n NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null);\n };\n\n /**\n * _isClobbered\n *\n * @param {Node} elm element to check for clobbering attacks\n * @return {Boolean} true if clobbered, false if safe\n */\n const _isClobbered = function _isClobbered(elm) {\n return elm instanceof HTMLFormElement && (typeof elm.nodeName !== 'string' || typeof elm.textContent !== 'string' || typeof elm.removeChild !== 'function' || !(elm.attributes instanceof NamedNodeMap) || typeof elm.removeAttribute !== 'function' || typeof elm.setAttribute !== 'function' || typeof elm.namespaceURI !== 'string' || typeof elm.insertBefore !== 'function' || typeof elm.hasChildNodes !== 'function');\n };\n\n /**\n * Checks whether the given object is a DOM node.\n *\n * @param {Node} object object to check whether it's a DOM node\n * @return {Boolean} true is object is a DOM node\n */\n const _isNode = function _isNode(object) {\n return typeof Node === 'function' && object instanceof Node;\n };\n\n /**\n * _executeHook\n * Execute user configurable hooks\n *\n * @param {String} entryPoint Name of the hook's entry point\n * @param {Node} currentNode node to work on with the hook\n * @param {Object} data additional hook parameters\n */\n const _executeHook = function _executeHook(entryPoint, currentNode, data) {\n if (!hooks[entryPoint]) {\n return;\n }\n arrayForEach(hooks[entryPoint], hook => {\n hook.call(DOMPurify, currentNode, data, CONFIG);\n });\n };\n\n /**\n * _sanitizeElements\n *\n * @protect nodeName\n * @protect textContent\n * @protect removeChild\n *\n * @param {Node} currentNode to check for permission to exist\n * @return {Boolean} true if node was killed, false if left alive\n */\n const _sanitizeElements = function _sanitizeElements(currentNode) {\n let content = null;\n\n /* Execute a hook if present */\n _executeHook('beforeSanitizeElements', currentNode, null);\n\n /* Check if element is clobbered or can clobber */\n if (_isClobbered(currentNode)) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Now let's check the element's type and name */\n const tagName = transformCaseFunc(currentNode.nodeName);\n\n /* Execute a hook if present */\n _executeHook('uponSanitizeElement', currentNode, {\n tagName,\n allowedTags: ALLOWED_TAGS\n });\n\n /* Detect mXSS attempts abusing namespace confusion */\n if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\\w]/g, currentNode.innerHTML) && regExpTest(/<[/\\w]/g, currentNode.textContent)) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Remove any occurrence of processing instructions */\n if (currentNode.nodeType === NODE_TYPE.progressingInstruction) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Remove any kind of possibly harmful comments */\n if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(/<[/\\w]/g, currentNode.data)) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Remove element if anything forbids its presence */\n if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {\n /* Check if we have a custom element to handle */\n if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {\n if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {\n return false;\n }\n if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) {\n return false;\n }\n }\n\n /* Keep content except for bad-listed elements */\n if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {\n const parentNode = getParentNode(currentNode) || currentNode.parentNode;\n const childNodes = getChildNodes(currentNode) || currentNode.childNodes;\n if (childNodes && parentNode) {\n const childCount = childNodes.length;\n for (let i = childCount - 1; i >= 0; --i) {\n const childClone = cloneNode(childNodes[i], true);\n childClone.__removalCount = (currentNode.__removalCount || 0) + 1;\n parentNode.insertBefore(childClone, getNextSibling(currentNode));\n }\n }\n }\n _forceRemove(currentNode);\n return true;\n }\n\n /* Check whether element has a valid namespace */\n if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Make sure that older browsers don't get fallback-tag mXSS */\n if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\\/no(script|embed|frames)/i, currentNode.innerHTML)) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Sanitize element content to be template-safe */\n if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) {\n /* Get the element's text content */\n content = currentNode.textContent;\n arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => {\n content = stringReplace(content, expr, ' ');\n });\n if (currentNode.textContent !== content) {\n arrayPush(DOMPurify.removed, {\n element: currentNode.cloneNode()\n });\n currentNode.textContent = content;\n }\n }\n\n /* Execute a hook if present */\n _executeHook('afterSanitizeElements', currentNode, null);\n return false;\n };\n\n /**\n * _isValidAttribute\n *\n * @param {string} lcTag Lowercase tag name of containing element.\n * @param {string} lcName Lowercase attribute name.\n * @param {string} value Attribute value.\n * @return {Boolean} Returns true if `value` is valid, otherwise false.\n */\n // eslint-disable-next-line complexity\n const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) {\n /* Make sure attribute cannot clobber */\n if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) {\n return false;\n }\n\n /* Allow valid data-* attributes: At least one character after \"-\"\n (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)\n XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)\n We don't need to check the value; it's always URI safe. */\n if (ALLOW_DATA_ATTR && !FORBID_ATTR[lcName] && regExpTest(DATA_ATTR, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) ; else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) {\n if (\n // First condition does a very basic check if a) it's basically a valid custom element tagname AND\n // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck\n // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck\n _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) ||\n // Alternative, second condition checks if it's an `is`-attribute, AND\n // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck\n lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ; else {\n return false;\n }\n /* Check value is safe. First, is attr inert? If so, is safe */\n } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if (value) {\n return false;\n } else ;\n return true;\n };\n\n /**\n * _isBasicCustomElement\n * checks if at least one dash is included in tagName, and it's not the first char\n * for more sophisticated checking see https://github.com/sindresorhus/validate-element-name\n *\n * @param {string} tagName name of the tag of the node to sanitize\n * @returns {boolean} Returns true if the tag name meets the basic criteria for a custom element, otherwise false.\n */\n const _isBasicCustomElement = function _isBasicCustomElement(tagName) {\n return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT);\n };\n\n /**\n * _sanitizeAttributes\n *\n * @protect attributes\n * @protect nodeName\n * @protect removeAttribute\n * @protect setAttribute\n *\n * @param {Node} currentNode to sanitize\n */\n const _sanitizeAttributes = function _sanitizeAttributes(currentNode) {\n /* Execute a hook if present */\n _executeHook('beforeSanitizeAttributes', currentNode, null);\n const {\n attributes\n } = currentNode;\n\n /* Check if we have attributes; if not we might have a text node */\n if (!attributes) {\n return;\n }\n const hookEvent = {\n attrName: '',\n attrValue: '',\n keepAttr: true,\n allowedAttributes: ALLOWED_ATTR\n };\n let l = attributes.length;\n\n /* Go backwards over all attributes; safely remove bad ones */\n while (l--) {\n const attr = attributes[l];\n const {\n name,\n namespaceURI,\n value: attrValue\n } = attr;\n const lcName = transformCaseFunc(name);\n let value = name === 'value' ? attrValue : stringTrim(attrValue);\n\n /* Execute a hook if present */\n hookEvent.attrName = lcName;\n hookEvent.attrValue = value;\n hookEvent.keepAttr = true;\n hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set\n _executeHook('uponSanitizeAttribute', currentNode, hookEvent);\n value = hookEvent.attrValue;\n\n /* Did the hooks approve of the attribute? */\n if (hookEvent.forceKeepAttr) {\n continue;\n }\n\n /* Remove attribute */\n _removeAttribute(name, currentNode);\n\n /* Did the hooks approve of the attribute? */\n if (!hookEvent.keepAttr) {\n continue;\n }\n\n /* Work around a security issue in jQuery 3.0 */\n if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\\/>/i, value)) {\n _removeAttribute(name, currentNode);\n continue;\n }\n\n /* Sanitize attribute content to be template-safe */\n if (SAFE_FOR_TEMPLATES) {\n arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => {\n value = stringReplace(value, expr, ' ');\n });\n }\n\n /* Is `value` valid for this attribute? */\n const lcTag = transformCaseFunc(currentNode.nodeName);\n if (!_isValidAttribute(lcTag, lcName, value)) {\n continue;\n }\n\n /* Full DOM Clobbering protection via namespace isolation,\n * Prefix id and name attributes with `user-content-`\n */\n if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) {\n // Remove the attribute with this value\n _removeAttribute(name, currentNode);\n\n // Prefix the value and later re-create the attribute with the sanitized value\n value = SANITIZE_NAMED_PROPS_PREFIX + value;\n }\n\n /* Work around a security issue with comments inside attributes */\n if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\\/(style|title)/i, value)) {\n _removeAttribute(name, currentNode);\n continue;\n }\n\n /* Handle attributes that require Trusted Types */\n if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function') {\n if (namespaceURI) ; else {\n switch (trustedTypes.getAttributeType(lcTag, lcName)) {\n case 'TrustedHTML':\n {\n value = trustedTypesPolicy.createHTML(value);\n break;\n }\n case 'TrustedScriptURL':\n {\n value = trustedTypesPolicy.createScriptURL(value);\n break;\n }\n }\n }\n }\n\n /* Handle invalid data-* attribute set by try-catching it */\n try {\n if (namespaceURI) {\n currentNode.setAttributeNS(namespaceURI, name, value);\n } else {\n /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. \"x-schema\". */\n currentNode.setAttribute(name, value);\n }\n if (_isClobbered(currentNode)) {\n _forceRemove(currentNode);\n } else {\n arrayPop(DOMPurify.removed);\n }\n } catch (_) {}\n }\n\n /* Execute a hook if present */\n _executeHook('afterSanitizeAttributes', currentNode, null);\n };\n\n /**\n * _sanitizeShadowDOM\n *\n * @param {DocumentFragment} fragment to iterate over recursively\n */\n const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) {\n let shadowNode = null;\n const shadowIterator = _createNodeIterator(fragment);\n\n /* Execute a hook if present */\n _executeHook('beforeSanitizeShadowDOM', fragment, null);\n while (shadowNode = shadowIterator.nextNode()) {\n /* Execute a hook if present */\n _executeHook('uponSanitizeShadowNode', shadowNode, null);\n\n /* Sanitize tags and elements */\n if (_sanitizeElements(shadowNode)) {\n continue;\n }\n\n /* Deep shadow DOM detected */\n if (shadowNode.content instanceof DocumentFragment) {\n _sanitizeShadowDOM(shadowNode.content);\n }\n\n /* Check attributes, sanitize if necessary */\n _sanitizeAttributes(shadowNode);\n }\n\n /* Execute a hook if present */\n _executeHook('afterSanitizeShadowDOM', fragment, null);\n };\n\n /**\n * Sanitize\n * Public method providing core sanitation functionality\n *\n * @param {String|Node} dirty string or DOM node\n * @param {Object} cfg object\n */\n // eslint-disable-next-line complexity\n DOMPurify.sanitize = function (dirty) {\n let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};\n let body = null;\n let importedNode = null;\n let currentNode = null;\n let returnNode = null;\n /* Make sure we have a string to sanitize.\n DO NOT return early, as this will return the wrong type if\n the user has requested a DOM object rather than a string */\n IS_EMPTY_INPUT = !dirty;\n if (IS_EMPTY_INPUT) {\n dirty = '';\n }\n\n /* Stringify, in case dirty is an object */\n if (typeof dirty !== 'string' && !_isNode(dirty)) {\n if (typeof dirty.toString === 'function') {\n dirty = dirty.toString();\n if (typeof dirty !== 'string') {\n throw typeErrorCreate('dirty is not a string, aborting');\n }\n } else {\n throw typeErrorCreate('toString is not a function');\n }\n }\n\n /* Return dirty HTML if DOMPurify cannot run */\n if (!DOMPurify.isSupported) {\n return dirty;\n }\n\n /* Assign config vars */\n if (!SET_CONFIG) {\n _parseConfig(cfg);\n }\n\n /* Clean up removed elements */\n DOMPurify.removed = [];\n\n /* Check if dirty is correctly typed for IN_PLACE */\n if (typeof dirty === 'string') {\n IN_PLACE = false;\n }\n if (IN_PLACE) {\n /* Do some early pre-sanitization to avoid unsafe root nodes */\n if (dirty.nodeName) {\n const tagName = transformCaseFunc(dirty.nodeName);\n if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {\n throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');\n }\n }\n } else if (dirty instanceof Node) {\n /* If dirty is a DOM element, append to an empty document to avoid\n elements being stripped by the parser */\n body = _initDocument('');\n importedNode = body.ownerDocument.importNode(dirty, true);\n if (importedNode.nodeType === NODE_TYPE.element && importedNode.nodeName === 'BODY') {\n /* Node is already a body, use as is */\n body = importedNode;\n } else if (importedNode.nodeName === 'HTML') {\n body = importedNode;\n } else {\n // eslint-disable-next-line unicorn/prefer-dom-node-append\n body.appendChild(importedNode);\n }\n } else {\n /* Exit directly if we have nothing to do */\n if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT &&\n // eslint-disable-next-line unicorn/prefer-includes\n dirty.indexOf('<') === -1) {\n return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty;\n }\n\n /* Initialize the document to work on */\n body = _initDocument(dirty);\n\n /* Check we have a DOM node from the data */\n if (!body) {\n return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : '';\n }\n }\n\n /* Remove first element node (ours) if FORCE_BODY is set */\n if (body && FORCE_BODY) {\n _forceRemove(body.firstChild);\n }\n\n /* Get node iterator */\n const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);\n\n /* Now start iterating over the created document */\n while (currentNode = nodeIterator.nextNode()) {\n /* Sanitize tags and elements */\n if (_sanitizeElements(currentNode)) {\n continue;\n }\n\n /* Shadow DOM detected, sanitize it */\n if (currentNode.content instanceof DocumentFragment) {\n _sanitizeShadowDOM(currentNode.content);\n }\n\n /* Check attributes, sanitize if necessary */\n _sanitizeAttributes(currentNode);\n }\n\n /* If we sanitized `dirty` in-place, return it. */\n if (IN_PLACE) {\n return dirty;\n }\n\n /* Return sanitized string or DOM */\n if (RETURN_DOM) {\n if (RETURN_DOM_FRAGMENT) {\n returnNode = createDocumentFragment.call(body.ownerDocument);\n while (body.firstChild) {\n // eslint-disable-next-line unicorn/prefer-dom-node-append\n returnNode.appendChild(body.firstChild);\n }\n } else {\n returnNode = body;\n }\n if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) {\n /*\n AdoptNode() is not used because internal state is not reset\n (e.g. the past names map of a HTMLFormElement), this is safe\n in theory but we would rather not risk another attack vector.\n The state that is cloned by importNode() is explicitly defined\n by the specs.\n */\n returnNode = importNode.call(originalDocument, returnNode, true);\n }\n return returnNode;\n }\n let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML;\n\n /* Serialize doctype if allowed */\n if (WHOLE_DOCUMENT && ALLOWED_TAGS['!doctype'] && body.ownerDocument && body.ownerDocument.doctype && body.ownerDocument.doctype.name && regExpTest(DOCTYPE_NAME, body.ownerDocument.doctype.name)) {\n serializedHTML = '\\n' + serializedHTML;\n }\n\n /* Sanitize final string template-safe */\n if (SAFE_FOR_TEMPLATES) {\n arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => {\n serializedHTML = stringReplace(serializedHTML, expr, ' ');\n });\n }\n return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML;\n };\n\n /**\n * Public method to set the configuration once\n * setConfig\n *\n * @param {Object} cfg configuration object\n */\n DOMPurify.setConfig = function () {\n let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n _parseConfig(cfg);\n SET_CONFIG = true;\n };\n\n /**\n * Public method to remove the configuration\n * clearConfig\n *\n */\n DOMPurify.clearConfig = function () {\n CONFIG = null;\n SET_CONFIG = false;\n };\n\n /**\n * Public method to check if an attribute value is valid.\n * Uses last set config, if any. Otherwise, uses config defaults.\n * isValidAttribute\n *\n * @param {String} tag Tag name of containing element.\n * @param {String} attr Attribute name.\n * @param {String} value Attribute value.\n * @return {Boolean} Returns true if `value` is valid. Otherwise, returns false.\n */\n DOMPurify.isValidAttribute = function (tag, attr, value) {\n /* Initialize shared config vars if necessary. */\n if (!CONFIG) {\n _parseConfig({});\n }\n const lcTag = transformCaseFunc(tag);\n const lcName = transformCaseFunc(attr);\n return _isValidAttribute(lcTag, lcName, value);\n };\n\n /**\n * AddHook\n * Public method to add DOMPurify hooks\n *\n * @param {String} entryPoint entry point for the hook to add\n * @param {Function} hookFunction function to execute\n */\n DOMPurify.addHook = function (entryPoint, hookFunction) {\n if (typeof hookFunction !== 'function') {\n return;\n }\n hooks[entryPoint] = hooks[entryPoint] || [];\n arrayPush(hooks[entryPoint], hookFunction);\n };\n\n /**\n * RemoveHook\n * Public method to remove a DOMPurify hook at a given entryPoint\n * (pops it from the stack of hooks if more are present)\n *\n * @param {String} entryPoint entry point for the hook to remove\n * @return {Function} removed(popped) hook\n */\n DOMPurify.removeHook = function (entryPoint) {\n if (hooks[entryPoint]) {\n return arrayPop(hooks[entryPoint]);\n }\n };\n\n /**\n * RemoveHooks\n * Public method to remove all DOMPurify hooks at a given entryPoint\n *\n * @param {String} entryPoint entry point for the hooks to remove\n */\n DOMPurify.removeHooks = function (entryPoint) {\n if (hooks[entryPoint]) {\n hooks[entryPoint] = [];\n }\n };\n\n /**\n * RemoveAllHooks\n * Public method to remove all DOMPurify hooks\n */\n DOMPurify.removeAllHooks = function () {\n hooks = {};\n };\n return DOMPurify;\n}\nvar purify = createDOMPurify();\n\nexport { purify as default };\n//# sourceMappingURL=purify.es.mjs.map\n","import React, { useMemo } from 'react';\n// @ts-ignore - DOMPurify 타입 정의 없음\nimport DOMPurify from 'dompurify';\nimport { Div } from '../basic/Div';\nimport type { EditorAttrs } from '../../types';\n\nexport interface HtmlContentProps {\n /**\n * 콘텐츠 (HTML 또는 일반 텍스트)\n */\n content?: string;\n\n /**\n * 콘텐츠가 HTML 형식인지 여부\n * - true: HTML 렌더링 (DOMPurify 적용, prose 스타일)\n * - false: 일반 텍스트 (whitespace-pre-wrap으로 줄바꿈 보존)\n * @default true\n */\n isHtml?: boolean;\n\n /**\n * 사용자 정의 클래스\n */\n className?: string;\n\n /**\n * DOMPurify 설정 오버라이드 (isHtml=true일 때만 사용)\n */\n purifyConfig?: any;\n\n /**\n * 레이아웃 JSON에서 text 속성으로 전달되는 콘텐츠\n * content보다 우선순위가 높음\n */\n text?: string;\n\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * HtmlContent 콘텐츠 렌더링 컴포넌트\n *\n * HTML과 일반 텍스트를 안전하게 렌더링하는 범용 composite 컴포넌트입니다.\n * - isHtml=true: DOMPurify를 사용하여 XSS 공격 방지\n * - isHtml=false: 일반 텍스트로 렌더링 (줄바꿈 보존)\n *\n * @example\n * // HTML 렌더링 (기본값)\n * 안녕하세요

\" />\n *\n * // 일반 텍스트 렌더링\n * \n *\n * // 커스텀 클래스 적용\n * 게시글 내용

\"\n * className=\"prose dark:prose-invert\"\n * />\n *\n * // DOMPurify 설정 커스터마이징 (HTML 모드)\n * 내용

\"\n * purifyConfig={{ ALLOWED_TAGS: ['p', 'br', 'strong', 'em'] }}\n * />\n */\nexport const HtmlContent: React.FC = ({\n content,\n text,\n isHtml = true,\n className = '',\n purifyConfig,\n id,\n editorAttrs,\n}) => {\n // text prop이 우선순위가 높음 (레이아웃 JSON에서 사용)\n const actualContent = text ?? content ?? '';\n\n // 빈 값 처리\n if (!actualContent || actualContent.trim() === '') {\n return null;\n }\n\n // isHtml=false: 일반 텍스트 렌더링\n if (!isHtml) {\n const textClasses = `\n whitespace-pre-wrap\n text-gray-900 dark:text-gray-100\n font-sans\n ${className}\n `.trim().replace(/\\s+/g, ' ');\n\n return (\n
\n {actualContent}\n
\n );\n }\n\n // 기본 DOMPurify 설정 (FORBID 방식: 위험 태그/속성만 차단, 나머지 허용)\n const defaultConfig: any = {\n FORBID_TAGS: [\n // 스크립트 실행\n 'script', 'noscript',\n // 외부 콘텐츠 삽입\n 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'portal',\n // 폼 요소 (피싱 방지)\n 'form', 'input', 'textarea', 'select', 'button',\n // 메타/스타일 조작\n 'style', 'link', 'meta', 'base',\n // 구조 태그 주입\n 'body', 'head', 'html', 'title',\n // SVG/MathML (XSS 벡터)\n 'svg', 'math',\n // 미디어 이벤트 핸들러 (onerror, onloadstart)\n 'audio', 'video', 'source', 'track', 'canvas',\n // UI 오버레이/이벤트\n 'details', 'dialog',\n // HTML 파서 혼란/sanitizer 우회\n 'plaintext', 'xmp', 'listing',\n // 레거시/비표준\n 'marquee', 'noframes', 'noembed', 'template', 'slot',\n ],\n FORBID_ATTR: [\n 'onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur',\n 'ontoggle', 'oncanplay', 'onloadstart',\n 'formaction', 'xlink:href', 'action',\n ],\n ALLOW_DATA_ATTR: true,\n ADD_ATTR: ['target'],\n };\n\n // sanitize된 HTML을 메모이제이션\n const sanitizedHtml = useMemo(() => {\n const config = purifyConfig\n ? {\n ...defaultConfig,\n ...purifyConfig,\n // 보안 기본값은 항상 유지 (사용자 설정으로 덮어쓰기 방지)\n FORBID_TAGS: [...defaultConfig.FORBID_TAGS, ...(purifyConfig.FORBID_TAGS || [])],\n FORBID_ATTR: [...defaultConfig.FORBID_ATTR, ...(purifyConfig.FORBID_ATTR || [])],\n }\n : defaultConfig;\n const cleaned = DOMPurify.sanitize(actualContent, config) as unknown as string;\n\n // target=\"_blank\" 링크에 rel 속성 추가 (보안)\n return cleaned.replace(\n /]*?)href=[\"']([^\"']+)[\"']([^>]*?)>/gi,\n (match: string, before: string, href: string, after: string) => {\n if (href.startsWith('http://') || href.startsWith('https://')) {\n if (!match.includes('rel=')) {\n return ``;\n }\n }\n return match;\n }\n );\n }, [actualContent, purifyConfig]);\n\n // 컨테이너 클래스 조합\n const containerClasses = `\n prose dark:prose-invert max-w-none\n prose-p:my-2\n prose-headings:font-bold prose-headings:mt-6 prose-headings:mb-4\n prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg\n prose-a:text-blue-600 dark:prose-a:text-blue-400\n prose-a:no-underline hover:prose-a:underline\n prose-blockquote:border-l-4 prose-blockquote:border-gray-300 dark:prose-blockquote:border-gray-600\n prose-blockquote:pl-4 prose-blockquote:italic\n prose-code:bg-gray-100 dark:prose-code:bg-gray-800\n prose-code:px-1 prose-code:py-0.5 prose-code:rounded\n prose-pre:bg-gray-100 dark:prose-pre:bg-gray-800\n prose-pre:p-4 prose-pre:rounded-lg prose-pre:overflow-x-auto\n [&_table]:w-full [&_table]:border-collapse [&_table]:border [&_table]:border-gray-300\n dark:[&_table]:border-gray-600\n [&_th]:border [&_th]:border-gray-300 [&_th]:px-4 [&_th]:py-2 [&_th]:bg-gray-100 [&_th]:text-left\n dark:[&_th]:border-gray-600 dark:[&_th]:bg-gray-800\n [&_td]:border [&_td]:border-gray-300 [&_td]:px-4 [&_td]:py-2\n dark:[&_td]:border-gray-600\n [&_tr:hover]:bg-gray-50 dark:[&_tr:hover]:bg-gray-700/50\n after:content-[''] after:block after:clear-both\n ${className}\n `.trim().replace(/\\s+/g, ' ');\n\n return (\n \n );\n};\n","import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';\nimport type { EditorAttrs } from '../../types';\nimport { Table } from '../basic/Table';\nimport { Thead } from '../basic/Thead';\nimport { Tbody } from '../basic/Tbody';\nimport { Tfoot } from '../basic/Tfoot';\nimport { Tr } from '../basic/Tr';\nimport { Th } from '../basic/Th';\nimport { Td } from '../basic/Td';\nimport { Button } from '../basic/Button';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport { Checkbox } from '../basic/Checkbox';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { ActionMenu, ActionMenuItem } from './ActionMenu';\nimport { Pagination } from './Pagination';\nimport { P } from '../basic/P';\nimport { A } from '../basic/A';\nimport { Label } from '../basic/Label';\nimport { Input } from '../basic/Input';\nimport { H1 } from '../basic/H1';\nimport { H2 } from '../basic/H2';\nimport { H3 } from '../basic/H3';\nimport { H4 } from '../basic/H4';\nimport { Img } from '../basic/Img';\nimport { Select } from '../basic/Select';\nimport { Textarea } from '../basic/Textarea';\nimport { MultilingualInput } from './MultilingualInput';\nimport { Toggle } from './Toggle';\nimport { StatusBadge } from './StatusBadge';\nimport { Badge } from './Badge';\nimport { HtmlContent } from './HtmlContent';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:DataGrid')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:DataGrid]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:DataGrid]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:DataGrid]', ...args),\n};\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n// cellChildren 및 expandChildren 렌더링을 위한 컴포넌트 맵\nconst componentMap: Record> = {\n // basic 컴포넌트\n Div,\n Span,\n Button,\n Icon,\n Checkbox,\n P,\n A,\n Label,\n Input,\n H1,\n H2,\n H3,\n H4,\n Img,\n Select,\n Textarea,\n // Table 관련 컴포넌트 (expandChildren에서 SubGrid 렌더링용)\n Table,\n Thead,\n Tbody,\n Tfoot,\n Tr,\n Th,\n Td,\n // composite 컴포넌트\n MultilingualInput,\n Toggle,\n StatusBadge,\n Badge,\n HtmlContent,\n ActionMenu,\n};\n\nexport interface DataGridCellChild {\n id?: string;\n type: 'basic' | 'composite';\n name: string;\n props?: Record;\n text?: string;\n children?: DataGridCellChild[];\n condition?: string;\n}\n\nexport interface DataGridColumn {\n field: string;\n header: string;\n width?: string;\n sortable?: boolean;\n hidden?: boolean;\n required?: boolean;\n render?: (value: any, row: any) => React.ReactNode;\n cellChildren?: DataGridCellChild[];\n}\n\n/**\n * 확장 행(Expandable Row)에 렌더링할 자식 컴포넌트 정의\n * 레이아웃 JSON에서 사용됩니다.\n */\nexport interface DataGridExpandChildren {\n id?: string;\n type: 'basic' | 'composite';\n name: string;\n props?: Record;\n text?: string;\n children?: DataGridExpandChildren[];\n iteration?: {\n source: string;\n item_var?: string;\n index_var?: string;\n };\n}\n\n/**\n * 서브 행(Sub Row)에 렌더링할 자식 컴포넌트 정의 (v1.6.0+)\n *\n * 각 데이터 행 아래에 추가 정보를 표시하는 병합된 행입니다.\n * expandable row와 달리 항상 표시되거나 조건부로 표시됩니다.\n * 모든 컬럼이 병합된 단일 셀로 렌더링됩니다.\n *\n * @example\n * // 배송비 요약 표시\n * {\n * \"subRowChildren\": [\n * {\n * \"type\": \"basic\",\n * \"name\": \"Span\",\n * \"props\": { \"className\": \"text-sm text-gray-500\" },\n * \"text\": \"{{row.fee_summary}}\"\n * }\n * ],\n * \"subRowCondition\": \"{{row.charge_policy !== 'free'}}\"\n * }\n */\nexport interface DataGridSubRowChildren {\n id?: string;\n type: 'basic' | 'composite';\n name: string;\n props?: Record;\n text?: string;\n children?: DataGridSubRowChildren[];\n condition?: string;\n}\n\n/**\n * 푸터 행(Footer Row)의 각 셀 정의 (v1.19.0+)\n *\n * 테이블 하단에 합계/요약 행을 표시합니다.\n * 각 셀은 특정 컬럼의 field에 매핑되거나, colSpan으로 여러 컬럼을 병합합니다.\n */\nexport interface DataGridFooterCell {\n /** 매핑할 컬럼 field명 (이 필드의 컬럼 위치에 셀 배치) */\n field?: string;\n /** 병합할 컬럼 수 (field 미지정 시 사용) */\n colSpan?: number;\n /** 셀 CSS 클래스 */\n className?: string;\n /** 셀 내부에 렌더링할 컴포넌트 (cellChildren과 동일 구조) */\n children?: DataGridCellChild[];\n /** 단순 텍스트 (children 미사용 시) */\n text?: string;\n}\n\nexport interface DataGridProps {\n /** DOM id (transition_overlay.target / overlay_target 으로 spinner mount 영역 지정용) */\n id?: string;\n columns: DataGridColumn[];\n data: any[];\n sortable?: boolean;\n pagination?: boolean;\n pageSize?: number;\n onRowClick?: (row: any) => void;\n className?: string;\n style?: React.CSSProperties;\n\n // 정렬 기능 (외부 제어)\n sortField?: string | null;\n sortDirection?: 'asc' | 'desc';\n onSortChange?: (field: string, direction: 'asc' | 'desc') => void;\n\n // 체크박스 선택 기능\n selectable?: boolean;\n selectedIds?: (string | number)[];\n onSelectionChange?: (ids: (string | number)[]) => void;\n idField?: string;\n\n // 컬럼 표시/숨김 기능\n showColumnSelector?: boolean;\n visibleColumns?: string[];\n onColumnVisibilityChange?: (fields: string[]) => void;\n\n // 반응형 카드 뷰\n responsiveBreakpoint?: number;\n cardRenderer?: (row: any, columns: DataGridColumn[]) => React.ReactNode;\n\n // 행 액션 메뉴\n rowActions?: ActionMenuItem[];\n onRowAction?: (actionId: string | number, row: any) => void;\n\n // 페이지네이션 항상 표시 옵션\n alwaysShowPagination?: boolean;\n\n // 서버 사이드 페이지네이션 (API에서 페이지네이션 처리 시)\n serverSidePagination?: boolean;\n serverCurrentPage?: number;\n serverTotalPages?: number;\n onPageChange?: (page: number) => void;\n\n // 다국어 지원 텍스트 (레이아웃 JSON에서 $t: 문법으로 전달)\n prevText?: string;\n nextText?: string;\n columnSelectorText?: string;\n emptyMessage?: string;\n actionsColumnHeader?: string;\n requiredText?: string;\n selectedCountText?: string;\n loadErrorMessage?: string;\n\n // 페이지네이션 옵션\n showFirstLast?: boolean;\n\n // 확장 행 (Expandable Row) 기능 - v1.3.0+\n expandable?: boolean;\n expandedRowIds?: (string | number)[];\n onExpandChange?: (ids: (string | number)[]) => void;\n expandChildren?: DataGridExpandChildren[];\n expandedRowRender?: (row: any) => React.ReactNode;\n expandColumnWidth?: string;\n expandIconExpanded?: IconName;\n expandIconCollapsed?: IconName;\n /** 행 단위 조건부 확장 필드명 — 해당 필드가 truthy인 행만 확장 가능 */\n expandConditionField?: string;\n\n /**\n * 동적 컬럼 배열 (v1.4.0+)\n *\n * 기존 columns 배열에 동적으로 병합될 컬럼들입니다.\n * insertAfterField가 지정된 경우 해당 필드 뒤에 삽입되고,\n * 그렇지 않으면 columns 배열 끝에 추가됩니다.\n *\n * 모듈 핸들러의 resultTo 패턴과 함께 사용하여\n * 환경설정 기반 동적 컬럼을 구현할 수 있습니다.\n *\n * @example\n * // 레이아웃 JSON에서 init_action으로 동적 컬럼 생성\n * {\n * \"handler\": \"sirsoft-ecommerce.buildProductColumns\",\n * \"params\": { ... },\n * \"resultTo\": { \"target\": \"_local\", \"key\": \"dynamicCols\" }\n * }\n *\n * // DataGrid에서 동적 컬럼 사용\n * {\n * \"name\": \"DataGrid\",\n * \"props\": {\n * \"columns\": [...],\n * \"dynamicColumns\": \"{{_local.dynamicCols}}\"\n * }\n * }\n */\n dynamicColumns?: DataGridColumn[];\n\n /**\n * 동적 컬럼 삽입 위치를 지정하는 필드명\n * 해당 필드 뒤에 dynamicColumns가 삽입됩니다.\n * 지정하지 않으면 columns 끝에 추가됩니다.\n */\n dynamicColumnsInsertAfter?: string;\n\n /**\n * expandChildren에 전달할 추가 컨텍스트 (v1.5.0+)\n *\n * expandChildren 렌더링 시 row 외에 추가로 전달할 데이터입니다.\n * 레이아웃 JSON에서 _local, _computed 값 등을 전달할 때 사용합니다.\n *\n * @example\n * {\n * \"name\": \"DataGrid\",\n * \"props\": {\n * \"expandContext\": {\n * \"optionColumns\": \"{{_local.optionColumns}}\",\n * \"currencies\": \"{{_computed.optionCurrencyColumns}}\"\n * }\n * }\n * }\n */\n expandContext?: Record;\n\n /**\n * 부모 컴포넌트 컨텍스트 (내부 사용)\n *\n * DynamicRenderer에서 전달되며, expandChildren 내부 액션에서\n * G7Core.state.setLocal()이 부모 상태를 업데이트할 수 있도록 합니다.\n */\n __componentContext?: { state?: any; setState?: (updates: any) => void };\n\n // 서브 행 (Sub Row) 기능 - v1.6.0+ (선택적)\n /**\n * 서브 행에 렌더링할 컴포넌트 정의\n *\n * 각 데이터 행 아래에 추가 정보를 표시합니다.\n * 모든 컬럼이 병합된 단일 셀로 렌더링됩니다.\n * 이 prop이 없거나 빈 배열이면 서브 행이 표시되지 않습니다.\n *\n * @example\n * // 배송비 요약을 서브 행으로 표시\n * {\n * \"subRowChildren\": [\n * {\n * \"type\": \"basic\",\n * \"name\": \"Span\",\n * \"props\": { \"className\": \"text-sm text-gray-500\" },\n * \"text\": \"배송비: {{row.fee_summary}}\"\n * }\n * ]\n * }\n */\n subRowChildren?: DataGridSubRowChildren[];\n\n /**\n * 서브 행 표시 조건 (선택적)\n *\n * JavaScript 표현식으로, true일 때만 서브 행이 표시됩니다.\n * row 객체에 접근할 수 있습니다.\n *\n * @example\n * \"{{row.charge_policy !== 'free'}}\" // 무료 배송이 아닐 때만 표시\n * \"{{row.fee_summary}}\" // fee_summary가 있을 때만 표시\n */\n subRowCondition?: string;\n\n /**\n * 서브 행 셀의 CSS 클래스 (선택적)\n *\n * @default \"px-6 py-2 bg-gray-50 dark:bg-gray-900\"\n */\n subRowClassName?: string;\n\n /**\n * 서브 행 렌더 함수 (선택적)\n *\n * subRowChildren 대신 함수로 서브 행 내용을 렌더링합니다.\n * TypeScript/React 코드에서 직접 사용할 때 유용합니다.\n */\n subRowRender?: (row: any) => React.ReactNode;\n\n /**\n * 푸터 행 셀 정의 (v1.19.0+)\n *\n * 테이블 하단에 합계/요약 행을 렌더링합니다.\n * 각 셀은 field로 컬럼 위치에 매핑하거나 colSpan으로 병합합니다.\n *\n * @example\n * {\n * \"footerCells\": [\n * { \"field\": \"product_info\", \"text\": \"합계\", \"className\": \"font-bold\" },\n * { \"field\": \"quantity\", \"text\": \"3개\" },\n * { \"field\": \"subtotal\", \"text\": \"690,000원\", \"className\": \"font-bold\" }\n * ]\n * }\n */\n footerCells?: DataGridFooterCell[];\n\n /**\n * 푸터 행 CSS 클래스 (선택적)\n *\n * @default \"bg-gray-50 dark:bg-gray-800/50 font-medium\"\n */\n footerClassName?: string;\n\n /**\n * 모바일 카드 뷰에서 푸터 렌더링용 컴포넌트 (선택적)\n *\n * 미지정 시 footerCells를 key-value 형태로 자동 렌더링합니다.\n */\n footerCardChildren?: DataGridCellChild[];\n\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * 셀 값을 안전하게 렌더링 가능한 형태로 변환합니다.\n * React에서 객체를 직접 렌더링하면 에러가 발생하므로 이를 방지합니다.\n *\n * @param value 렌더링할 값\n * @returns 렌더링 가능한 문자열 또는 원본 값\n */\nconst safeRenderValue = (value: any): React.ReactNode => {\n // null, undefined는 빈 문자열로\n if (value === null || value === undefined) {\n return '';\n }\n\n // 원시 타입은 그대로 반환\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n\n // 배열인 경우 각 요소를 안전하게 변환 후 조인\n if (Array.isArray(value)) {\n return value.map((v) => safeRenderValue(v)).join(', ');\n }\n\n // 객체인 경우 (React 노드 제외)\n if (typeof value === 'object') {\n // React 엘리먼트인 경우 그대로 반환\n if (React.isValidElement(value)) {\n return value;\n }\n\n // MissingValue나 빈 객체 처리 (Laravel의 $this->when() 반환값)\n if (Object.keys(value).length === 0) {\n return '';\n }\n\n // 일반 객체는 JSON 문자열로 변환 시도\n try {\n return JSON.stringify(value);\n } catch {\n logger.warn('객체를 문자열로 변환할 수 없습니다:', value);\n return '[Object]';\n }\n }\n\n // 그 외의 경우 문자열로 변환\n return String(value);\n};\n\n/**\n * subRow 컨테이너의 표시 조건을 평가합니다.\n * JSX 인라인 조건에서 사용되므로 엔진 위임 불가 — 직접 평가합니다.\n *\n * @param condition 조건 문자열 (예: \"{{row.charge_policy !== 'free'}}\")\n * @param row 현재 행 데이터\n * @returns 조건 평가 결과\n */\nconst evaluateSubRowCondition = (condition: string | undefined, row: any): boolean => {\n if (!condition) return true;\n\n // {{...}} 패턴 추출\n const match = condition.match(/^\\{\\{(.+)\\}\\}$/);\n if (!match) return true;\n\n const expr = match[1].trim();\n\n try {\n // row 컨텍스트에서 표현식 평가\n // eslint-disable-next-line no-new-func\n const evaluator = new Function('row', `return ${expr}`);\n return !!evaluator(row);\n } catch (error) {\n logger.warn('subRow 조건 평가 실패:', condition, error);\n return true;\n }\n};\n\n/**\n * cellChildren 배열을 React 요소로 렌더링합니다.\n *\n * G7Core.renderItemChildren을 사용하여 템플릿 엔진의 모든 표현식 처리 기능을 활용합니다.\n * condition 속성은 엔진의 evaluateRenderCondition에서 네이티브로 지원됩니다 (engine-v1.17.1).\n */\nconst renderCellChildren = (\n cellChildren: DataGridCellChild[],\n row: any,\n value: any,\n keyPrefix: string = '',\n componentContext?: { state?: any; setState?: (updates: any) => void }\n): React.ReactNode => {\n const G7Core = (window as any).G7Core;\n if (G7Core?.renderItemChildren) {\n const context = {\n row,\n value,\n $value: value,\n };\n\n return G7Core.renderItemChildren(\n cellChildren,\n context,\n componentMap,\n keyPrefix,\n componentContext ? { componentContext } : undefined\n );\n }\n\n logger.warn('G7Core.renderItemChildren을 사용할 수 없습니다.');\n return null;\n};\n\n/**\n * subRowChildren 배열을 React 요소로 렌더링합니다.\n *\n * @param subRowChildren 렌더링할 컴포넌트 정의 배열\n * @param row 현재 행 데이터\n * @param keyPrefix 키 접두사\n * @returns React 노드\n */\nconst renderSubRowChildren = (\n subRowChildren: DataGridSubRowChildren[],\n row: any,\n keyPrefix: string = '',\n componentContext?: { state?: any; setState?: (updates: any) => void }\n): React.ReactNode => {\n const G7Core = (window as any).G7Core;\n\n if (G7Core?.renderItemChildren) {\n const context = {\n row,\n $value: row,\n };\n\n return G7Core.renderItemChildren(\n subRowChildren,\n context,\n componentMap,\n keyPrefix,\n componentContext ? { componentContext } : undefined\n );\n }\n\n logger.warn('G7Core.renderItemChildren을 사용할 수 없습니다.');\n return null;\n};\n\n/**\n * DataGrid 집합 컴포넌트\n *\n * Table 기본 컴포넌트를 조합하여 데이터 그리드 UI를 구성합니다.\n * 정렬, 필터, 페이지네이션, 체크박스 선택, 컬럼 선택, 반응형 카드 뷰 기능을 포함합니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"DataGrid\",\n * \"props\": {\n * \"columns\": [\n * {\"field\": \"name\", \"header\": \"$t:admin.users.name\", \"sortable\": true, \"required\": true},\n * {\"field\": \"email\", \"header\": \"$t:admin.users.email\", \"required\": true}\n * ],\n * \"data\": \"{{users.data}}\",\n * \"sortable\": true,\n * \"pagination\": true,\n * \"pageSize\": 10,\n * \"selectable\": true,\n * \"showColumnSelector\": true,\n * \"prevText\": \"$t:common.prev\",\n * \"nextText\": \"$t:common.next\",\n * \"columnSelectorText\": \"$t:common.column_selector\",\n * \"emptyMessage\": \"$t:common.no_data\",\n * \"actionsColumnHeader\": \"$t:common.actions\",\n * \"requiredText\": \"$t:common.required\"\n * }\n * }\n */\nexport const DataGrid: React.FC = ({\n id,\n columns,\n data,\n sortable = true,\n pagination = false,\n pageSize = 10,\n onRowClick,\n className = '',\n style,\n // 정렬 (외부 제어)\n sortField: externalSortField,\n sortDirection: externalSortDirection,\n onSortChange,\n // 체크박스 선택\n selectable = false,\n selectedIds = [],\n onSelectionChange,\n idField = 'id',\n // 컬럼 표시/숨김\n showColumnSelector = false,\n visibleColumns: initialVisibleColumns,\n onColumnVisibilityChange,\n // 반응형\n responsiveBreakpoint = 768,\n cardRenderer,\n // 행 액션\n rowActions,\n onRowAction,\n // 페이지네이션 항상 표시\n alwaysShowPagination = false,\n // 서버 사이드 페이지네이션\n serverSidePagination = false,\n serverCurrentPage,\n serverTotalPages,\n onPageChange,\n // 다국어 텍스트\n prevText,\n nextText,\n columnSelectorText,\n emptyMessage,\n actionsColumnHeader,\n requiredText,\n selectedCountText,\n // 페이지네이션 옵션\n showFirstLast = true,\n // 확장 행 (Expandable Row)\n expandable = false,\n expandedRowIds: externalExpandedRowIds,\n onExpandChange,\n expandChildren,\n expandedRowRender,\n expandColumnWidth = '48px',\n expandIconExpanded = IconName.ChevronDown,\n expandIconCollapsed = IconName.ChevronRight,\n expandConditionField,\n // 동적 컬럼\n dynamicColumns,\n dynamicColumnsInsertAfter,\n // expandChildren 추가 컨텍스트\n expandContext,\n // 부모 컴포넌트 컨텍스트 (내부 사용)\n __componentContext,\n // 서브 행 (Sub Row) - 선택적\n subRowChildren,\n subRowCondition,\n subRowClassName = 'px-6 py-2 bg-gray-50 dark:bg-gray-900',\n subRowRender,\n // 푸터 행 (Footer Row) - v1.19.0+\n footerCells,\n footerClassName = 'bg-gray-50 dark:bg-gray-800/50 font-medium',\n footerCardChildren,\n editorAttrs,\n}) => {\n // props로 전달된 값이 없으면 undefined (Pagination이 기본 화살표 사용)\n const resolvedPrevText = prevText || undefined;\n const resolvedNextText = nextText || undefined;\n const resolvedColumnSelectorText = columnSelectorText ?? t('common.column_selector');\n const resolvedEmptyMessage = emptyMessage ?? t('common.no_data');\n const resolvedActionsColumnHeader = actionsColumnHeader ?? t('common.actions');\n const resolvedRequiredText = requiredText ?? t('common.required');\n const resolvedSelectedCountText = selectedCountText ?? t('common.selected_count');\n\n // G7Core.useControllableState 훅 참조 (Phase 2-2)\n const G7Core = (window as any).G7Core;\n const useControllableState = G7Core?.useControllableState as\n | ((\n controlledValue: T | undefined,\n defaultValue: T,\n onChange?: (value: T) => void,\n options?: { isEqual?: (a: T, b: T) => boolean }\n ) => [T, (value: T | ((prev: T) => T)) => void])\n | undefined;\n const shallowArrayEqual = G7Core?.shallowArrayEqual as\n | ((a: T[], b: T[]) => boolean)\n | undefined;\n\n // 정렬 상태 - useControllableState 적용\n // 외부 제어(externalSortField)가 있으면 Controlled, 없으면 Uncontrolled\n const [sortField, setSortField] = useControllableState\n ? useControllableState(externalSortField, null, (value: string | null) => {\n if (value !== null) {\n onSortChange?.(value, sortDirection);\n }\n })\n : [externalSortField ?? null, () => {}];\n\n const [sortDirection, setSortDirection] = useControllableState\n ? useControllableState<'asc' | 'desc'>(externalSortDirection, 'asc', (value: 'asc' | 'desc') => {\n if (sortField !== null) {\n onSortChange?.(sortField, value);\n }\n })\n : [externalSortDirection ?? 'asc', () => {}];\n\n const [currentPage, setCurrentPage] = useState(1);\n\n // 확장 행 상태 관리 - useControllableState 적용 (Phase 2-2)\n // 외부 제어(externalExpandedRowIds)가 있으면 Controlled, 없으면 Uncontrolled\n const [expandedRowIds, setExpandedRowIds] = useControllableState\n ? useControllableState<(string | number)[]>(\n externalExpandedRowIds,\n [],\n onExpandChange,\n { isEqual: shallowArrayEqual }\n )\n : [externalExpandedRowIds ?? [], () => {}];\n const [isColumnSelectorOpen, setIsColumnSelectorOpen] = useState(false);\n const columnSelectorRef = useRef(null);\n\n // G7Core.useResponsive를 통해 반응형 상태 구독\n // G7Core는 위에서 이미 선언됨 (Phase 2-2)\n const useResponsive = G7Core?.useResponsive;\n const responsiveValue = useResponsive?.();\n const isMobileView = responsiveValue\n ? responsiveValue.width < responsiveBreakpoint\n : typeof window !== 'undefined' && window.innerWidth < responsiveBreakpoint;\n\n // 동적 컬럼 병합\n // dynamicColumns가 있으면 columns에 병합하고, dynamicColumnsInsertAfter 위치에 삽입\n const mergedColumns = useMemo(() => {\n if (!dynamicColumns || !Array.isArray(dynamicColumns) || dynamicColumns.length === 0) {\n return columns;\n }\n\n // 삽입 위치 찾기\n if (dynamicColumnsInsertAfter) {\n const insertIndex = columns.findIndex((col) => col.field === dynamicColumnsInsertAfter);\n if (insertIndex !== -1) {\n return [\n ...columns.slice(0, insertIndex + 1),\n ...dynamicColumns,\n ...columns.slice(insertIndex + 1),\n ];\n }\n }\n\n // 삽입 위치가 없으면 끝에 추가\n return [...columns, ...dynamicColumns];\n }, [columns, dynamicColumns, dynamicColumnsInsertAfter]);\n\n // 표시할 컬럼 관리 (hidden 속성 기반 초기값 설정)\n // 참고: mergedColumns가 변경되면 useEffect에서 동기화됨\n const [visibleColumns, setVisibleColumns] = useState(() => {\n if (initialVisibleColumns && initialVisibleColumns.length > 0) return initialVisibleColumns;\n return columns.filter((col) => !col.hidden).map((col) => col.field);\n });\n\n // 외부 visibleColumns가 변경되면 내부 상태 동기화\n useEffect(() => {\n if (initialVisibleColumns && initialVisibleColumns.length > 0) {\n setVisibleColumns(initialVisibleColumns);\n }\n }, [initialVisibleColumns]);\n\n // columns prop이 변경되면 (다른 DataGrid = 다른 페이지) 내부 visibleColumns 리셋\n const prevColumnFieldsRef = useRef('');\n useEffect(() => {\n const currentFields = columns.map(col => col.field).join(',');\n if (prevColumnFieldsRef.current && currentFields !== prevColumnFieldsRef.current) {\n // columns가 변경되었고, 외부 visibleColumns가 없으면 새 columns 기준으로 리셋\n if (!initialVisibleColumns || initialVisibleColumns.length === 0) {\n setVisibleColumns(columns.filter(col => !col.hidden).map(col => col.field));\n }\n }\n prevColumnFieldsRef.current = currentFields;\n }, [columns, initialVisibleColumns]);\n\n // 동적 컬럼이 추가되면 visibleColumns에 자동으로 포함\n // 단, 외부에서 전달된 initialVisibleColumns가 있으면 그 값을 존중\n const prevDynamicFieldsRef = useRef([]);\n const hasInitialColumnsRef = useRef(null);\n\n useEffect(() => {\n if (dynamicColumns && dynamicColumns.length > 0) {\n const currentDynamicFields = dynamicColumns.map((col) => col.field);\n\n // 최초 실행 시 initialVisibleColumns 존재 여부 확인\n if (hasInitialColumnsRef.current === null) {\n hasInitialColumnsRef.current = initialVisibleColumns !== undefined && initialVisibleColumns.length > 0;\n\n // initialVisibleColumns가 있으면 현재 동적 필드를 \"이미 알려진\" 것으로 기록\n // 이렇게 하면 외부에서 관리하는 visibleColumns를 존중함\n if (hasInitialColumnsRef.current) {\n prevDynamicFieldsRef.current = currentDynamicFields;\n return; // 외부 visibleColumns 존중, 자동 추가 안 함\n }\n }\n\n const prevDynamicFields = prevDynamicFieldsRef.current;\n\n // 새로 추가된 동적 필드만 찾기 (기존에 없던 필드)\n const trulyNewFields = currentDynamicFields.filter(\n (field) => !prevDynamicFields.includes(field)\n );\n\n // 새로운 동적 필드가 있을 때만 자동 추가\n if (trulyNewFields.length > 0) {\n const fieldsToAdd = trulyNewFields.filter((field) => !visibleColumns.includes(field));\n if (fieldsToAdd.length > 0) {\n const updatedVisible = [...visibleColumns, ...fieldsToAdd];\n setVisibleColumns(updatedVisible);\n onColumnVisibilityChange?.(updatedVisible);\n }\n }\n\n // 현재 동적 필드 목록 저장\n prevDynamicFieldsRef.current = currentDynamicFields;\n }\n }, [dynamicColumns]); // visibleColumns 의존성 제외하여 무한 루프 방지\n\n // 데이터가 변경되면 첫 페이지로 리셋 (서버 사이드 페이지네이션이 아닐 때만)\n useEffect(() => {\n if (!serverSidePagination) {\n setCurrentPage(1);\n }\n }, [data, serverSidePagination]);\n\n // 컬럼 선택 메뉴 외부 클릭 감지\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (columnSelectorRef.current && !columnSelectorRef.current.contains(event.target as Node)) {\n setIsColumnSelectorOpen(false);\n }\n };\n\n if (isColumnSelectorOpen) {\n document.addEventListener('mousedown', handleClickOutside);\n }\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, [isColumnSelectorOpen]);\n\n // 표시할 컬럼 필터링 (mergedColumns에서 visibleColumns만 표시)\n // showColumnSelector가 false이고 외부 visibleColumns도 없으면 모든 비-hidden 컬럼 표시\n // (SPA 네비게이션 시 React 컴포넌트 재사용으로 이전 페이지의 visibleColumns가 남는 문제 방지)\n const displayColumns = useMemo(() => {\n if (!showColumnSelector && (!initialVisibleColumns || initialVisibleColumns.length === 0)) {\n return mergedColumns.filter((col) => !col.hidden);\n }\n return mergedColumns.filter((col) => visibleColumns.includes(col.field));\n }, [mergedColumns, visibleColumns, showColumnSelector, initialVisibleColumns]);\n\n // 정렬 처리\n // onSortChange 가 연결된 경우(controlled) 정렬 주체는 서버다. 헤더 클릭 →\n // onSortChange → 상위가 query 갱신 후 API 재요청 → 서버가 새 순서를 내려준다.\n // 이때 data 는 이미 서버가 정렬한 결과이므로 클라이언트가 다시 정렬하면 안 된다\n // (프론트는 서버가 내려준 순서를 그대로 출력한다). 특히 클라이언트 정렬은 null\n // 값 비교 시 항상 앞으로 밀어 서버 ORDER BY 순서를 훼손한다.\n // onSortChange 가 없는 경우(uncontrolled)에만 컴포넌트가 단독 정렬 주체로서\n // 전달받은 로컬 데이터를 클라이언트에서 정렬한다.\n const sortedData = useMemo(() => {\n if (!sortField || !sortable || !data) return data || [];\n if (onSortChange) return data;\n\n return [...data].sort((a, b) => {\n const aValue = a[sortField];\n const bValue = b[sortField];\n\n if (aValue === bValue) return 0;\n\n const comparison = aValue > bValue ? 1 : -1;\n return sortDirection === 'asc' ? comparison : -comparison;\n });\n }, [data, sortField, sortDirection, sortable, onSortChange]);\n\n // 페이지네이션 처리\n const paginatedData = useMemo(() => {\n // 서버 사이드 페이지네이션인 경우 데이터를 그대로 사용 (이미 서버에서 페이징됨)\n if (serverSidePagination) return sortedData;\n if (!pagination) return sortedData;\n\n const start = (currentPage - 1) * pageSize;\n const end = start + pageSize;\n return sortedData.slice(start, end);\n }, [sortedData, currentPage, pageSize, pagination, serverSidePagination]);\n\n // 서버 사이드 페이지네이션인 경우 서버에서 전달된 값 사용\n const effectiveCurrentPage = serverSidePagination ? (serverCurrentPage || 1) : currentPage;\n const effectiveTotalPages = serverSidePagination ? (serverTotalPages || 1) : Math.ceil((sortedData?.length || 0) / pageSize);\n\n // 페이지 변경 핸들러\n const handlePageChange = useCallback((page: number) => {\n if (serverSidePagination && onPageChange) {\n onPageChange(page);\n } else {\n setCurrentPage(page);\n }\n }, [serverSidePagination, onPageChange]);\n\n // 정렬 토글 (Phase 2-2: useControllableState로 단순화)\n const handleSort = (field: string) => {\n if (!sortable) return;\n\n const column = columns.find((col) => col.field === field);\n if (column?.sortable === false) return;\n\n const newDirection: 'asc' | 'desc' = sortField === field && sortDirection === 'asc' ? 'desc' : 'asc';\n\n // useControllableState 사용 시: setter가 onChange 콜백 자동 호출\n // 폴백 시: 직접 onSortChange 호출\n if (useControllableState) {\n setSortField(field);\n setSortDirection(newDirection);\n } else if (onSortChange) {\n onSortChange(field, newDirection);\n }\n };\n\n // 전체 선택 처리\n const handleSelectAll = useCallback(() => {\n if (!onSelectionChange || !paginatedData) return;\n\n const allIds = paginatedData.map((row) => row[idField]);\n const allSelected = allIds.every((id) => selectedIds.includes(id));\n\n if (allSelected) {\n onSelectionChange(selectedIds.filter((id) => !allIds.includes(id)));\n } else {\n const newSelectedIds = [...new Set([...selectedIds, ...allIds])];\n onSelectionChange(newSelectedIds);\n }\n }, [paginatedData, selectedIds, onSelectionChange, idField]);\n\n // 개별 선택 처리\n const handleSelectRow = useCallback(\n (rowId: string | number) => {\n if (!onSelectionChange) return;\n\n if (selectedIds.includes(rowId)) {\n onSelectionChange(selectedIds.filter((id) => id !== rowId));\n } else {\n onSelectionChange([...selectedIds, rowId]);\n }\n },\n [selectedIds, onSelectionChange]\n );\n\n // 컬럼 표시/숨김 토글\n const handleColumnToggle = useCallback(\n (field: string) => {\n const column = mergedColumns.find((col) => col.field === field);\n if (column?.required) return;\n\n const newVisibleColumns = visibleColumns.includes(field)\n ? visibleColumns.filter((f) => f !== field)\n : [...visibleColumns, field];\n\n setVisibleColumns(newVisibleColumns);\n onColumnVisibilityChange?.(newVisibleColumns);\n },\n [mergedColumns, visibleColumns, onColumnVisibilityChange]\n );\n\n // 행 액션 처리\n const handleRowAction = useCallback(\n (actionId: string | number, row: any) => {\n onRowAction?.(actionId, row);\n },\n [onRowAction]\n );\n\n /**\n * 행 데이터 기반으로 rowActions의 disabled 상태를 결정합니다.\n * disabledField가 지정된 경우 해당 경로의 값이 falsy면 disabled 처리합니다.\n */\n const resolveRowActions = useCallback(\n (row: any): ActionMenuItem[] => {\n if (!rowActions) return [];\n return rowActions.map((action) => {\n let isDisabled = action.disabled ?? false;\n if (action.disabledField && !isDisabled) {\n const parts = action.disabledField.split('.');\n let value: any = row;\n for (const part of parts) {\n value = value?.[part];\n }\n if (!value) {\n isDisabled = true;\n }\n }\n return {\n ...action,\n disabled: isDisabled,\n onClick: () => handleRowAction(action.id, row),\n };\n });\n },\n [rowActions, handleRowAction]\n );\n\n // 행 단위 확장 가능 여부 확인\n const isRowExpandable = useCallback(\n (row: any): boolean => {\n if (!expandConditionField) return true;\n const value = row[expandConditionField];\n return Array.isArray(value) ? value.length > 0 : !!value;\n },\n [expandConditionField]\n );\n\n // 확장 행 토글 처리\n // 확장 토글 (Phase 2-2: useControllableState로 단순화)\n const handleExpandToggle = useCallback(\n (rowId: string | number) => {\n const newExpandedIds = expandedRowIds.includes(rowId)\n ? expandedRowIds.filter((id: string | number) => id !== rowId)\n : [...expandedRowIds, rowId];\n\n // useControllableState 사용 시: setter가 onChange 콜백 자동 호출\n // 폴백 시: 직접 onExpandChange 호출\n if (useControllableState) {\n setExpandedRowIds(newExpandedIds);\n } else if (onExpandChange) {\n onExpandChange(newExpandedIds);\n }\n },\n [expandedRowIds, onExpandChange, useControllableState, setExpandedRowIds]\n );\n\n // 행이 확장되었는지 확인\n const isRowExpanded = useCallback(\n (rowId: string | number) => expandedRowIds.includes(rowId),\n [expandedRowIds]\n );\n\n // 확장 행 콘텐츠 렌더링 (Phase 2-1: G7Core.renderExpandContent API 사용으로 간소화)\n const renderExpandedContent = useCallback(\n (row: any) => {\n // expandedRowRender 함수가 제공된 경우 우선 사용\n if (expandedRowRender) {\n return expandedRowRender(row);\n }\n\n // expandChildren이 제공된 경우 G7Core.renderExpandContent 사용\n // 기존 ~80줄의 복잡한 로직이 renderExpandContent API 내부로 캡슐화됨\n // G7Core는 컴포넌트 시작 부분에서 이미 선언됨 (Phase 2-2)\n if (expandChildren && expandChildren.length > 0 && G7Core?.renderExpandContent) {\n return G7Core.renderExpandContent({\n children: expandChildren,\n row,\n expandContext,\n componentContext: __componentContext,\n componentMap,\n keyPrefix: `expand-${row[idField]}`,\n });\n }\n\n return null;\n },\n // __componentContext?.state를 의존성에 추가하여 상태 변경 시 확장 영역이 다시 렌더링되도록 함\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [expandedRowRender, expandChildren, idField, expandContext, __componentContext, __componentContext?.state]\n );\n\n // 전체 선택 상태 계산\n const isAllSelected = useMemo(() => {\n if (!paginatedData || paginatedData.length === 0) return false;\n return paginatedData.every((row) => selectedIds.includes(row[idField]));\n }, [paginatedData, selectedIds, idField]);\n\n const isIndeterminate = useMemo(() => {\n if (!paginatedData || paginatedData.length === 0) return false;\n const selectedCount = paginatedData.filter((row) => selectedIds.includes(row[idField])).length;\n return selectedCount > 0 && selectedCount < paginatedData.length;\n }, [paginatedData, selectedIds, idField]);\n\n // 기본 카드 렌더러\n const defaultCardRenderer = (row: any, cols: DataGridColumn[]) => {\n const rowId = row[idField];\n const expanded = isRowExpanded(rowId);\n\n return (\n
\n
\n {/* 확장 버튼 (카드 뷰) */}\n {expandable && isRowExpandable(row) && (\n handleExpandToggle(rowId)}\n className=\"p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors mr-2\"\n aria-label={expanded ? 'Collapse card' : 'Expand card'}\n >\n \n \n )}\n\n {selectable && (\n handleSelectRow(row[idField])}\n className=\"w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded mr-3 mt-1\"\n />\n )}\n
\n
\n {cols.map((col) => (\n
\n \n {col.header}\n \n
\n {col.cellChildren && col.cellChildren.length > 0\n ? renderCellChildren(col.cellChildren, row, row[col.field], `card-${row[idField]}-${col.field}`, __componentContext)\n : col.render\n ? col.render(row[col.field], row)\n : safeRenderValue(row[col.field])}\n
\n
\n ))}\n
\n
\n {rowActions && rowActions.length > 0 && (\n
\n \n
\n )}\n
\n\n {/* 확장된 콘텐츠 (카드 뷰) */}\n {expandable && expanded && isRowExpandable(row) && (\n
\n {renderExpandedContent(row)}\n
\n )}\n\n {/* 서브 행 콘텐츠 (카드 뷰) */}\n {(subRowChildren && subRowChildren.length > 0 || subRowRender) &&\n evaluateSubRowCondition(subRowCondition, row) && (\n
\n {subRowRender\n ? subRowRender(row)\n : renderSubRowChildren(subRowChildren!, row, `card-subrow-${rowId}`, __componentContext)}\n
\n )}\n
\n );\n };\n\n // 모바일 카드 뷰\n if (isMobileView) {\n return (\n
\n {/* 컬럼 선택 메뉴 */}\n {showColumnSelector && (\n
\n
\n setIsColumnSelectorOpen(!isColumnSelectorOpen)}\n className=\"flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700\"\n >\n \n \n {resolvedColumnSelectorText}\n \n \n\n {isColumnSelectorOpen && (\n
\n
\n {mergedColumns.map((col) => (\n !col.required && handleColumnToggle(col.field)}\n >\n {}}\n className=\"w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded\"\n />\n \n {col.header}\n {col.required && (\n \n ({resolvedRequiredText})\n \n )}\n \n
\n ))}\n
\n
\n )}\n
\n
\n )}\n\n {/* 선택된 항목 수 표시 */}\n {selectable && selectedIds.length > 0 && resolvedSelectedCountText && (\n
\n {resolvedSelectedCountText.replace('{count}', String(selectedIds.length))}\n
\n )}\n\n {/* 카드 목록 — body wrapper 에 `${id}__body` 부여 (pagination 제외 영역) */}\n
\n {paginatedData && paginatedData.length > 0 ? (\n paginatedData.map((row, index) => (\n
\n {cardRenderer\n ? cardRenderer(row, displayColumns)\n : defaultCardRenderer(row, displayColumns)}\n
\n ))\n ) : (\n
\n {resolvedEmptyMessage}\n
\n )}\n
\n\n {/* 푸터 (모바일 카드 뷰) - v1.19.0+ */}\n {footerCells && footerCells.length > 0 && paginatedData && paginatedData.length > 0 && (\n
\n {footerCardChildren && footerCardChildren.length > 0\n ? renderCellChildren(footerCardChildren, {}, null, 'footer-card', __componentContext)\n : (\n
\n {footerCells.filter(c => c.text || (c.children && c.children.length > 0)).map((cell, idx) => {\n const column = displayColumns.find(col => col.field === cell.field);\n return (\n
\n {column?.header || ''}\n \n {cell.children && cell.children.length > 0\n ? renderCellChildren(cell.children, {}, null, `footer-mobile-${idx}`, __componentContext)\n : cell.text || ''}\n \n
\n );\n })}\n
\n )\n }\n
\n )}\n\n {/* 페이지네이션 */}\n {pagination && (alwaysShowPagination || effectiveTotalPages > 1) && (\n
\n \n
\n )}\n \n );\n }\n\n // 데스크톱 테이블 뷰\n return (\n
\n {/* 컬럼 선택 메뉴 */}\n {showColumnSelector && (\n
\n
\n setIsColumnSelectorOpen(!isColumnSelectorOpen)}\n className=\"flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700\"\n >\n \n \n {resolvedColumnSelectorText}\n \n \n\n {isColumnSelectorOpen && (\n
\n
\n {mergedColumns.map((col) => (\n !col.required && handleColumnToggle(col.field)}\n >\n {}}\n className=\"w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded\"\n />\n \n {col.header}\n {col.required && (\n \n ({resolvedRequiredText})\n \n )}\n \n
\n ))}\n
\n
\n )}\n
\n
\n )}\n\n {/* 선택된 항목 수 표시 */}\n {selectable && selectedIds.length > 0 && resolvedSelectedCountText && (\n
\n {resolvedSelectedCountText.replace('{count}', String(selectedIds.length))}\n
\n )}\n\n {/* 테이블 — body wrapper 에 `${id}__body` 부여 (pagination 제외 영역) */}\n
\n \n \n \n {/* 확장 버튼 컬럼 */}\n {expandable && (\n \n )}\n\n {/* 체크박스 컬럼 */}\n {selectable && (\n \n )}\n\n {/* 데이터 컬럼 */}\n {displayColumns.map((column) => (\n handleSort(column.field)}\n >\n {column.header}\n {sortField === column.field && (\n \n {sortDirection === 'asc' ? '↑' : '↓'}\n \n )}\n \n ))}\n\n {/* 액션 컬럼 */}\n {rowActions && rowActions.length > 0 && (\n \n )}\n \n \n \n {paginatedData && paginatedData.length > 0 ? (\n paginatedData.map((row, rowIndex) => {\n const rowId = row[idField] || rowIndex;\n const expanded = isRowExpanded(rowId);\n\n return (\n \n {/* 데이터 행 */}\n 0 ? 'border-t border-gray-200 dark:border-gray-700' : ''}`}\n onClick={() => onRowClick?.(row)}\n >\n {/* 확장 버튼 */}\n {expandable && (\n e.stopPropagation()}\n >\n {isRowExpandable(row) ? (\n handleExpandToggle(rowId)}\n className=\"p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors\"\n aria-label={expanded ? 'Collapse row' : 'Expand row'}\n >\n \n \n ) : null}\n \n )}\n\n {/* 체크박스 */}\n {selectable && (\n \n )}\n\n {/* 데이터 셀 */}\n {displayColumns.map((column) => (\n \n {column.cellChildren && column.cellChildren.length > 0\n ? renderCellChildren(column.cellChildren, row, row[column.field], `${row[idField]}-${column.field}`, __componentContext)\n : column.render\n ? column.render(row[column.field], row)\n : safeRenderValue(row[column.field])}\n \n ))}\n\n {/* 액션 메뉴 */}\n {rowActions && rowActions.length > 0 && (\n e.stopPropagation()}\n >\n \n \n )}\n \n\n {/* 확장된 행 콘텐츠 */}\n {expandable && expanded && isRowExpandable(row) && (\n \n 0 ? 1 : 0)\n }\n className=\"px-6 py-4\"\n >\n {renderExpandedContent(row)}\n \n \n )}\n\n {/* 서브 행 (Sub Row) - 배송비 요약 등 추가 정보 표시용 */}\n {(subRowChildren && subRowChildren.length > 0 || subRowRender) &&\n evaluateSubRowCondition(subRowCondition, row) && (\n \n 0 ? 1 : 0)\n }\n className={subRowClassName ? 'p-2' : subRowClassName || 'px-6 py-2 bg-gray-50 dark:bg-gray-900'}\n >\n {subRowClassName ? (\n
\n {subRowRender\n ? subRowRender(row)\n : renderSubRowChildren(subRowChildren!, row, `subrow-${rowId}`, __componentContext)}\n
\n ) : (\n subRowRender\n ? subRowRender(row)\n : renderSubRowChildren(subRowChildren!, row, `subrow-${rowId}`, __componentContext)\n )}\n \n
\n )}\n \n );\n })\n ) : (\n \n 0 ? 1 : 0)\n }\n className=\"px-6 py-8 text-center text-gray-500 dark:text-gray-400\"\n >\n {resolvedEmptyMessage}\n \n \n )}\n \n\n {/* 푸터 행 (Footer Row) - v1.19.0+ */}\n {footerCells && footerCells.length > 0 && paginatedData && paginatedData.length > 0 && (\n \n \n {/* 확장 버튼 컬럼 빈 셀 */}\n {expandable && \n );\n });\n })()}\n\n {/* 액션 컬럼 빈 셀 */}\n {rowActions && rowActions.length > 0 && \n \n )}\n
\n {/* 빈 헤더 - 확장 버튼용 */}\n \n {\n if (el) {\n el.indeterminate = isIndeterminate;\n }\n }}\n onChange={handleSelectAll}\n className=\"w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded\"\n />\n \n {resolvedActionsColumnHeader}\n
e.stopPropagation()}>\n handleSelectRow(row[idField])}\n className=\"w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded\"\n />\n
}\n\n {/* 체크박스 컬럼 빈 셀 */}\n {selectable && }\n\n {/* 푸터 셀 렌더링 */}\n {(() => {\n const footerFieldMap = new Map(\n footerCells.filter(c => c.field).map(c => [c.field!, c])\n );\n return displayColumns.map((column) => {\n const cell = footerFieldMap.get(column.field);\n if (!cell) {\n return ;\n }\n return (\n \n {cell.children && cell.children.length > 0\n ? renderCellChildren(cell.children, {}, null, `footer-${column.field}`, __componentContext)\n : cell.text || ''}\n }\n
\n
\n\n {/* 페이지네이션 */}\n {pagination && (alwaysShowPagination || effectiveTotalPages > 1) && (\n
\n \n
\n )}\n \n );\n};\n","import React, { useMemo, useState, useCallback } from 'react';\nimport { Div } from '../basic/Div';\nimport { P } from '../basic/P';\nimport { ActionMenuItem } from './ActionMenu';\nimport { Pagination } from './Pagination';\nimport type { EditorAttrs } from '../../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:CardGrid')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:CardGrid]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:CardGrid]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:CardGrid]', ...args),\n};\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n/**\n * CardGrid 셀 자식 요소 타입\n */\nexport interface CardGridCellChild {\n id?: string;\n type: 'basic' | 'composite';\n name: string;\n props?: Record;\n text?: string;\n children?: CardGridCellChild[];\n condition?: string;\n if?: string;\n iteration?: {\n source: string;\n item_var?: string;\n index_var?: string;\n };\n actions?: any[]; // JSON에서 정의된 actions 배열 (ActionMenu용)\n}\n\n/**\n * 카드 컬럼 정의 (DataGrid의 컬럼 패턴과 동일)\n */\nexport interface CardGridColumn {\n id: string;\n cellChildren?: CardGridCellChild[];\n}\n\n/**\n * CardGrid Props 인터페이스\n */\nexport interface CardGridProps {\n // 필수 props\n data?: any[];\n\n // 카드 컬럼 정의 (DataGrid의 columns 패턴)\n cardColumns?: CardGridColumn[];\n\n // 그리드 레이아웃\n gridColumns?: number;\n gap?: number;\n\n // 반응형 설정\n responsiveColumns?: {\n sm?: number;\n md?: number;\n lg?: number;\n xl?: number;\n };\n\n // 카드 스타일\n cardClassName?: string;\n\n // 행 ID 필드\n idField?: string;\n\n // 페이지네이션\n pagination?: boolean;\n pageSize?: number;\n serverSidePagination?: boolean;\n serverCurrentPage?: number;\n serverTotalPages?: number;\n alwaysShowPagination?: boolean;\n onPageChange?: (page: number) => void;\n\n // 행 액션 메뉴 (DataGrid 패턴)\n rowActions?: ActionMenuItem[];\n onRowAction?: (actionId: string | number, row: any) => void;\n\n // 기타\n className?: string;\n style?: React.CSSProperties;\n\n // 다국어 텍스트\n emptyMessage?: string;\n\n // 페이지네이션 정보\n paginationInfoText?: string;\n\n // 페이지네이션 텍스트 (다국어 지원)\n prevText?: string;\n nextText?: string;\n\n // 스켈레톤 관련 props\n showSkeleton?: boolean; // 스켈레톤 표시 여부 (기본값: true)\n skeletonCount?: number; // 스켈레톤 개수 (기본값: gridColumns)\n skeletonCellChildren?: CardGridCellChild[]; // 커스텀 스켈레톤 정의\n\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * G7Core에서 전체 컴포넌트 맵을 가져옵니다.\n * 템플릿에 등록된 모든 컴포넌트를 자동으로 사용할 수 있습니다.\n */\nconst getComponentMap = (): Record> => {\n const G7Core = (window as any).G7Core;\n return G7Core?.getComponentMap?.() || {};\n};\n\n/**\n * 조건 문자열을 평가합니다.\n * 복잡한 JavaScript 표현식도 지원합니다 (&&, ||, length 등)\n */\nconst evaluateCondition = (condition: string, row: any): boolean => {\n if (!condition) return true;\n\n const match = condition.match(/^\\{\\{(.+)\\}\\}$/);\n if (!match) return true;\n\n const expr = match[1].trim();\n\n // 디버깅: row 데이터와 표현식 확인\n logger.log('evaluateCondition:', { condition, expr, row, rowStatus: row?.status });\n\n // 복잡한 표현식 (&&, ||, length, > 등)이 포함된 경우 eval 사용\n if (/[&|<>=!]/.test(expr) || expr.includes('.length')) {\n try {\n // row 컨텍스트에서 표현식 평가\n // eslint-disable-next-line no-new-func\n const evaluator = new Function('row', `return ${expr}`);\n const result = !!evaluator(row);\n logger.log('evaluateCondition result:', result);\n return result;\n } catch (error) {\n logger.warn('evaluateCondition: 표현식 평가 실패:', expr, error);\n return false;\n }\n }\n\n // !row.field 패턴\n if (expr.startsWith('!row.')) {\n const field = expr.slice(5);\n return !row[field];\n }\n\n // row.field 패턴\n if (expr.startsWith('row.')) {\n const field = expr.slice(4);\n return !!row[field];\n }\n\n return true;\n};\n\n/**\n * ActionMenu의 actions를 처리하여 onClick 함수를 생성합니다.\n * rowActions prop이 있으면 우선 사용합니다 (DataGrid 패턴).\n */\nconst processActionMenuActions = (\n child: CardGridCellChild,\n row: any,\n context: Record,\n rowActions?: ActionMenuItem[],\n onRowActionHandler?: (actionId: string | number, row: any) => void\n): CardGridCellChild => {\n const G7Core = (window as any).G7Core;\n\n // ActionMenu인 경우\n if (child.name === 'ActionMenu') {\n // 1. rowActions prop이 있으면 우선 사용 (DataGrid 패턴)\n if (rowActions && rowActions.length > 0 && onRowActionHandler) {\n return {\n ...child,\n props: {\n ...child.props,\n items: rowActions.map((action) => ({\n ...action,\n onClick: () => onRowActionHandler(action.id, row),\n })),\n },\n };\n }\n\n // 2. actions 배열이 있는 경우 (기존 패턴)\n if (child.actions && Array.isArray(child.actions)) {\n const items = child.props?.items || [];\n const actionsMap = new Map(child.actions.map((action: any) => [action.id, action]));\n\n // items의 onClick을 실제 함수로 변환\n const processedItems = items.map((item: any) => {\n // onClick이 문자열(액션 ID)인 경우\n if (typeof item.onClick === 'string') {\n const actionId = item.onClick;\n const actionDef = actionsMap.get(actionId);\n\n if (actionDef && G7Core?.ActionDispatcher) {\n return {\n ...item,\n onClick: () => {\n // 액션 컨텍스트 생성 (row 데이터 포함)\n const actionContext = { ...context };\n G7Core.ActionDispatcher.dispatch(actionDef, actionContext);\n },\n };\n }\n }\n return item;\n });\n\n // 처리된 items로 child 복사본 반환\n return {\n ...child,\n props: {\n ...child.props,\n items: processedItems,\n },\n };\n }\n }\n\n // children이 있으면 재귀적으로 처리\n if (child.children && Array.isArray(child.children)) {\n return {\n ...child,\n children: child.children.map((c) => processActionMenuActions(c, row, context, rowActions, onRowActionHandler)),\n };\n }\n\n return child;\n};\n\n/**\n * cellChildren 배열을 React 요소로 렌더링합니다.\n * DataGrid의 renderCellChildren 패턴을 따릅니다.\n */\nconst renderCellChildren = (\n cellChildren: CardGridCellChild[],\n row: any,\n index: number,\n keyPrefix: string = '',\n rowActions?: ActionMenuItem[],\n onRowActionHandler?: (actionId: string | number, row: any) => void\n): React.ReactNode => {\n const G7Core = (window as any).G7Core;\n\n if (G7Core?.renderItemChildren) {\n // 조건부 렌더링 필터링 (condition 또는 if 속성 지원)\n // 참고: renderItemChildren도 자체적으로 if 조건을 평가하지만,\n // 여기서 미리 필터링하면 불필요한 렌더링을 줄일 수 있음\n const filteredChildren = cellChildren.filter((child) => {\n const conditionExpr = child.condition || child.if;\n if (!conditionExpr) return true;\n const result = evaluateCondition(conditionExpr, row);\n return result;\n });\n\n // 컨텍스트 생성\n const context = {\n row,\n $value: row,\n index,\n };\n\n // ActionMenu actions 전처리 (재귀적으로)\n const processedChildren = filteredChildren.map((child) =>\n processActionMenuActions(child, row, context, rowActions, onRowActionHandler)\n );\n\n try {\n // G7Core에서 전체 컴포넌트 맵 가져오기\n const componentMap = getComponentMap();\n\n // G7Core 템플릿 엔진으로 렌더링\n return G7Core.renderItemChildren(\n processedChildren,\n context,\n componentMap,\n keyPrefix\n );\n } catch (error) {\n return (\n
\n

컴포넌트 로드 실패

\n

[CardGrid] 데이터를 표시할 수 없습니다.

\n
\n );\n }\n }\n\n return null;\n};\n\n/**\n * 개별 CardGridCellChild에 대한 스켈레톤을 생성합니다.\n * 모든 컴포넌트에 대해 동일한 기본 스켈레톤을 생성하여 확장성을 보장합니다.\n *\n * @param child 스켈레톤을 생성할 child 요소\n * @param key React key\n * @returns 생성된 스켈레톤 React 노드\n */\nconst generateSkeletonForChild = (\n child: CardGridCellChild,\n key: string\n): React.ReactNode => {\n // Div만 특수 처리 (레이아웃 구조 유지)\n if (child.name === 'Div' && child.children && child.children.length > 0) {\n const childSkeletons = child.children\n .map((c, idx) => generateSkeletonForChild(c, `${key}-child-${idx}`))\n .filter(Boolean);\n\n return (\n
\n {childSkeletons}\n
\n );\n }\n\n // 모든 컴포넌트는 동일한 기본 스켈레톤 (확장성 보장)\n return
;\n};\n\n/**\n * cellChildren 배열에서 스켈레톤 UI를 자동 생성합니다.\n *\n * @param cellChildren 카드 컬럼의 cellChildren 배열\n * @param keyPrefix 고유 키 생성을 위한 접두사\n * @returns 생성된 스켈레톤 React 노드 (cellChildren이 없으면 null)\n */\nconst generateSkeletonFromCellChildren = (\n cellChildren: CardGridCellChild[],\n keyPrefix: string = 'skeleton'\n): React.ReactNode => {\n if (!cellChildren || cellChildren.length === 0) {\n return null;\n }\n\n return cellChildren\n .map((child, index) => {\n const key = `${keyPrefix}-${child.id || child.name}-${index}`;\n\n // 조건부 렌더링 요소는 제외\n if (child.condition || child.if) {\n return null;\n }\n\n // iteration이 있으면 1개만 표시\n if (child.iteration) {\n return generateSkeletonForChild(child, key);\n }\n\n // 일반 요소 처리\n return generateSkeletonForChild(child, key);\n })\n .filter(Boolean);\n};\n\n/**\n * CardGrid 집합 컴포넌트\n *\n * 카드 레이아웃으로 데이터를 표시하는 그리드 컴포넌트입니다.\n * DataGrid의 cellChildren 패턴을 사용하여 카드 내용을 JSON으로 정의할 수 있습니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"type\": \"composite\",\n * \"name\": \"CardGrid\",\n * \"props\": {\n * \"data\": \"{{boards.data.data}}\",\n * \"columns\": 3,\n * \"gap\": 4,\n * \"pagination\": true,\n * \"pageSize\": 12,\n * \"cellChildren\": [\n * {\n * \"type\": \"basic\",\n * \"name\": \"Div\",\n * \"props\": {\n * \"className\": \"bg-white dark:bg-gray-800 rounded-lg p-6\"\n * },\n * \"children\": [\n * {\n * \"type\": \"basic\",\n * \"name\": \"H3\",\n * \"text\": \"{{row.name}}\"\n * }\n * ]\n * }\n * ]\n * }\n * }\n */\nexport const CardGrid: React.FC = ({\n data,\n cardColumns = [],\n gridColumns = 3,\n gap = 4,\n responsiveColumns,\n cardClassName = '',\n idField = 'id',\n pagination = true,\n pageSize = 12,\n serverSidePagination = false,\n serverCurrentPage,\n serverTotalPages,\n alwaysShowPagination = false,\n onPageChange,\n paginationInfoText,\n rowActions,\n onRowAction,\n className = '',\n style,\n emptyMessage,\n prevText,\n nextText,\n showSkeleton = true,\n skeletonCount,\n skeletonCellChildren,\n id,\n editorAttrs,\n}) => {\n // props로 전달된 값이 없으면 다국어 키 사용\n const resolvedEmptyMessage = emptyMessage ?? t('common.no_data');\n\n // cardColumns의 첫 번째 항목에서 cellChildren 추출\n const cellChildren = cardColumns[0]?.cellChildren || [];\n // 클라이언트 사이드 페이지네이션 상태\n const [currentPage, setCurrentPage] = useState(1);\n\n // 반응형 상태 구독 - cellChildren 내 responsive 속성 지원을 위해 필요\n // 화면 크기 변경 시 CardGrid가 리렌더링되어 renderItemChildren이 새로운 너비로 재실행됨\n // 훅 호출 자체가 리렌더링을 트리거하므로 반환값은 사용하지 않음 (void 처리)\n const G7Core = (window as any).G7Core;\n // eslint-disable-next-line react-hooks/rules-of-hooks\n G7Core?.useResponsive?.();\n\n // 서버/클라이언트 페이지 정보\n const effectiveCurrentPage = serverSidePagination ? (serverCurrentPage ?? 1) : currentPage;\n const effectiveTotalPages = serverSidePagination\n ? (serverTotalPages ?? 1)\n : Math.ceil((data?.length ?? 0) / pageSize);\n\n // 페이지 변경 핸들러\n const handlePageChange = (page: number) => {\n if (serverSidePagination) {\n onPageChange?.(page);\n } else {\n setCurrentPage(page);\n }\n };\n\n // 행 액션 처리 (DataGrid 패턴 + 템플릿 엔진 이벤트 연동)\n const handleRowAction = useCallback(\n (actionId: string | number, row: any) => {\n // prop으로 전달된 onRowAction 호출\n // DynamicRenderer의 actionDispatcher.bindActionsToProps가\n // 레이아웃 JSON의 actions 배열과 자동 연결함\n // onRowAction 호출 시 $args[0] = actionId, $args[1] = row로 전달됨\n onRowAction?.(actionId, row);\n },\n [onRowAction]\n );\n\n // 페이지네이션 적용된 데이터\n const paginatedData = useMemo(() => {\n if (!data) return [];\n if (!pagination) return data;\n if (serverSidePagination) return data;\n\n const startIndex = (currentPage - 1) * pageSize;\n const endIndex = startIndex + pageSize;\n return data.slice(startIndex, endIndex);\n }, [data, pagination, serverSidePagination, currentPage, pageSize]);\n\n // 반응형 그리드 클래스 생성\n const gridClasses = useMemo(() => {\n const classes = [];\n\n // 기본 (모바일)\n const smCols = responsiveColumns?.sm ?? 1;\n classes.push(`grid-cols-${smCols}`);\n\n // sm (640px+)\n if (responsiveColumns?.sm) {\n classes.push(`sm:grid-cols-${responsiveColumns.sm}`);\n }\n\n // md (768px+)\n const mdCols = responsiveColumns?.md ?? Math.min(gridColumns, 2);\n classes.push(`md:grid-cols-${mdCols}`);\n\n // lg (1024px+)\n const lgCols = responsiveColumns?.lg ?? gridColumns;\n classes.push(`lg:grid-cols-${lgCols}`);\n\n // xl (1280px+)\n if (responsiveColumns?.xl) {\n classes.push(`xl:grid-cols-${responsiveColumns.xl}`);\n }\n\n return classes.join(' ');\n }, [gridColumns, responsiveColumns]);\n\n // 스켈레톤 UI 생성 (useMemo로 최적화)\n const skeletonUI = useMemo(() => {\n if (!showSkeleton) {\n return null;\n }\n\n // 1. skeletonCellChildren이 있으면 우선 사용\n if (skeletonCellChildren && skeletonCellChildren.length > 0) {\n return generateSkeletonFromCellChildren(skeletonCellChildren, 'custom-skeleton');\n }\n\n // 2. cellChildren 기반 자동 생성\n if (cellChildren && cellChildren.length > 0) {\n return generateSkeletonFromCellChildren(cellChildren, 'auto-skeleton');\n }\n\n // 3. cellChildren도 없으면 null (빈 카드)\n return null;\n }, [showSkeleton, skeletonCellChildren, cellChildren]);\n\n // 로딩 상태 (data가 undefined/null)\n if (!data) {\n // showSkeleton이 false면 빈 화면\n if (!showSkeleton) {\n return null;\n }\n\n const effectiveSkeletonCount = skeletonCount ?? gridColumns;\n\n return (\n
\n {Array.from({ length: effectiveSkeletonCount }).map((_, index) => (\n \n {skeletonUI}\n
\n ))}\n
\n );\n }\n\n // 빈 데이터 상태\n if (paginatedData.length === 0) {\n return (\n
\n
\n

\n {resolvedEmptyMessage}\n

\n
\n
\n );\n }\n\n // 페이지네이션 표시 여부\n const showPagination =\n pagination &&\n (alwaysShowPagination || effectiveTotalPages > 1);\n\n return (\n
\n {/* 카드 그리드 */}\n
\n {paginatedData.map((row, index) => (\n
\n {renderCellChildren(cellChildren, row, index, `card-${index}`, rowActions, handleRowAction)}\n
\n ))}\n
\n\n {/* 페이지네이션 */}\n {showPagination && (\n
\n \n {/* 페이지네이션 정보 */}\n {paginationInfoText && (\n
\n {paginationInfoText}\n
\n )}\n
\n )}\n
\n );\n};","import React, { useEffect, useRef } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\nexport interface ModalProps {\n isOpen: boolean;\n onClose: () => void;\n title?: string;\n /** 타이틀 앞에 렌더링할 컴포넌트 배열 (레이아웃 JSON) */\n titlePrefix?: any[];\n /** 타이틀 뒤에 렌더링할 컴포넌트 배열 (레이아웃 JSON) */\n titleSuffix?: any[];\n /**\n * Backdrop(오버레이) 클릭 시 모달 닫기 여부\n * @default true\n */\n closeOnBackdropClick?: boolean;\n /**\n * closeOnOverlayClick 별칭 (레이아웃 JSON 호환)\n * @default true\n */\n closeOnOverlayClick?: boolean;\n /**\n * 헤더 닫기 버튼 표시 여부\n * @default true\n */\n showCloseButton?: boolean;\n /**\n * ESC 키로 모달 닫기 허용 여부\n * @default true\n */\n closeOnEscape?: boolean;\n children?: React.ReactNode;\n width?: string;\n className?: string;\n style?: React.CSSProperties;\n /** DynamicRenderer용 데이터 컨텍스트 (내부 사용) */\n __dataContext?: Record;\n /** DynamicRenderer용 번역 컨텍스트 (내부 사용) */\n __translationContext?: any;\n}\n\n/**\n * Modal 집합 컴포넌트\n *\n * Div + Button 기본 컴포넌트를 조합하여 모달 UI를 구성합니다.\n * ESC 키 닫기, 포커스 트랩, 오버레이 클릭 닫기 기능을 포함합니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"Modal\",\n * \"props\": {\n * \"isOpen\": \"{{modals.userEdit}}\",\n * \"title\": \"사용자 편집\",\n * \"width\": \"600px\"\n * },\n * \"children\": [...]\n * }\n */\nexport const Modal: React.FC = ({\n isOpen,\n onClose,\n title,\n titlePrefix,\n titleSuffix,\n closeOnBackdropClick = true,\n closeOnOverlayClick,\n showCloseButton = true,\n closeOnEscape = true,\n children,\n width = '500px',\n className = '',\n style,\n __dataContext,\n __translationContext,\n}) => {\n // closeOnOverlayClick 별칭 지원 (레이아웃 JSON 호환)\n const shouldCloseOnBackdrop = closeOnOverlayClick !== undefined\n ? closeOnOverlayClick\n : closeOnBackdropClick;\n const modalRef = useRef(null);\n const previousFocusRef = useRef(null);\n\n // ESC 키로 모달 닫기\n useEffect(() => {\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && isOpen && closeOnEscape) {\n onClose();\n }\n };\n\n if (isOpen && closeOnEscape) {\n document.addEventListener('keydown', handleEscape);\n return () => document.removeEventListener('keydown', handleEscape);\n }\n }, [isOpen, onClose, closeOnEscape]);\n\n // 포커스 트랩\n useEffect(() => {\n if (isOpen && modalRef.current) {\n // 모달 열릴 때 현재 포커스 저장\n previousFocusRef.current = document.activeElement as HTMLElement;\n\n // 모달 내부 포커스 가능한 요소들\n const focusableElements = modalRef.current.querySelectorAll(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n );\n\n const firstElement = focusableElements[0] as HTMLElement;\n const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;\n\n // 첫 번째 요소에 포커스\n firstElement?.focus();\n\n // Tab 키 핸들러 (포커스 트랩)\n const handleTab = (event: KeyboardEvent) => {\n if (event.key !== 'Tab') return;\n\n if (event.shiftKey) {\n // Shift + Tab\n if (document.activeElement === firstElement) {\n event.preventDefault();\n lastElement?.focus();\n }\n } else {\n // Tab\n if (document.activeElement === lastElement) {\n event.preventDefault();\n firstElement?.focus();\n }\n }\n };\n\n document.addEventListener('keydown', handleTab);\n\n return () => {\n document.removeEventListener('keydown', handleTab);\n // 모달 닫힐 때 이전 포커스 복원\n previousFocusRef.current?.focus();\n };\n }\n }, [isOpen]);\n\n // body 스크롤 방지\n useEffect(() => {\n if (isOpen) {\n document.body.style.overflow = 'hidden';\n return () => {\n document.body.style.overflow = '';\n };\n }\n }, [isOpen]);\n\n if (!isOpen) return null;\n\n // z-index를 style에서 가져오거나 기본값 50 사용\n const zIndex = style?.zIndex ?? 50;\n\n // titlePrefix/titleSuffix 렌더링 헬퍼\n const renderTitleComponents = (components: any[] | undefined, keyPrefix: string) => {\n if (!components || components.length === 0) return null;\n\n const g7Core = (window as any).G7Core;\n if (!g7Core) return null;\n\n const DynamicRenderer = g7Core.getDynamicRenderer?.();\n if (!DynamicRenderer) return null;\n\n return components.map((componentDef, index) => (\n \n ));\n };\n\n return (\n \n {/* Overlay */}\n \n\n {/* Modal Content */}\n e.stopPropagation()}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? 'modal-title' : undefined}\n >\n {/* Header */}\n {(title || titlePrefix || titleSuffix || showCloseButton) && (\n
\n
\n {/* titlePrefix: 타이틀 앞에 렌더링 */}\n {renderTitleComponents(titlePrefix, 'title-prefix')}\n {title && (\n \n {title}\n
\n )}\n {/* titleSuffix: 타이틀 뒤에 렌더링 */}\n {renderTitleComponents(titleSuffix, 'title-suffix')}\n
\n {showCloseButton && (\n \n \n \n \n \n )}\n \n )}\n\n {/* Body */}\n
\n {children}\n
\n \n \n );\n};\n","import React, { useState, useRef, useEffect } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport type { EditorAttrs } from '../../types';\n\nexport interface DropdownItem {\n label: string;\n value: string;\n onClick?: () => void;\n disabled?: boolean;\n}\n\nexport interface DropdownProps {\n label: string;\n items: DropdownItem[];\n /** 아이템 클릭 시 호출되는 콜백. value 문자열을 전달합니다. */\n onItemClick?: (value: string, item: DropdownItem) => void;\n position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';\n /** 드롭다운 스타일 변형 */\n variant?: 'default' | 'text';\n className?: string;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * Dropdown 집합 컴포넌트\n *\n * Button + Div 기본 컴포넌트를 조합하여 드롭다운 메뉴 UI를 구성합니다.\n * 키보드 네비게이션 (Arrow Up/Down, Enter, Escape) 지원, 외부 클릭 감지 기능을 포함합니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"Dropdown\",\n * \"props\": {\n * \"label\": \"작업\",\n * \"items\": [\n * {\"label\": \"수정\", \"value\": \"edit\"},\n * {\"label\": \"삭제\", \"value\": \"delete\"}\n * ]\n * }\n * }\n */\nexport const Dropdown: React.FC = ({\n label,\n items,\n onItemClick,\n position = 'bottom-left',\n variant = 'default',\n className = '',\n style,\n id,\n editorAttrs,\n}) => {\n const [isOpen, setIsOpen] = useState(false);\n const [focusedIndex, setFocusedIndex] = useState(-1);\n const dropdownRef = useRef(null);\n const menuRef = useRef(null);\n const [adjustedPosition, setAdjustedPosition] = useState(position);\n\n // 외부 클릭 감지\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n setIsOpen(false);\n setFocusedIndex(-1);\n }\n };\n\n if (isOpen) {\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }\n }, [isOpen]);\n\n // 키보드 네비게이션\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (!isOpen) return;\n\n const enabledIndexes = items\n .map((item, index) => (!item.disabled ? index : -1))\n .filter((index) => index !== -1);\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n setFocusedIndex((prev) => {\n const currentEnabledIndex = enabledIndexes.indexOf(prev);\n const nextEnabledIndex = (currentEnabledIndex + 1) % enabledIndexes.length;\n return enabledIndexes[nextEnabledIndex];\n });\n break;\n\n case 'ArrowUp':\n event.preventDefault();\n setFocusedIndex((prev) => {\n const currentEnabledIndex = enabledIndexes.indexOf(prev);\n const prevEnabledIndex =\n currentEnabledIndex <= 0 ? enabledIndexes.length - 1 : currentEnabledIndex - 1;\n return enabledIndexes[prevEnabledIndex];\n });\n break;\n\n case 'Enter':\n event.preventDefault();\n if (focusedIndex >= 0 && !items[focusedIndex].disabled) {\n handleItemClick(items[focusedIndex]);\n }\n break;\n\n case 'Escape':\n event.preventDefault();\n setIsOpen(false);\n setFocusedIndex(-1);\n break;\n }\n };\n\n if (isOpen) {\n document.addEventListener('keydown', handleKeyDown);\n return () => document.removeEventListener('keydown', handleKeyDown);\n }\n }, [isOpen, focusedIndex, items]);\n\n // 드롭다운 메뉴가 화면을 벗어나는지 감지하고 위치 조정\n useEffect(() => {\n if (isOpen && menuRef.current && dropdownRef.current) {\n const menuRect = menuRef.current.getBoundingClientRect();\n const triggerRect = dropdownRef.current.getBoundingClientRect();\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n\n // 메뉴 크기 (w-56 = 14rem = 224px)\n const menuWidth = menuRect.width || 224;\n const menuHeight = menuRect.height || 200;\n\n let newPosition = position;\n\n // 수평 위치 조정\n const isLeftAligned = position.includes('left');\n if (isLeftAligned) {\n // left 정렬: 메뉴가 버튼 왼쪽 끝에서 오른쪽으로 펼쳐짐\n const menuRight = triggerRect.left + menuWidth;\n if (menuRight > viewportWidth) {\n newPosition = newPosition.replace('left', 'right') as typeof position;\n }\n } else {\n // right 정렬: 메뉴가 버튼 오른쪽 끝에서 왼쪽으로 펼쳐짐\n const menuLeft = triggerRect.right - menuWidth;\n if (menuLeft < 0) {\n newPosition = newPosition.replace('right', 'left') as typeof position;\n }\n }\n\n // 수직 위치 조정\n const isTopAligned = position.includes('top');\n if (isTopAligned) {\n // top 정렬: 메뉴가 버튼 위로 펼쳐짐\n if (triggerRect.top - menuHeight < 0) {\n newPosition = newPosition.replace('top', 'bottom') as typeof position;\n }\n } else {\n // bottom 정렬: 메뉴가 버튼 아래로 펼쳐짐\n if (triggerRect.bottom + menuHeight > viewportHeight) {\n newPosition = newPosition.replace('bottom', 'top') as typeof position;\n }\n }\n\n setAdjustedPosition(newPosition);\n }\n }, [isOpen, position]);\n\n const handleItemClick = (item: DropdownItem) => {\n if (item.disabled) return;\n\n item.onClick?.();\n // value를 첫 번째 인자로 전달하여 switch 핸들러와 호환\n onItemClick?.(item.value, item);\n setIsOpen(false);\n setFocusedIndex(-1);\n };\n\n const positionClasses = {\n 'bottom-left': 'dropdown-menu-bottom-left',\n 'bottom-right': 'dropdown-menu-bottom-right',\n 'top-left': 'dropdown-menu-top-left',\n 'top-right': 'dropdown-menu-top-right',\n };\n\n return (\n \n {/* Trigger Button */}\n setIsOpen(!isOpen)}\n className={variant === 'text' ? 'dropdown-trigger-text' : 'dropdown-trigger'}\n aria-haspopup=\"true\"\n aria-expanded={isOpen}\n >\n {label}\n {variant !== 'text' && (\n \n \n \n )}\n \n\n {/* Dropdown Menu */}\n {isOpen && (\n \n
\n {items.map((item, index) => (\n handleItemClick(item)}\n onMouseEnter={() => !item.disabled && setFocusedIndex(index)}\n role=\"menuitem\"\n aria-disabled={item.disabled}\n tabIndex={item.disabled ? -1 : 0}\n >\n {item.label}\n
\n ))}\n \n \n )}\n \n );\n};\n","// e2e:allow편집기 추가 직후 price 미지정 시 크래시(\"컴포넌트 로드 실패\") 결함#5 수정(price 폴백). 라이브 검증은 Chrome MCP T1~T7(에디터 추가/속성편집/저장200/reload영속/게스트 사용자화면 ₩55000 렌더)로 수행, 단위 회귀는 ProductCard.test.tsx 결함#5 describe.\nimport React from 'react';\nimport { Div } from '../basic/Div';\nimport { Img } from '../basic/Img';\nimport { H2 } from '../basic/H2';\nimport { H3 } from '../basic/H3';\nimport { P } from '../basic/P';\nimport { Span } from '../basic/Span';\nimport { Button } from '../basic/Button';\nimport type { EditorAttrs } from '../../types';\n\nexport interface ProductCardProps {\n imageUrl?: string;\n imageAlt?: string;\n title: string;\n subtitle?: string;\n description?: string;\n price: number;\n originalPrice?: number;\n discountRate?: number;\n currency?: string;\n buttonText?: string;\n onButtonClick?: () => void;\n className?: string;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * ProductCard 집합 컴포넌트\n *\n * 상품 정보를 표시하는 카드 컴포넌트입니다.\n * 이미지, 제목, 부제목, 설명, 가격, 할인율, 액션 버튼을 포함합니다.\n *\n * 기본 컴포넌트 조합: Div + Img + H2 + H3 + P + Span + Button\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"ProductCard\",\n * \"props\": {\n * \"imageUrl\": \"/product.png\",\n * \"title\": \"프리미엄 제품\",\n * \"subtitle\": \"신제품\",\n * \"description\": \"최고급 소재로 제작된 프리미엄 제품입니다.\",\n * \"price\": 45000,\n * \"originalPrice\": 50000,\n * \"discountRate\": 10,\n * \"buttonText\": \"구매하기\"\n * }\n * }\n */\nexport const ProductCard: React.FC = ({\n imageUrl,\n imageAlt = '',\n title,\n subtitle,\n description,\n price,\n originalPrice,\n discountRate,\n currency = '₩',\n buttonText = '자세히 보기',\n onButtonClick,\n className = '',\n style,\n id,\n editorAttrs,\n}) => {\n const hasDiscount = discountRate && discountRate > 0;\n // price 미지정 시에도 크래시하지 않도록 0 으로 폴백한다(레이아웃 편집기에서 추가 직후\n // 작성자가 price 를 지정하기 전 상태를 안전하게 렌더).\n const formattedPrice = (price ?? 0).toLocaleString();\n const formattedOriginalPrice = originalPrice?.toLocaleString();\n\n return (\n \n {/* 상품 이미지 */}\n {imageUrl && (\n
\n \n {hasDiscount && (\n
\n -{discountRate}%\n
\n )}\n
\n )}\n\n {/* 상품 정보 */}\n
\n {/* 부제목 (태그/카테고리) */}\n {subtitle && (\n

\n {subtitle}\n

\n )}\n\n {/* 제목 */}\n

\n {title}\n

\n\n {/* 설명 */}\n {description && (\n

\n {description}\n

\n )}\n\n {/* 가격 정보 */}\n
\n \n {currency}{formattedPrice}\n \n {originalPrice && (\n \n {currency}{formattedOriginalPrice}\n \n )}\n
\n\n {/* 액션 버튼 */}\n {buttonText && (\n \n {buttonText}\n \n )}\n
\n \n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { H3 } from '../basic/H3';\nimport { Span } from '../basic/Span';\nimport { P } from '../basic/P';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\nexport interface StatCardProps {\n value: string | number;\n label: string;\n change?: number;\n changeLabel?: string;\n iconName?: IconName;\n trend?: 'up' | 'down' | 'neutral';\n className?: string;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * StatCard 집합 컴포넌트\n *\n * 통계 수치와 변화율을 시각화하는 카드 컴포넌트입니다.\n * 수치, 라벨, 변화율(증가/감소), 아이콘을 표시합니다.\n *\n * 기본 컴포넌트 조합: Div + H3 + Span + P + Icon\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"StatCard\",\n * \"props\": {\n * \"value\": 12345,\n * \"label\": \"총 사용자\",\n * \"change\": 12.5,\n * \"changeLabel\": \"지난달 대비\",\n * \"iconName\": \"users\",\n * \"trend\": \"up\"\n * }\n * }\n */\nexport const StatCard: React.FC = ({\n value,\n label,\n change,\n changeLabel = '전월 대비',\n iconName,\n trend = 'neutral',\n className = '',\n style,\n id,\n editorAttrs,\n}) => {\n const isPositive = trend === 'up';\n const isNegative = trend === 'down';\n const changeValue = change !== undefined ? Math.abs(change) : 0;\n\n const trendClass = isPositive\n ? 'stats-trend-up'\n : isNegative\n ? 'stats-trend-down'\n : 'stats-trend-neutral';\n\n const trendIcon = isPositive ? IconName.ArrowUp : isNegative ? IconName.ArrowDown : undefined;\n\n return (\n \n {/* 헤더: 아이콘과 변화율 */}\n
\n {iconName && (\n
\n \n
\n )}\n\n {change !== undefined && (\n
\n {trendIcon && (\n \n )}\n \n {changeValue}%\n \n
\n )}\n
\n\n {/* 통계 수치 */}\n

\n {typeof value === 'number' ? value.toLocaleString() : value}\n

\n\n {/* 라벨 */}\n

\n {label}\n

\n\n {/* 변화율 설명 */}\n {change !== undefined && changeLabel && (\n
\n \n {changeLabel}\n \n
\n )}\n \n );\n};\n","import React, { useState, useEffect } from 'react';\nimport { Form } from '../basic/Form';\nimport { Input } from '../basic/Input';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core 참조\nconst G7Core = () => (window as any).G7Core;\nconst t = (key: string, params?: Record) =>\n G7Core()?.t?.(key, params) ?? key;\n\nexport interface SearchSuggestion {\n id: string | number;\n text: string;\n}\n\nexport interface SearchBarProps {\n name?: string;\n placeholder?: string;\n value?: string;\n onChange?: (e: React.ChangeEvent) => void;\n onSubmit?: (e: React.FormEvent) => void;\n showButton?: boolean; // true: 검색 버튼 표시, false: 버튼 숨김 (기본값: false)\n suggestions?: SearchSuggestion[];\n onSuggestionClick?: (suggestion: SearchSuggestion) => void;\n showSuggestions?: boolean;\n className?: string; // 외부 className (w-full 등)은 G7Core.style.mergeClasses로 내부 기본 클래스를 오버라이드\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * SearchBar 집합 컴포넌트\n *\n * 검색 입력 필드와 선택적 버튼을 제공하는 검색 바 컴포넌트입니다.\n * Enter 키를 누르면 항상 검색이 실행됩니다.\n *\n * 기본 컴포넌트 조합: Form + Input + Button + Icon + Div + Span\n *\n * @example\n * // 레이아웃 JSON 사용 예시 (버튼 없음)\n * {\n * \"name\": \"SearchBar\",\n * \"props\": {\n * \"placeholder\": \"검색어를 입력하세요\",\n * \"value\": \"{{query.search}}\",\n * \"showButton\": false\n * }\n * }\n *\n * @example\n * // 레이아웃 JSON 사용 예시 (버튼 있음)\n * {\n * \"name\": \"SearchBar\",\n * \"props\": {\n * \"placeholder\": \"검색어를 입력하세요\",\n * \"value\": \"{{query.search}}\",\n * \"showButton\": true\n * }\n * }\n *\n * @example\n * // 레이아웃 JSON 사용 예시 (전체 너비 - className으로 오버라이드)\n * {\n * \"name\": \"SearchBar\",\n * \"props\": {\n * \"placeholder\": \"검색어를 입력하세요\",\n * \"value\": \"{{query.search}}\",\n * \"className\": \"w-full\"\n * }\n * }\n */\nexport const SearchBar: React.FC = ({\n name = 'search',\n placeholder,\n value: controlledValue,\n onChange,\n onSubmit,\n showButton = false,\n suggestions = [],\n onSuggestionClick,\n showSuggestions = false,\n className = '',\n style,\n id,\n editorAttrs,\n}) => {\n // props로 전달된 값이 없으면 다국어 키 사용\n const resolvedPlaceholder = placeholder ?? t('common.search_placeholder');\n\n const [internalValue, setInternalValue] = useState('');\n const [isFocused, setIsFocused] = useState(false);\n const containerRef = React.useRef(null);\n const previousValueRef = React.useRef('');\n\n const value = controlledValue !== undefined ? controlledValue : internalValue;\n const shouldShowSuggestions = showSuggestions && isFocused && suggestions.length > 0 && value.length > 0;\n\n // 기본 Input 클래스 (w-64 포함)\n const baseInputClasses = 'w-64 pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm';\n // 외부 className으로 기본 클래스 오버라이드 (예: w-full이 w-64를 대체)\n const inputClassName = G7Core()?.style?.mergeClasses?.(baseInputClasses, className) ?? `${baseInputClasses} ${className}`;\n\n // controlledValue가 변경될 때 previousValueRef 동기화\n useEffect(() => {\n if (controlledValue !== undefined) {\n previousValueRef.current = controlledValue;\n }\n }, [controlledValue]);\n\n const handleChange = (e: React.ChangeEvent) => {\n const newValue = e.target.value;\n setInternalValue(newValue);\n onChange?.(e);\n\n // X 버튼으로 검색어를 지운 경우 감지\n // 이전 값이 있었는데 새 값이 빈 문자열이고, nativeEvent가 없는 경우 = X 버튼 클릭\n if (previousValueRef.current !== '' && newValue === '' && !(e.nativeEvent as any).inputType) {\n // X 버튼 클릭: 자동으로 검색 초기화\n setTimeout(() => {\n const formElement = containerRef.current?.querySelector('form');\n if (formElement) {\n formElement.requestSubmit();\n }\n }, 0);\n }\n\n previousValueRef.current = newValue;\n };\n\n const handleSuggestionClick = (suggestion: SearchSuggestion) => {\n setInternalValue(suggestion.text);\n const syntheticEvent = {\n target: { value: suggestion.text, name },\n } as React.ChangeEvent;\n onChange?.(syntheticEvent);\n onSuggestionClick?.(suggestion);\n setIsFocused(false);\n };\n\n const handleSubmit = (e: React.FormEvent) => {\n e.preventDefault();\n onSubmit?.(e);\n };\n\n return (\n
\n
\n
\n {/* Input wrapper */}\n
\n {/* 검색 아이콘 */}\n
\n \n
\n\n {/* 검색 입력 */}\n setIsFocused(true)}\n onBlur={() => setTimeout(() => setIsFocused(false), 200)}\n placeholder={resolvedPlaceholder}\n className={inputClassName}\n />\n
\n\n {/* 검색 버튼 (showButton=true일 때만 표시) */}\n {showButton && (\n \n {t('common.search')}\n \n )}\n
\n
\n\n {/* 자동완성 제안 목록 */}\n {shouldShowSuggestions && (\n
\n {suggestions.map((suggestion) => (\n handleSuggestionClick(suggestion)}\n >\n
\n \n {suggestion.text}\n
\n
\n ))}\n
\n )}\n \n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { Label } from '../basic/Label';\nimport { Select } from '../basic/Select';\nimport { Checkbox } from '../basic/Checkbox';\nimport { Span } from '../basic/Span';\nimport { Button } from '../basic/Button';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\nexport interface FilterOption {\n id: string | number;\n label: string;\n value: string | number;\n}\n\nexport interface Filter {\n id: string;\n label: string;\n type: 'select' | 'checkbox';\n options: FilterOption[];\n value?: string | number | string[] | number[];\n}\n\nexport interface FilterGroupProps {\n title?: string;\n filters: Filter[];\n onChange?: (filterId: string, value: string | number | string[] | number[] | boolean) => void;\n onReset?: () => void;\n showResetButton?: boolean;\n className?: string;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * FilterGroup 집합 컴포넌트\n *\n * 다중 필터 조합을 지원하는 필터 그룹 컴포넌트입니다.\n * Select, Checkbox 타입의 필터들을 조합하여 표시합니다.\n *\n * 기본 컴포넌트 조합: Div + Label + Select + Checkbox + Span + Button\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"FilterGroup\",\n * \"props\": {\n * \"title\": \"$t:common.filter\",\n * \"filters\": [\n * {\n * \"id\": \"category\",\n * \"label\": \"카테고리\",\n * \"type\": \"select\",\n * \"options\": [\n * {\"id\": 1, \"label\": \"전자기기\", \"value\": \"electronics\"},\n * {\"id\": 2, \"label\": \"의류\", \"value\": \"clothing\"}\n * ]\n * }\n * ]\n * }\n * }\n */\nexport const FilterGroup: React.FC = ({\n title,\n filters,\n onChange,\n onReset,\n showResetButton = true,\n className = '',\n style,\n id,\n editorAttrs,\n}) => {\n // props로 전달된 값이 없으면 다국어 키 사용\n const resolvedTitle = title ?? t('common.filter');\n\n const handleSelectChange = (filterId: string, value: string | number) => {\n onChange?.(filterId, value);\n };\n\n const handleCheckboxChange = (filterId: string, optionValue: string | number, checked: boolean) => {\n const filter = filters.find(f => f.id === filterId);\n if (!filter) return;\n\n const currentValues = Array.isArray(filter.value) ? filter.value : [];\n\n // 타입에 따라 분리하여 처리\n if (typeof optionValue === 'string') {\n const stringValues = currentValues.filter((v): v is string => typeof v === 'string');\n const newValues = checked\n ? [...stringValues, optionValue]\n : stringValues.filter(v => v !== optionValue);\n onChange?.(filterId, newValues);\n } else {\n const numberValues = currentValues.filter((v): v is number => typeof v === 'number');\n const newValues = checked\n ? [...numberValues, optionValue]\n : numberValues.filter(v => v !== optionValue);\n onChange?.(filterId, newValues);\n }\n };\n\n return (\n \n {/* 헤더 */}\n
\n \n {resolvedTitle}\n \n {showResetButton && onReset && (\n \n {t('common.reset')}\n \n )}\n
\n\n {/* 필터 목록 */}\n
\n {filters.map((filter) => (\n
\n \n\n {/* Select 타입 필터 */}\n {filter.type === 'select' && (\n handleSelectChange(filter.id, e.target.value)}\n className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white\"\n >\n \n {filter.options.map((option) => (\n \n ))}\n \n )}\n\n {/* Checkbox 타입 필터 */}\n {filter.type === 'checkbox' && (\n
\n {filter.options.map((option) => {\n const isChecked = Array.isArray(filter.value)\n ? filter.value.some(v => v === option.value)\n : false;\n\n return (\n \n \n handleCheckboxChange(filter.id, option.value, e.target.checked)\n }\n className=\"w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500\"\n />\n \n {option.label}\n \n \n );\n })}\n
\n )}\n
\n ))}\n
\n \n );\n};\n","import React from 'react';\nimport { Nav } from '../basic/Nav';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport { Select } from '../basic/Select';\nimport type { EditorAttrs } from '../../types';\n\nexport interface Tab {\n id: string | number;\n label: string;\n iconName?: IconName;\n disabled?: boolean;\n badge?: string | number;\n}\n\nexport interface TabNavigationProps {\n tabs: Tab[];\n activeTabId?: string | number;\n onTabChange?: (tabId: string | number) => void;\n variant?: 'default' | 'pills' | 'underline';\n className?: string;\n style?: React.CSSProperties;\n /** 모바일 전환 임계값 (px). 기본값 768 — G7 ResponsiveContext mobile 프리셋과 동일 */\n mobileBreakpoint?: number;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * TabNavigation 집합 컴포넌트\n *\n * 탭 네비게이션을 제공하는 컴포넌트입니다.\n * 여러 탭을 전환할 수 있으며, 아이콘과 뱃지를 지원합니다.\n *\n * 반응형: G7Core.useResponsive() hook으로 화면 너비를 구독하여\n * mobileBreakpoint(기본 768px) 미만일 때 Select 드롭다운으로 자동 전환됩니다.\n * Tailwind hidden md:flex 분기를 사용하지 않으므로 위지윅 편집기의 디바이스 미리보기와도 호환됩니다.\n *\n * **주의**: 이 컴포넌트는 순수 네비게이션 UI만 제공하며,\n * 실제 탭 컨텐츠는 부모 컴포넌트에서 activeTabId를 기반으로 조건부 렌더링해야 합니다.\n *\n * 기본 컴포넌트 조합: Nav + Button + Icon + Div + Span + Select\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"TabNavigation\",\n * \"props\": {\n * \"activeTabId\": 1,\n * \"tabs\": [\n * {\"id\": 1, \"label\": \"프로필\", \"iconName\": \"user\"},\n * {\"id\": 2, \"label\": \"설정\", \"iconName\": \"cog\", \"badge\": 3}\n * ]\n * }\n * }\n */\nexport const TabNavigation: React.FC = ({\n tabs,\n activeTabId,\n onTabChange,\n variant = 'default',\n className = '',\n style,\n mobileBreakpoint = 768,\n id,\n editorAttrs,\n}) => {\n // G7Core.useResponsive를 통해 반응형 상태 구독 (G7 표준)\n const G7Core = (window as any).G7Core;\n const useResponsive = G7Core?.useResponsive;\n const responsiveValue = useResponsive?.();\n const isMobile = responsiveValue\n ? responsiveValue.width < mobileBreakpoint\n : typeof window !== 'undefined' && window.innerWidth < mobileBreakpoint;\n\n const handleTabClick = (tab: Tab) => {\n if (!tab.disabled && tab.id !== activeTabId) {\n onTabChange?.(tab.id);\n }\n };\n\n const handleSelectChange = (\n e: React.ChangeEvent | { target: { value: string | number } }\n ) => {\n const selectedId = e.target.value;\n const numericId = Number(selectedId);\n const finalId =\n typeof selectedId === 'string' && selectedId !== '' && !Number.isNaN(numericId)\n ? numericId\n : selectedId;\n const selectedTab = tabs.find((tab) => String(tab.id) === String(finalId));\n if (selectedTab) {\n handleTabClick(selectedTab);\n }\n };\n\n // 모바일: Select 드롭다운 단일 렌더\n if (isMobile) {\n return (\n
\n ({\n value: String(tab.id),\n label: tab.badge !== undefined ? `${tab.label} (${tab.badge})` : tab.label,\n disabled: tab.disabled,\n }))}\n className=\"w-full\"\n />\n
\n );\n }\n\n const getTabClasses = (tab: Tab) => {\n const isActive = tab.id === activeTabId;\n\n if (tab.disabled) {\n return 'tab-btn-base tab-btn-disabled';\n }\n\n switch (variant) {\n case 'pills':\n return isActive ? 'tab-btn-base tab-btn-pills-active' : 'tab-btn-base tab-btn-pills';\n\n case 'underline':\n return isActive\n ? 'tab-btn-base tab-btn-underline-active'\n : 'tab-btn-base tab-btn-underline';\n\n case 'default':\n default:\n return isActive\n ? 'tab-btn-base tab-btn-default-active'\n : 'tab-btn-base tab-btn-default';\n }\n };\n\n const navClasses = variant === 'underline' ? 'tab-nav-underline' : 'tab-nav-default';\n\n // 레이아웃 편집기 인플레이스 오버레이용 탭별 측정 마커(편집 모드 전용).\n // 탭은 자식 노드가 아니라 `props.tabs` 배열이라 `data-editor-path` 가 없다. 편집기 코어가\n // 각 탭 헤더 박스를 측정해 인플레이스 오버레이(+추가/✕삭제/정렬)에 넘길 수 있도록, 각 탭\n // 버튼에 `data-editor-item-path=\"<자기 path>.props.tabs.\"` 를 부여한다. 런타임에는\n // editorAttrs 가 주입되지 않으므로(undefined) 본 마커도 렌더되지 않아 사용자 페이지 무영향.\n const editorNodePath = editorAttrs?.['data-editor-path'];\n\n // 데스크톱: 탭 버튼 단일 렌더\n return (\n \n {tabs.map((tab, tabIndex) => (\n handleTabClick(tab)}\n disabled={tab.disabled}\n className={getTabClasses(tab)}\n {...(editorNodePath\n ? { 'data-editor-item-path': `${editorNodePath}.props.tabs.${tabIndex}` }\n : {})}\n >\n {tab.iconName && (\n \n )}\n\n {tab.label}\n\n {tab.badge !== undefined && (\n
\n {tab.badge}\n
\n )}\n \n ))}\n \n );\n};\n","/**\n * scrollToSection 핸들러\n *\n * 특정 섹션으로 부드럽게 스크롤 이동합니다.\n * - Sticky 헤더를 고려한 offset 적용 지원\n * - React 조건부 렌더링 대응 (재시도 로직)\n * - 페이지/컨테이너 스크롤 자동 감지\n *\n * @param action 액션 정의 (params: targetId, offset, delay, scrollContainerId)\n * @param _context 액션 컨텍스트 (미사용)\n */\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Handler:ScrollToSection')) ?? {\n log: (...args: unknown[]) => console.log('[Handler:ScrollToSection]', ...args),\n warn: (...args: unknown[]) => console.warn('[Handler:ScrollToSection]', ...args),\n error: (...args: unknown[]) => console.error('[Handler:ScrollToSection]', ...args),\n};\n\nexport async function scrollToSectionHandler(\n action: any,\n _context?: any\n): Promise {\n const params = action.params || {};\n const {\n targetId,\n offset = 120,\n delay = 100,\n scrollContainerId,\n } = params as {\n targetId?: string;\n offset?: number;\n delay?: number;\n scrollContainerId?: string;\n };\n\n if (!targetId) {\n logger.warn('targetId is required');\n return;\n }\n\n // 1. 대상 요소 찾기 (React 렌더링 대기 + 재시도)\n const element = await findElementWithRetry(targetId, delay);\n if (!element) {\n logger.warn(`Element not found: ${targetId}`);\n return;\n }\n\n // 2. 스크롤 컨테이너 결정 (명시적 지정 or 자동 검색 or window)\n const scrollContainer = findScrollContainer(element, scrollContainerId);\n\n // 3. 스크롤 위치 계산 및 실행\n const offsetPosition = calculateScrollPosition(element, scrollContainer, offset);\n performScroll(scrollContainer, offsetPosition);\n}\n\n/**\n * 요소를 찾을 때까지 재시도\n * React 조건부 렌더링으로 요소가 늦게 생성될 수 있음\n */\nasync function findElementWithRetry(\n targetId: string,\n initialDelay: number\n): Promise {\n await new Promise((resolve) => setTimeout(resolve, initialDelay));\n\n const maxRetries = 10;\n const retryInterval = 50;\n\n for (let i = 0; i < maxRetries; i++) {\n const element = document.getElementById(targetId);\n if (element) {\n // 요소 발견 후 추가 렌더링 대기\n await new Promise((resolve) => setTimeout(resolve, 50));\n return element;\n }\n await new Promise((resolve) => setTimeout(resolve, retryInterval));\n }\n\n return null;\n}\n\n/**\n * 실제 스크롤 컨테이너 찾기\n * 1. scrollContainerId 파라미터가 있으면 해당 요소 검증 후 사용\n * 2. scrollContainerId가 없거나 유효하지 않으면 부모 요소 중 스크롤 가능한 요소 검색\n * 3. 찾지 못하면 페이지 스크롤 사용 (window)\n */\nfunction findScrollContainer(\n element: HTMLElement,\n scrollContainerId?: string\n): HTMLElement | Window {\n // 1. 명시적으로 지정된 경우 - 검증 후 사용\n if (scrollContainerId) {\n const container = document.getElementById(scrollContainerId);\n if (container && isScrollable(container)) {\n return container;\n }\n logger.warn(\n `Container '${scrollContainerId}' ${container ? 'has no scroll' : 'not found'}, trying auto-detect`\n );\n }\n\n // 2. 부모 요소 중 스크롤 가능한 요소 검색 (최대 10단계)\n let currentElement = element.parentElement;\n let depth = 0;\n const maxDepth = 10;\n\n while (currentElement && currentElement !== document.body && depth < maxDepth) {\n if (isScrollable(currentElement)) {\n return currentElement;\n }\n currentElement = currentElement.parentElement;\n depth++;\n }\n\n // 3. 스크롤 컨테이너를 찾지 못했으면 window 사용\n return window;\n}\n\n/**\n * 요소가 스크롤 가능한지 확인\n */\nfunction isScrollable(element: HTMLElement): boolean {\n const computedStyle = window.getComputedStyle(element);\n return (\n (computedStyle.overflowY === 'auto' || computedStyle.overflowY === 'scroll') &&\n element.scrollHeight > element.clientHeight\n );\n}\n\n/**\n * 스크롤 위치 계산\n */\nfunction calculateScrollPosition(\n element: HTMLElement,\n scrollContainer: HTMLElement | Window,\n offset: number\n): number {\n if (scrollContainer === window || !('getBoundingClientRect' in scrollContainer)) {\n // Window 스크롤\n const viewportTop = element.getBoundingClientRect().top;\n const documentTop = viewportTop + window.pageYOffset;\n return documentTop - offset;\n } else {\n // 컨테이너 내부 스크롤\n const containerRect = scrollContainer.getBoundingClientRect();\n const elementRect = element.getBoundingClientRect();\n return scrollContainer.scrollTop + (elementRect.top - containerRect.top) - offset;\n }\n}\n\n/**\n * 스크롤 실행\n */\nfunction performScroll(\n scrollContainer: HTMLElement | Window,\n offsetPosition: number\n): void {\n if (scrollContainer === window || !('scrollTo' in scrollContainer)) {\n window.scrollTo({ top: offsetPosition, behavior: 'smooth' });\n } else {\n scrollContainer.scrollTo({ top: offsetPosition, behavior: 'smooth' });\n }\n}","import React, { useState, useEffect, useRef } from 'react';\nimport { Button } from '../basic/Button';\nimport { Div } from '../basic/Div';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Span } from '../basic/Span';\nimport { Select } from '../basic/Select';\nimport { scrollToSectionHandler } from '../../handlers/scrollToSectionHandler';\nimport type { EditorAttrs } from '../../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:TabNavigation')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:TabNavigation]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:TabNavigation]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:TabNavigation]', ...args),\n};\n\n/**\n * 탭 정의 인터페이스\n */\nexport interface Tab {\n id: string | number;\n label: string;\n /** 탭 아이콘 (Font Awesome 아이콘명) */\n iconName?: IconName;\n disabled?: boolean;\n /** 개별 탭의 스크롤 오프셋 (우선순위: 개별 > 공통 > 기본값) */\n scrollOffset?: number;\n /** 개별 탭의 스크롤 딜레이 (우선순위: 개별 > 공통 > 기본값) */\n scrollDelay?: number;\n /** 개별 탭 클릭 시 실행될 콜백 (우선순위: 개별 > 공통 > 기본) */\n onClick?: (tabId: string | number) => void;\n}\n\n/**\n * TabNavigationScroll Props\n */\nexport interface TabNavigationScrollProps {\n /** 탭 목록 */\n tabs: Tab[];\n /** 활성 탭 ID (초기값으로만 사용) */\n activeTabId?: string | number;\n /** 컨테이너 className */\n className?: string;\n /** 컨테이너 스타일 */\n style?: React.CSSProperties;\n /** Active 탭 className */\n activeClassName?: string;\n /** Inactive 탭 className */\n inactiveClassName?: string;\n /** 섹션 ID 접두사 */\n sectionIdPrefix?: string;\n /** 스크롤 오프셋 */\n scrollOffset?: number;\n /** 스크롤 딜레이 */\n scrollDelay?: number;\n /** 탭 변경 시 실행될 공통 콜백 (우선순위: 개별 탭 onClick > onTabChange > 기본) */\n onTabChange?: (tabId: string | number) => void;\n /** Scroll Spy 활성화 여부 (스크롤 시 자동으로 탭 활성화) */\n enableScrollSpy?: boolean;\n /** Scroll Spy 감지 오프셋 (기본값: 80) */\n scrollSpyOffset?: number;\n /** 스크롤 컨테이너 ID (IntersectionObserver root 및 scrollToSection 대상, 미지정 시 window) */\n scrollContainerId?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * 탭 네비게이션 컴포넌트 (자동 스크롤 기능 내장)\n *\n * 특징:\n * - 내부 상태로 활성 탭 관리 (React useState)\n * - scrollToSectionHandler를 사용하여 자동 스크롤\n * - 탭 클릭 시 즉시 UI 업데이트 + 스크롤 이동\n * - 반응형: G7Core.useResponsive() hook으로 화면 너비를 구독하여 768px 미만 시\n * Select 드롭다운으로 자동 전환 (Tailwind hidden md:flex 분기 미사용 — 위지윅 미리보기 호환)\n *\n * @example\n * ```json\n * {\n * \"type\": \"composite\",\n * \"name\": \"TabNavigationScroll\",\n * \"props\": {\n * \"tabs\": [\n * {\"id\": \"basic\", \"label\": \"$t:admin.tabs.basic\"},\n * {\"id\": \"permissions\", \"label\": \"$t:admin.tabs.permissions\"}\n * ]\n * }\n * }\n * ```\n */\nexport const TabNavigationScroll: React.FC = ({\n tabs,\n activeTabId,\n className = 'bg-white dark:bg-gray-800 flex gap-2 border-b border-gray-200 dark:border-gray-700 px-6',\n style,\n activeClassName = 'tab-btn-base tab-btn-default-active',\n inactiveClassName = 'tab-btn-base tab-btn-default',\n sectionIdPrefix = 'tab_content_',\n scrollOffset = 120,\n scrollDelay = 100,\n onTabChange,\n enableScrollSpy = false,\n scrollSpyOffset = 80,\n scrollContainerId,\n id,\n editorAttrs,\n}) => {\n const [activeTab, setActiveTab] = useState(\n activeTabId ?? (tabs.length > 0 ? tabs[0].id : '')\n );\n\n // G7Core.useResponsive를 통해 반응형 상태 구독 (G7 표준)\n const G7Core = (window as any).G7Core;\n const useResponsive = G7Core?.useResponsive;\n const responsiveValue = useResponsive?.();\n const isMobile = responsiveValue\n ? responsiveValue.width < 768\n : typeof window !== 'undefined' && window.innerWidth < 768;\n\n // Scroll Spy 일시 중지를 위한 ref\n const isScrollingRef = useRef(false);\n const scrollTimeoutRef = useRef(null);\n\n /**\n * 탭 클릭 핸들러\n * 우선순위: 개별 탭 onClick > 공통 onTabChange > 기본 동작\n */\n const handleTabClick = (tab: Tab) => {\n // Scroll Spy 일시 중지\n if (enableScrollSpy) {\n isScrollingRef.current = true;\n if (scrollTimeoutRef.current) {\n clearTimeout(scrollTimeoutRef.current);\n }\n }\n\n // 우선순위 1: 개별 탭의 onClick이 있으면 실행\n if (tab.onClick) {\n tab.onClick(tab.id);\n // enableScrollSpy가 아니면 onClick에 전적으로 위임\n if (!enableScrollSpy) {\n return;\n }\n }\n // 우선순위 2: 공통 onTabChange가 있으면 실행\n else if (onTabChange) {\n onTabChange(tab.id);\n // enableScrollSpy가 아니면 onTabChange에 전적으로 위임\n if (!enableScrollSpy) {\n return;\n }\n }\n // 우선순위 3: 콜백이 없으면 내부 상태 업데이트\n else {\n setActiveTab(tab.id);\n }\n\n // 개별 탭 설정 우선, 없으면 공통 설정 사용\n const finalOffset = tab.scrollOffset ?? scrollOffset;\n const finalDelay = tab.scrollDelay ?? scrollDelay;\n\n scrollToSectionHandler(\n {\n params: {\n targetId: `${sectionIdPrefix}${tab.id}`,\n offset: finalOffset,\n delay: finalDelay,\n scrollContainerId,\n },\n },\n {}\n );\n\n // 스크롤 완료 후 Scroll Spy 재활성화 (딜레이 + 애니메이션 시간 고려)\n if (enableScrollSpy) {\n scrollTimeoutRef.current = setTimeout(() => {\n isScrollingRef.current = false;\n }, finalDelay + 500);\n }\n };\n\n /**\n * Scroll Spy 구현 (IntersectionObserver 사용)\n *\n * 짧은 섹션과 긴 섹션이 혼재된 경우를 고려한 알고리즘:\n * - 여러 섹션이 동시에 보일 경우, 가장 많이 보이는 섹션을 활성화\n * - intersectionRatio를 비교하여 최적의 섹션 선택\n * - 스크롤이 페이지 하단에 도달하면 마지막 탭 활성화 (짧은 마지막 섹션 대응)\n */\n useEffect(() => {\n if (!enableScrollSpy) {\n return;\n }\n\n const rootElement = scrollContainerId\n ? document.getElementById(scrollContainerId)\n : null;\n\n const observerOptions = {\n root: rootElement ?? null,\n // 상단 오프셋 + 하단 30% 여유 공간 (짧은 섹션도 감지 가능)\n rootMargin: `-${scrollSpyOffset}px 0px -30% 0px`,\n // 0.1 단위로 교차 비율 감지 (더 정밀한 감지)\n threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],\n };\n\n // 현재 보이는 섹션들의 교차 비율을 저장\n const intersectingEntries = new Map();\n\n // 페이지 하단 도달 확인 헬퍼 함수 (scrollContainer 기준)\n const isAtBottom = () => {\n const container = rootElement;\n const scrollTop = container\n ? container.scrollTop\n : window.pageYOffset || document.documentElement.scrollTop;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const clientHeight = container\n ? container.clientHeight\n : document.documentElement.clientHeight;\n // 10px 여유를 두고 하단 체크 (정확한 하단 감지)\n const atBottom = scrollTop + clientHeight >= scrollHeight - 10;\n\n // 디버깅 로그 (개발 환경에서만)\n if (process.env.NODE_ENV === 'development' && atBottom) {\n logger.log('Bottom reached:', {\n scrollTop,\n clientHeight,\n scrollHeight,\n remaining: scrollHeight - (scrollTop + clientHeight),\n });\n }\n\n return atBottom;\n };\n\n const observerCallback = (entries: IntersectionObserverEntry[]) => {\n // 수동 스크롤 중에는 Scroll Spy 비활성화\n if (isScrollingRef.current) {\n return;\n }\n\n // 교차 비율 업데이트\n entries.forEach((entry) => {\n const targetId = entry.target.id;\n if (entry.isIntersecting) {\n intersectingEntries.set(targetId, entry.intersectionRatio);\n } else {\n intersectingEntries.delete(targetId);\n }\n });\n\n // 가장 많이 보이는 섹션 찾기\n let maxRatio = 0;\n let mostVisibleId = '';\n\n intersectingEntries.forEach((ratio, id) => {\n if (ratio > maxRatio) {\n maxRatio = ratio;\n mostVisibleId = id;\n }\n });\n\n // 하단 도달 시 마지막 탭 활성화 (단, 마지막 섹션이 실제로 보이고 있을 때만)\n const lastTab = tabs[tabs.length - 1];\n const lastSectionId = `${sectionIdPrefix}${lastTab?.id}`;\n const isLastSectionVisible = intersectingEntries.has(lastSectionId);\n\n if (isAtBottom() && isLastSectionVisible && lastTab) {\n if (process.env.NODE_ENV === 'development') {\n logger.log('Activating last tab (bottom reached + last section visible):', lastTab.id);\n }\n setActiveTab(lastTab.id);\n return;\n }\n\n // 가장 많이 보이는 섹션의 탭 활성화\n if (mostVisibleId) {\n const tabId = mostVisibleId.replace(sectionIdPrefix, '');\n const numericTabId = Number(tabId);\n const finalTabId = isNaN(numericTabId) ? tabId : numericTabId;\n\n const tabExists = tabs.some((tab) => tab.id === finalTabId);\n if (tabExists) {\n setActiveTab(finalTabId);\n }\n }\n };\n\n // 스크롤 이벤트 핸들러 (현재는 IntersectionObserver가 모든 처리를 담당)\n const handleScroll = () => {\n // IntersectionObserver가 모든 탭 활성화를 처리하므로 여기서는 추가 동작 없음\n // 향후 필요시 추가 로직 구현 가능\n };\n\n const observer = new IntersectionObserver(observerCallback, observerOptions);\n\n // 모든 섹션 관찰 시작\n tabs.forEach((tab) => {\n const sectionElement = document.getElementById(`${sectionIdPrefix}${tab.id}`);\n if (sectionElement) {\n observer.observe(sectionElement);\n }\n });\n\n // 스크롤 이벤트 리스너 등록\n window.addEventListener('scroll', handleScroll, { passive: true });\n\n // Cleanup\n return () => {\n observer.disconnect();\n window.removeEventListener('scroll', handleScroll);\n if (scrollTimeoutRef.current) {\n clearTimeout(scrollTimeoutRef.current);\n }\n };\n }, [enableScrollSpy, tabs, sectionIdPrefix, scrollSpyOffset, scrollContainerId]);\n\n /**\n * Select 변경 핸들러 (모바일용)\n */\n const handleSelectChange = (\n e: React.ChangeEvent | { target: { value: string | number } }\n ) => {\n const selectedId = e.target.value;\n // 숫자형 ID 변환\n const numericId = Number(selectedId);\n const finalId = isNaN(numericId) ? selectedId : numericId;\n\n const selectedTab = tabs.find((tab) => tab.id === finalId);\n if (selectedTab) {\n handleTabClick(selectedTab);\n }\n };\n\n // 모바일: Select 드롭다운 단일 렌더\n if (isMobile) {\n return (\n
\n ({\n value: String(tab.id),\n label: tab.label,\n disabled: tab.disabled,\n }))}\n className=\"w-full\"\n />\n
\n );\n }\n\n // 데스크톱: 탭 버튼 단일 렌더\n return (\n
\n {tabs.map((tab) => (\n handleTabClick(tab)}\n >\n {tab.iconName && (\n \n )}\n {tab.label}\n \n ))}\n
\n );\n};\n","import React from 'react';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core 전역 객체의 스타일 헬퍼 접근\nconst G7Core = () => (window as any).G7Core;\n\nexport interface IconButtonProps {\n iconName: IconName;\n label?: string;\n onClick?: () => void;\n variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline';\n size?: 'sm' | 'md' | 'lg';\n disabled?: boolean;\n className?: string;\n style?: React.CSSProperties;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * IconButton 집합 컴포넌트\n *\n * 아이콘이 포함된 버튼 컴포넌트입니다.\n * 아이콘만 표시하거나 라벨과 함께 표시할 수 있습니다.\n *\n * 기본 컴포넌트 조합: Button + Icon\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"IconButton\",\n * \"props\": {\n * \"iconName\": \"plus\",\n * \"label\": \"추가\",\n * \"variant\": \"primary\",\n * \"size\": \"md\"\n * }\n * }\n */\nexport const IconButton: React.FC = ({\n iconName,\n label,\n onClick,\n variant = 'primary',\n size = 'md',\n disabled = false,\n className = '',\n style,\n id,\n editorAttrs,\n}) => {\n // 크기별 스타일 (라벨 없을 때는 정사각 고정 크기로 정렬을 보장)\n const sizeClasses = {\n sm: label ? 'px-3 py-1.5 text-sm' : 'w-8 h-8',\n md: label ? 'px-4 py-2 text-base' : 'w-10 h-10',\n lg: label ? 'px-5 py-2.5 text-lg' : 'w-12 h-12',\n };\n\n // 아이콘 크기\n const iconSizeClasses = {\n sm: 'w-4 h-4',\n md: 'w-5 h-5',\n lg: 'w-6 h-6',\n };\n\n // 변형별 스타일\n const variantClasses = {\n primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',\n secondary: 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',\n danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',\n ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent',\n outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',\n };\n\n const baseClasses = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg border transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-400';\n const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : '';\n\n // 모든 내부 클래스를 결합한 후 외부 className과 병합\n const internalClasses = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${disabledClasses}`;\n const mergedClassName = G7Core()?.style?.mergeClasses?.(internalClasses, className)\n ?? `${internalClasses} ${className}`;\n\n return (\n \n \n {label}\n \n );\n};\n","import React from 'react';\nimport { Modal } from './Modal';\nimport { Div } from '../basic/Div';\nimport { H2 } from '../basic/H2';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\nexport interface DialogAction {\n label: string;\n onClick: () => void;\n variant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n iconName?: IconName;\n disabled?: boolean;\n}\n\nexport interface DialogProps {\n isOpen: boolean;\n onClose: () => void;\n title: string;\n content: React.ReactNode;\n actions?: DialogAction[];\n width?: string;\n closeOnOverlay?: boolean;\n showCloseButton?: boolean;\n className?: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Dialog 집합 컴포넌트\n *\n * Modal의 특화 버전으로 제목 + 본문 + 액션 영역 구조를 표준화한 컴포넌트.\n * Modal보다 더 구조화된 레이아웃 제공.\n *\n * 기본 컴포넌트 조합:\n * - Modal > Div(header) > H2(title) + Button(close)\n * - Div(body) > {content}\n * - Div(footer) > Button[](actions)\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"Dialog\",\n * \"props\": {\n * \"isOpen\": \"{{dialogs.confirm}}\",\n * \"title\": \"확인\",\n * \"content\": \"정말 삭제하시겠습니까?\",\n * \"actions\": [\n * {\"label\": \"취소\", \"variant\": \"secondary\"},\n * {\"label\": \"삭제\", \"variant\": \"danger\"}\n * ]\n * }\n * }\n */\nexport const Dialog: React.FC = ({\n isOpen,\n onClose,\n title,\n content,\n actions = [],\n width = '500px',\n closeOnOverlay = true,\n showCloseButton = true,\n className = '',\n style,\n}) => {\n const handleOverlayClick = () => {\n if (closeOnOverlay) {\n onClose();\n }\n };\n\n // 버튼 variant별 스타일 매핑\n const variantClassMap: Record<'primary' | 'secondary' | 'danger' | 'ghost', string> = {\n primary: 'dialog-btn-primary',\n secondary: 'dialog-btn-secondary',\n danger: 'dialog-btn-danger',\n ghost: 'dialog-btn-ghost',\n };\n\n return (\n \n {/* Header */}\n
\n

\n {title}\n

\n {showCloseButton && (\n \n \n \n )}\n
\n\n {/* Body */}\n
\n {content}\n
\n\n {/* Footer (Actions) */}\n {actions.length > 0 && (\n
\n {actions.map((action, index) => (\n \n {action.iconName && (\n \n )}\n {action.label}\n \n ))}\n
\n )}\n \n );\n};\n","import React from 'react';\nimport { Modal } from './Modal';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { P } from '../basic/P';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\nexport interface ConfirmDialogProps {\n isOpen: boolean;\n onClose: () => void;\n title: string;\n message: string | React.ReactNode;\n confirmText?: string;\n cancelText?: string;\n onConfirm: () => void;\n onCancel?: () => void;\n confirmButtonVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n cancelButtonVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n width?: string;\n closeOnOverlay?: boolean;\n /** 로딩 중 여부 (확인 버튼 비활성화 및 텍스트 변경) */\n isLoading?: boolean;\n /** 로딩 중 확인 버튼에 표시할 텍스트 */\n loadingText?: string;\n}\n\n/**\n * ConfirmDialog 집합 컴포넌트\n *\n * Modal 기반 확인/취소 다이얼로그 컴포넌트.\n * 사용자에게 확인을 요청하고 확인/취소 중 하나를 선택하도록 합니다.\n *\n * 기본 컴포넌트 조합:\n * - Modal > P(message) + Button(확인) + Button(취소)\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"ConfirmDialog\",\n * \"props\": {\n * \"isOpen\": \"{{dialogs.deleteConfirm}}\",\n * \"title\": \"삭제 확인\",\n * \"message\": \"정말 이 항목을 삭제하시겠습니까?\",\n * \"confirmText\": \"삭제\",\n * \"cancelText\": \"$t:common.cancel\",\n * \"confirmButtonVariant\": \"danger\"\n * }\n * }\n */\nexport const ConfirmDialog: React.FC = ({\n isOpen,\n onClose,\n title,\n message,\n confirmText,\n cancelText,\n onConfirm,\n onCancel,\n confirmButtonVariant = 'primary',\n // cancelButtonVariant는 현재 고정 스타일 사용으로 미사용\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n cancelButtonVariant: _cancelButtonVariant = 'secondary',\n width = '500px',\n // closeOnOverlay는 Modal에서 처리하므로 미사용\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n closeOnOverlay: _closeOnOverlay = true,\n isLoading = false,\n loadingText,\n}) => {\n // props로 전달된 값이 없으면 다국어 키 사용\n const resolvedConfirmText = confirmText ?? t('common.confirm');\n const resolvedCancelText = cancelText ?? t('common.cancel');\n const resolvedLoadingText = loadingText ?? t('common.loading');\n\n const handleConfirm = () => {\n onConfirm();\n };\n\n const handleCancel = () => {\n // 로딩 중에는 취소 불가\n if (isLoading) return;\n onCancel?.();\n onClose();\n };\n\n const handleClose = () => {\n // 로딩 중에는 닫기 불가\n if (isLoading) return;\n onClose();\n };\n\n // 버튼 variant별 스타일 매핑\n const confirmVariantClassMap: Record<'primary' | 'secondary' | 'danger' | 'ghost', string> = {\n primary: 'bg-blue-600 text-white hover:bg-blue-700',\n secondary: 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600',\n danger: 'bg-red-600 text-white hover:bg-red-700',\n ghost: 'bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700',\n };\n\n return (\n \n {/* 본문 */}\n
\n {typeof message === 'string' ? (\n

{message}

\n ) : (\n message\n )}\n
\n\n {/* 푸터 (버튼 영역) */}\n
\n {/* 취소 버튼 */}\n \n {resolvedCancelText}\n \n\n {/* 확인 버튼 */}\n \n {isLoading && (\n \n )}\n {isLoading ? resolvedLoadingText : resolvedConfirmText}\n \n
\n \n );\n};\n","import React from 'react';\nimport { Dialog, DialogAction } from './Dialog';\nimport { P } from '../basic/P';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\nexport interface AlertDialogProps {\n isOpen: boolean;\n onClose: () => void;\n title: string;\n message: string | React.ReactNode;\n confirmText?: string;\n onConfirm?: () => void;\n confirmButtonVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n width?: string;\n closeOnOverlay?: boolean;\n}\n\n/**\n * AlertDialog 집합 컴포넌트\n *\n * Dialog 기반 단일 확인 알림 다이얼로그 컴포넌트.\n * 사용자에게 정보를 전달하고 확인만 받습니다.\n *\n * 기본 컴포넌트 조합:\n * - Dialog > P(message) + Button(확인)\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"AlertDialog\",\n * \"props\": {\n * \"isOpen\": \"{{dialogs.successAlert}}\",\n * \"title\": \"성공\",\n * \"message\": \"작업이 성공적으로 완료되었습니다.\",\n * \"confirmText\": \"$t:common.confirm\",\n * \"confirmButtonVariant\": \"primary\"\n * }\n * }\n */\nexport const AlertDialog: React.FC = ({\n isOpen,\n onClose,\n title,\n message,\n confirmText,\n onConfirm,\n confirmButtonVariant = 'primary',\n width = '500px',\n closeOnOverlay = true,\n}) => {\n // props로 전달된 값이 없으면 다국어 키 사용\n const resolvedConfirmText = confirmText ?? t('common.confirm');\n\n const handleConfirm = () => {\n onConfirm?.();\n onClose();\n };\n\n const actions: DialogAction[] = [\n {\n label: resolvedConfirmText,\n onClick: handleConfirm,\n variant: confirmButtonVariant,\n },\n ];\n\n // message가 문자열인 경우 P 컴포넌트로 래핑\n const content = typeof message === 'string' ? (\n

{message}

\n ) : (\n message\n );\n\n return (\n \n );\n};\n","import React, { useEffect, useState, useCallback } from 'react';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\n\n/**\n * G7Core.t() 번역 함수 참조\n * 런타임에 G7Core.t를 접근하여 테스트 환경에서도 모킹이 적용되도록 함\n */\nconst getTranslation = (key: string, params?: Record) => {\n const G7Core = (window as any).G7Core;\n return G7Core?.t?.(key, params) ?? key;\n};\n\nexport type ToastType = 'success' | 'error' | 'warning' | 'info';\nexport type ToastPosition =\n | 'top-left'\n | 'top-center'\n | 'top-right'\n | 'bottom-left'\n | 'bottom-center'\n | 'bottom-right';\n\nexport interface ToastItem {\n id: string | number;\n type: ToastType;\n message: string;\n /** 커스텀 아이콘 이름 (지정하지 않으면 타입별 기본 아이콘 사용) */\n icon?: string;\n /** 개별 토스트 자동 닫힘 시간 (ms) */\n duration?: number;\n}\n\nexport interface ToastProps {\n /** 토스트 배열 (전역 상태 _global.toasts) */\n toasts?: ToastItem[] | null;\n /** 토스트 표시 위치 */\n position?: ToastPosition;\n /** 기본 자동 닫힘 시간 (ms, 기본 3000) */\n duration?: number;\n /** 토스트 제거 콜백 (전역 상태 업데이트용) */\n onRemove?: (id: string | number) => void;\n}\n\n// 타입별 기본 아이콘 매핑\nconst defaultIconMap: Record = {\n success: IconName.CheckCircle,\n error: IconName.XCircle,\n warning: IconName.ExclamationTriangle,\n info: IconName.InfoCircle,\n};\n\n// 타입별 스타일 매핑\nconst typeStyleMap: Record = {\n success: {\n bg: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',\n text: 'text-green-800 dark:text-green-300',\n },\n error: {\n bg: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',\n text: 'text-red-800 dark:text-red-300',\n },\n warning: {\n bg: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',\n text: 'text-yellow-800 dark:text-yellow-300',\n },\n info: {\n bg: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',\n text: 'text-blue-800 dark:text-blue-300',\n },\n};\n\n// position별 스타일 매핑\nconst positionClassMap: Record = {\n 'top-left': 'top-4 left-4',\n 'top-center': 'top-4 left-1/2 -translate-x-1/2',\n 'top-right': 'top-4 right-4',\n 'bottom-left': 'bottom-4 left-4',\n 'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',\n 'bottom-right': 'bottom-4 right-4',\n};\n\n/**\n * Toast 컴포넌트\n *\n * 전역 상태(_global.toasts)를 구독하여 토스트 알림을 스택으로 표시하는 컴포넌트.\n * ActionDispatcher의 toast 핸들러와 연동됩니다.\n *\n * 기능:\n * - 다중 토스트 스택 지원\n * - 타입별 스타일 (success, error, warning, info)\n * - 커스텀 아이콘 지정 가능\n * - 개별 토스트 자동 닫힘 시간 설정\n * - 수동 닫기 버튼\n *\n * @example\n * // 레이아웃 JSON 사용 예시 (_admin_base.json)\n * {\n * \"id\": \"global_toast\",\n * \"type\": \"composite\",\n * \"name\": \"Toast\",\n * \"props\": {\n * \"toasts\": \"{{_global.toasts}}\",\n * \"position\": \"bottom-center\",\n * \"duration\": 3000\n * }\n * }\n *\n * @example\n * // toast 핸들러 호출 예시 (레이아웃 JSON)\n * {\n * \"handler\": \"toast\",\n * \"params\": {\n * \"type\": \"success\",\n * \"message\": \"$t:admin.users.modals.bulk_activate_success\",\n * \"icon\": \"check\",\n * \"duration\": 5000\n * }\n * }\n */\nexport const Toast: React.FC = ({\n toasts,\n position = 'bottom-center',\n duration = 3000,\n onRemove,\n}) => {\n // 내부적으로 표시할 토스트 상태 관리 (애니메이션용)\n const [visibleToasts, setVisibleToasts] = useState>(new Map());\n\n // 토스트 제거 핸들러\n const handleRemove = useCallback((id: string | number) => {\n // 먼저 숨김 애니메이션 시작\n setVisibleToasts(prev => {\n const next = new Map(prev);\n next.set(id, false);\n return next;\n });\n\n // 애니메이션 후 실제 제거\n setTimeout(() => {\n if (onRemove) {\n onRemove(id);\n } else {\n // onRemove가 없으면 G7Core.state.update를 통해 전역 상태 업데이트\n const G7Core = (window as any).G7Core;\n if (G7Core?.state?.update) {\n G7Core.state.update((prevState: Record) => {\n const currentToasts = Array.isArray(prevState.toasts) ? prevState.toasts : [];\n return {\n toasts: currentToasts.filter((t: ToastItem) => t.id !== id),\n };\n });\n }\n }\n }, 300);\n }, [onRemove]);\n\n // 새 토스트가 추가되면 visible 상태 설정 및 자동 제거 타이머 설정\n useEffect(() => {\n if (!toasts || toasts.length === 0) return;\n\n toasts.forEach(toast => {\n // 이미 처리된 토스트는 건너뜀\n if (visibleToasts.has(toast.id)) return;\n\n // visible 상태 추가\n setVisibleToasts(prev => {\n const next = new Map(prev);\n next.set(toast.id, true);\n return next;\n });\n\n // 자동 제거 타이머 설정\n const effectiveDuration = toast.duration || duration;\n if (effectiveDuration > 0) {\n setTimeout(() => {\n handleRemove(toast.id);\n }, effectiveDuration);\n }\n });\n }, [toasts, duration, handleRemove, visibleToasts]);\n\n // 토스트가 없으면 렌더링하지 않음\n if (!toasts || toasts.length === 0) {\n return null;\n }\n\n return (\n \n {toasts.map((toast) => (\n handleRemove(toast.id)}\n />\n ))}\n \n );\n};\n\ninterface ToastItemProps {\n toast: ToastItem;\n position: ToastPosition;\n isVisible: boolean;\n onClose: () => void;\n}\n\nconst ToastItem: React.FC = ({ toast, position, isVisible, onClose }) => {\n const typeStyle = typeStyleMap[toast.type] || typeStyleMap.info;\n\n // 아이콘 결정: 커스텀 아이콘 > 타입별 기본 아이콘\n const iconName = toast.icon\n ? (toast.icon as IconName)\n : defaultIconMap[toast.type];\n\n // position에 따라 숨김 애니메이션 방향 결정 (top → 위로, bottom → 아래로)\n const hideTranslate = position.startsWith('bottom') ? 'translate-y-2' : '-translate-y-2';\n\n return (\n \n \n \n {toast.message}\n \n \n \n \n \n );\n};\n","import React from 'react';\nimport { Nav } from '../basic/Nav';\nimport { Div } from '../basic/Div';\nimport { A } from '../basic/A';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\nexport interface BreadcrumbItem {\n label: string;\n href?: string;\n onClick?: () => void;\n}\n\nexport interface BreadcrumbProps {\n items: BreadcrumbItem[];\n separator?: React.ReactNode;\n showHome?: boolean;\n homeHref?: string;\n maxItems?: number;\n className?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * Breadcrumb 집합 컴포넌트\n *\n * 현재 페이지의 경로를 계층적으로 표시하는 내비게이션 컴포넌트.\n * separator 커스터마이징, showHome 옵션, maxItems로 중간 생략 기능 제공.\n *\n * 기본 컴포넌트 조합:\n * - Nav > Div > A(링크) + Span(구분자)\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"Breadcrumb\",\n * \"props\": {\n * \"items\": [\n * {\"label\": \"대시보드\", \"href\": \"/admin\"},\n * {\"label\": \"사용자 관리\", \"href\": \"/admin/users\"},\n * {\"label\": \"사용자 목록\"}\n * ],\n * \"showHome\": true,\n * \"maxItems\": 4\n * }\n * }\n */\nexport const Breadcrumb: React.FC = ({\n items,\n separator = ,\n showHome = false,\n homeHref = '/',\n maxItems,\n className = '',\n id,\n editorAttrs,\n}) => {\n // Home 아이템 생성\n const homeItem: BreadcrumbItem = {\n label: 'Home',\n href: homeHref,\n };\n\n // 전체 아이템 배열 생성\n let allItems = showHome ? [homeItem, ...items] : items;\n\n // maxItems 적용 (중간 생략)\n let displayItems = allItems;\n let hasEllipsis = false;\n\n if (maxItems && allItems.length > maxItems) {\n hasEllipsis = true;\n const firstItems = allItems.slice(0, 1);\n const lastItems = allItems.slice(-(maxItems - 2));\n displayItems = [...firstItems, ...lastItems];\n }\n\n return (\n \n
\n {displayItems.map((item, index) => {\n const isLast = index === displayItems.length - 1;\n const isFirst = index === 0;\n\n return (\n \n {/* 중간 생략 표시 (첫 번째 아이템 다음) */}\n {hasEllipsis && isFirst && (\n <>\n \n \n {separator}\n \n ...\n {displayItems.length > 1 && (\n \n {separator}\n \n )}\n \n )}\n\n {/* 일반 아이템 */}\n {(!hasEllipsis || !isFirst) && (\n <>\n \n {!isLast && (\n \n {separator}\n \n )}\n \n )}\n \n );\n })}\n
\n \n );\n};\n\ninterface BreadcrumbLinkProps {\n item: BreadcrumbItem;\n isLast: boolean;\n}\n\nconst BreadcrumbLink: React.FC = ({ item, isLast }) => {\n if (isLast || (!item.href && !item.onClick)) {\n // 마지막 아이템이거나 링크가 없는 경우\n return (\n \n {item.label}\n \n );\n }\n\n if (item.onClick) {\n // onClick 핸들러가 있는 경우\n return (\n {\n e.preventDefault();\n item.onClick?.();\n }}\n className=\"text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors\"\n >\n {item.label}\n
\n );\n }\n\n // 일반 링크\n return (\n \n {item.label}\n \n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { H2 } from '../basic/H2';\nimport { P } from '../basic/P';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { Img } from '../basic/Img';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\nexport interface EmptyStateAction {\n label: string;\n onClick: () => void;\n variant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n iconName?: IconName;\n}\n\nexport interface EmptyStateProps {\n title: string;\n description?: string;\n iconName?: IconName;\n illustrationSrc?: string;\n illustrationAlt?: string;\n actions?: EmptyStateAction[];\n className?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * EmptyState 집합 컴포넌트\n *\n * 데이터가 없거나 검색 결과가 없을 때 표시하는 빈 상태 컴포넌트.\n * icon/illustration, title, description, actions 버튼 배열 지원.\n *\n * 기본 컴포넌트 조합:\n * - Div(컨테이너) > Icon/Img + H2(제목) + P(설명) + Div(액션) > Button[]\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"EmptyState\",\n * \"props\": {\n * \"title\": \"검색 결과가 없습니다\",\n * \"description\": \"다른 검색어로 시도해보세요.\",\n * \"iconName\": \"Search\",\n * \"actions\": [\n * {\"label\": \"검색 초기화\", \"variant\": \"secondary\"}\n * ]\n * }\n * }\n */\nexport const EmptyState: React.FC = ({\n title,\n description,\n iconName,\n illustrationSrc,\n illustrationAlt = 'Empty state illustration',\n actions = [],\n className = '',\n id,\n editorAttrs,\n}) => {\n // 버튼 variant별 스타일 매핑\n const variantClassMap: Record<'primary' | 'secondary' | 'danger' | 'ghost', string> = {\n primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',\n secondary: 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 focus:ring-gray-500',\n danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',\n ghost: 'bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-gray-500',\n };\n\n return (\n \n {/* Icon 또는 Illustration */}\n {illustrationSrc ? (\n \n ) : iconName ? (\n
\n \n
\n ) : null}\n\n {/* Title */}\n

\n {title}\n

\n\n {/* Description */}\n {description && (\n

\n {description}\n

\n )}\n\n {/* Actions */}\n {actions.length > 0 && (\n
\n {actions.map((action, index) => (\n \n {action.iconName && (\n \n )}\n {action.label}\n \n ))}\n
\n )}\n \n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\nexport type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl';\n\nexport interface LoadingSpinnerProps {\n id?: string;\n size?: SpinnerSize;\n color?: string;\n fullscreen?: boolean;\n text?: string;\n className?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * LoadingSpinner 집합 컴포넌트\n *\n * 로딩 상태를 시각적으로 표시하는 스피너 컴포넌트.\n * size, color, fullscreen 모드, text 라벨 옵션 제공.\n *\n * 기본 컴포넌트 조합:\n * - Div(컨테이너) > Div(스피너) + Span(텍스트, 옵션)\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"LoadingSpinner\",\n * \"props\": {\n * \"size\": \"lg\",\n * \"text\": \"데이터를 불러오는 중...\",\n * \"fullscreen\": false\n * }\n * }\n */\nexport const LoadingSpinner: React.FC = ({\n id,\n size = 'md',\n color = 'text-blue-600',\n fullscreen = false,\n text,\n className = '',\n editorAttrs,\n}) => {\n // size별 스피너 크기 매핑\n const sizeClassMap: Record = {\n sm: 'w-4 h-4 border-2',\n md: 'w-8 h-8 border-2',\n lg: 'w-12 h-12 border-3',\n xl: 'w-16 h-16 border-4',\n };\n\n // size별 텍스트 크기 매핑\n const textSizeClassMap: Record = {\n sm: 'text-xs',\n md: 'text-sm',\n lg: 'text-base',\n xl: 'text-lg',\n };\n\n const spinnerContent = (\n
\n {/* Spinner */}\n \n\n {/* Text Label */}\n {text && (\n \n {text}\n \n )}\n
\n );\n\n if (fullscreen) {\n return (\n \n {spinnerContent}\n \n );\n }\n\n return (\n \n {spinnerContent}\n \n );\n};\n","import React, { useState, useEffect, useMemo, useCallback } from 'react';\nimport { Div } from '../basic/Div';\nimport { Img } from '../basic/Img';\nimport { Span } from '../basic/Span';\nimport { Button } from '../basic/Button';\nimport { I } from '../basic/I';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core 전역 객체 접근 헬퍼\nconst G7Core = () => (window as any).G7Core;\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n/**\n * 메뉴 아이템 인터페이스 (API 응답 구조)\n */\nexport interface MenuItem {\n id: string | number;\n name: string | { ko: string; en: string };\n slug: string;\n url?: string | null;\n icon?: string;\n children?: MenuItem[];\n is_active?: boolean;\n module_id?: number | null;\n}\n\n/**\n * AdminSidebar Props\n */\nexport interface AdminSidebarProps {\n logo?: string;\n logoAlt?: string;\n menu: MenuItem[];\n collapsed?: boolean;\n onToggleCollapse?: () => void;\n className?: string;\n /** 현재 로케일 (다국어 메뉴 이름 표시용). 미지정 시 G7Core.locale.current() 자동 사용 */\n currentLocale?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * AdminSidebar 컴포넌트\n *\n * 관리자 사이드바 - 계층형 메뉴, 접기/펼치기 기능 제공\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport const AdminSidebar: React.FC = ({\n logo,\n logoAlt = 'Logo',\n menu = [],\n collapsed = false,\n onToggleCollapse,\n className = '',\n currentLocale: currentLocaleProp,\n id,\n editorAttrs,\n}) => {\n const [expandedItems, setExpandedItems] = useState>(new Set());\n const [locale, setLocale] = useState(currentLocaleProp || G7Core()?.locale?.current?.() || 'ko');\n const [currentPath, setCurrentPath] = useState(window.location.pathname);\n\n // 메뉴 트리에서 모든 URL 수집 (가장 구체적인 매칭 판별용)\n const allMenuUrls = useMemo(() => {\n const urls: string[] = [];\n const collect = (items: MenuItem[]) => {\n for (const item of items) {\n if (item.url) urls.push(item.url);\n if (item.children) collect(item.children);\n }\n };\n collect(menu);\n return urls;\n }, [menu]);\n\n // 주어진 경로에 가장 구체적으로 매칭되는 메뉴 URL 찾기\n const findBestMatch = useCallback((path: string): string | null => {\n let bestMatch: string | null = null;\n for (const url of allMenuUrls) {\n if (path === url || path.startsWith(url + '/')) {\n if (!bestMatch || url.length > bestMatch.length) {\n bestMatch = url;\n }\n }\n }\n return bestMatch;\n }, [allMenuUrls]);\n\n // G7Core 로케일 및 경로 변경 구독\n useEffect(() => {\n const g7core = G7Core();\n if (!g7core?.state?.subscribe) return;\n\n const unsubscribe = g7core.state.subscribe((state: any) => {\n const newLocale = state?._global?.locale || g7core?.locale?.current?.() || 'ko';\n setLocale(newLocale);\n\n // 현재 경로 업데이트\n const newPath = state?._global?.currentPath || window.location.pathname;\n setCurrentPath(newPath);\n });\n\n return unsubscribe;\n }, []);\n\n // 브라우저 뒤로가기/앞으로가기 감지\n useEffect(() => {\n const handlePopState = () => {\n setCurrentPath(window.location.pathname);\n };\n\n window.addEventListener('popstate', handlePopState);\n return () => window.removeEventListener('popstate', handlePopState);\n }, []);\n\n // currentLocale prop이 변경되면 우선 적용\n useEffect(() => {\n if (currentLocaleProp) {\n setLocale(currentLocaleProp);\n }\n }, [currentLocaleProp]);\n\n /**\n * 특정 경로가 메뉴 URL과 일치하는지 확인\n * 접두사 매칭 시 가장 구체적인(가장 긴) 매칭만 활성화 ()\n *\n * @param url 메뉴 URL\n * @param path 확인할 경로\n * @returns 일치 여부\n */\n const isPathActive = (url: string | null | undefined, path: string): boolean => {\n if (!url || typeof path !== 'string') return false;\n if (path === url) return true;\n if (path.startsWith(url + '/')) {\n return findBestMatch(path) === url;\n }\n return false;\n };\n\n /**\n * 활성 메뉴의 부모 ID들을 찾아 반환\n *\n * @param items 메뉴 아이템 배열\n * @param path 현재 경로\n * @returns 활성 부모 ID Set\n */\n const findActiveParentIds = (items: MenuItem[], path: string): Set => {\n const parents = new Set();\n\n const traverse = (menuItems: MenuItem[]): boolean => {\n for (const item of menuItems) {\n if (item.children && item.children.length > 0) {\n const hasActiveInChildren = traverse(item.children);\n if (hasActiveInChildren || isPathActive(item.url, path)) {\n parents.add(item.id);\n return true;\n }\n } else if (isPathActive(item.url, path)) {\n return true;\n }\n }\n return false;\n };\n\n traverse(items);\n return parents;\n };\n\n // 초기 로드 및 경로 변경 시 활성 메뉴의 부모를 자동 펼침\n useEffect(() => {\n if (menu.length > 0 && currentPath) {\n const activeParents = findActiveParentIds(menu, currentPath);\n if (activeParents.size > 0) {\n setExpandedItems((prev) => new Set([...prev, ...activeParents]));\n }\n }\n }, [currentPath, menu]);\n\n /**\n * 메뉴 URL이 현재 경로와 일치하는지 확인\n * 접두사 매칭 시 가장 구체적인 매칭만 활성화 ()\n *\n * @param url 메뉴 URL\n * @returns 활성 여부\n */\n const isActiveMenu = (url: string | null | undefined): boolean => {\n return isPathActive(url, currentPath);\n };\n\n /**\n * 하위 메뉴 중 활성 메뉴가 있는지 확인 (부모 메뉴 강조용)\n *\n * @param item 메뉴 아이템\n * @returns 하위에 활성 메뉴 존재 여부\n */\n const hasActiveChild = (item: MenuItem): boolean => {\n if (!item.children) return false;\n return item.children.some((child) => isActiveMenu(child.url) || hasActiveChild(child));\n };\n\n /**\n * 하위 메뉴 토글\n */\n const toggleExpand = (itemId: string | number) => {\n setExpandedItems((prev) => {\n const newSet = new Set(prev);\n if (newSet.has(itemId)) {\n newSet.delete(itemId);\n } else {\n newSet.add(itemId);\n }\n return newSet;\n });\n };\n\n /**\n * 다국어 name을 현재 로케일에 맞게 반환\n */\n const getMenuLabel = (name: string | { ko: string; en: string; [key: string]: string }): string => {\n if (typeof name === 'string') return name;\n // 현재 로케일에 맞는 이름 반환, fallback으로 en → 첫 번째 값\n return name[locale] || name.en || Object.values(name)[0] || '';\n };\n\n /**\n * 메뉴 네비게이션 핸들러\n * G7Core.dispatch가 있으면 SPA 방식으로, 없으면 전체 페이지 리로드\n * 모바일에서는 페이지 로드 완료 후 사이드바를 닫음\n */\n const handleNavigate = (event: React.MouseEvent, url: string) => {\n if (G7Core()?.dispatch) {\n // G7Core.dispatch가 있으면 SPA 방식 네비게이션\n event.preventDefault();\n\n // 페이지 전환 완료 후 사이드바 닫기\n G7Core().navigation?.onComplete(() => {\n G7Core().dispatch({\n handler: 'setState',\n params: {\n target: 'global',\n sidebarOpen: false,\n },\n });\n });\n\n G7Core().dispatch({\n handler: 'navigate',\n params: { path: url },\n });\n } else {\n // fallback: 전체 페이지 리로드\n window.location.href = url;\n }\n };\n\n /**\n * 메뉴 아이템 렌더링 (재귀)\n */\n const renderMenuItem = (item: MenuItem, level: number = 0, isLast: boolean = false): React.ReactNode => {\n const hasChildren = item.children && item.children.length > 0;\n const isExpanded = expandedItems.has(item.id);\n // padding-left 는 .admin-sidebar-item (px-3 → 12px) / .admin-sidebar-subitem\n // (pl-[30px]) 시맨틱 자산에 흡수됨.\n const label = getMenuLabel(item.name);\n const isSubItem = level > 0;\n\n // 활성 메뉴 판별\n const isActive = isActiveMenu(item.url);\n const hasActiveDescendant = hasActiveChild(item);\n\n // 스타일 클래스\n const baseClass = isSubItem ? 'admin-sidebar-subitem' : 'admin-sidebar-item';\n const activeClass = 'admin-sidebar-item-active';\n const activeParentClass = 'admin-sidebar-item-active-parent';\n const defaultClass = 'admin-sidebar-item-default';\n const disabledClass = 'admin-sidebar-item-disabled';\n\n return (\n
\n {/* 서브메뉴 트리라인 */}\n {isSubItem && (\n <>\n {/* 세로선 (마지막 아이템이 아닐 때만) */}\n {!isLast && (\n
\n )}\n {/* 커브드 연결선 */}\n
\n \n )}\n {/* 메뉴 아이템 */}\n {hasChildren && item.url ? (\n // 하위 메뉴 + URL 있는 경우: 텍스트 클릭 → 네비게이션, chevron 클릭 → 토글\n \n handleNavigate(event, item.url!)}\n className=\"flex items-center justify-start flex-1 min-w-0\"\n >\n {item.icon && (\n \n )}\n {!collapsed && {label}}\n \n {!collapsed && (\n
\n {item.module_id && (\n \n )}\n toggleExpand(item.id)}\n className=\"p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600\"\n aria-label={t(isExpanded ? 'common.collapse' : 'common.expand')}\n >\n \n \n
\n )}\n
\n ) : hasChildren ? (\n // 하위 메뉴만 있는 경우 (URL 없음): 전체 클릭 → 토글\n toggleExpand(item.id)}\n className={`\n ${baseClass}\n ${hasActiveDescendant ? activeParentClass : defaultClass}\n ${collapsed ? 'justify-center' : ''}\n `}\n >\n
\n {item.icon && (\n
\n \n
\n )}\n {!collapsed && {label}}\n
\n {!collapsed && (\n
\n {item.module_id && (\n \n )}\n \n
\n )}\n \n ) : item.url ? (\n // 하위 메뉴가 없고 URL이 있는 경우: 네비게이션 버튼\n handleNavigate(event, item.url!)}\n className={`\n ${baseClass}\n ${isActive ? activeClass : defaultClass}\n ${collapsed ? 'justify-center' : ''}\n text-left\n `}\n >\n
\n {item.icon && (\n
\n \n
\n )}\n {!collapsed && {label}}\n
\n {!collapsed && item.module_id && (\n \n )}\n \n ) : (\n // 하위 메뉴도 없고 URL도 없는 경우: 비활성 버튼\n \n
\n {item.icon && (\n
\n \n
\n )}\n {!collapsed && {label}}\n
\n {!collapsed && item.module_id && (\n \n )}\n \n )}\n\n {/* 하위 메뉴 */}\n {hasChildren && !collapsed && isExpanded && (\n
\n {item.children!.map((child, index) =>\n renderMenuItem(child, level + 1, index === item.children!.length - 1)\n )}\n
\n )}\n
\n );\n };\n\n return (\n
\n {/* 로고 영역 (선택적) */}\n {(logo || onToggleCollapse) && (\n
\n {logo && !collapsed && (\n \n )}\n {onToggleCollapse && (\n \n \n \n )}\n
\n )}\n\n {/* 메뉴 영역 */}\n
\n {menu.map((item, index) => renderMenuItem(item, 0, index === menu.length - 1))}\n
\n
\n );\n};\n","import React, { useState, useRef, useEffect } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { Img } from '../basic/Img';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n/**\n * 사용자 정보 인터페이스\n */\nexport interface AdminUser {\n name: string;\n email: string;\n avatar?: string;\n role?: string;\n}\n\n/**\n * 알림 아이템 인터페이스\n */\nexport interface NotificationItem {\n id: string | number;\n title: string;\n message: string;\n time: string;\n read?: boolean;\n iconName?: IconName;\n onClick?: () => void;\n}\n\n/**\n * AdminHeader Props\n */\nexport interface AdminHeaderProps {\n user: AdminUser;\n notifications?: NotificationItem[];\n onNotificationClick?: (id: string | number) => void;\n onProfileClick?: () => void;\n onLogoutClick?: () => void;\n className?: string;\n}\n\n/**\n * AdminHeader 컴포넌트\n *\n * 관리자 헤더 - 사용자 프로필, 알림 센터\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport const AdminHeader: React.FC = ({\n user = { name: '', email: '' },\n notifications = [],\n onNotificationClick,\n onProfileClick,\n onLogoutClick,\n className = '',\n}) => {\n const [showNotifications, setShowNotifications] = useState(false);\n const [showProfileMenu, setShowProfileMenu] = useState(false);\n const notificationRef = useRef(null);\n const profileRef = useRef(null);\n\n const unreadCount = notifications.filter((n) => !n.read).length;\n\n /**\n * 외부 클릭 감지\n */\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (\n notificationRef.current &&\n !notificationRef.current.contains(event.target as Node)\n ) {\n setShowNotifications(false);\n }\n if (\n profileRef.current &&\n !profileRef.current.contains(event.target as Node)\n ) {\n setShowProfileMenu(false);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, []);\n\n return (\n \n {/* 알림 센터 */}\n
\n setShowNotifications(!showNotifications)}\n className=\"relative p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors\"\n aria-label={t('common.notifications')}\n >\n \n {unreadCount > 0 && (\n \n {unreadCount > 9 ? '9+' : unreadCount}\n \n )}\n \n\n {/* 알림 드롭다운 */}\n {showNotifications && (\n
\n
\n {t('common.notifications')}\n
\n
\n {notifications.length === 0 ? (\n
\n {t('common.no_notifications')}\n
\n ) : (\n notifications.map((notification) => (\n {\n notification.onClick?.();\n onNotificationClick?.(notification.id);\n setShowNotifications(false);\n }}\n className={`\n w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 transition-colors\n ${!notification.read ? 'bg-blue-50 dark:bg-blue-900/20' : ''}\n `}\n >\n
\n {notification.iconName && (\n \n )}\n
\n
\n {notification.title}\n
\n
\n {notification.message}\n
\n
\n {notification.time}\n
\n
\n {!notification.read && (\n \n )}\n
\n \n ))\n )}\n
\n
\n )}\n
\n\n {/* 사용자 프로필 */}\n
\n setShowProfileMenu(!showProfileMenu)}\n className=\"flex items-center gap-3 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors\"\n >\n {user.avatar ? (\n \n ) : (\n
\n \n
\n )}\n
\n
{user.name}
\n {user.role && (\n
{user.role}
\n )}\n
\n \n \n\n {/* 프로필 드롭다운 */}\n {showProfileMenu && (\n
\n
\n
{user.name}
\n
{user.email}
\n
\n
\n {\n onProfileClick?.();\n setShowProfileMenu(false);\n }}\n className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors text-gray-900 dark:text-white\"\n >\n \n {t('common.profile_settings')}\n \n {\n onLogoutClick?.();\n setShowProfileMenu(false);\n }}\n className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 text-red-600 dark:text-red-400 transition-colors\"\n >\n \n {t('common.logout')}\n \n
\n
\n )}\n
\n
\n );\n};\n","import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Input } from '../basic/Input';\nimport type { EditorAttrs } from '../../types';\n\n/**\n * 알림 아이템 인터페이스\n */\nexport interface NotificationItem {\n id: string | number;\n title: string;\n message: string;\n time: string;\n read?: boolean;\n iconName?: IconName;\n data?: Record;\n onClick?: () => void;\n}\n\n/**\n * NotificationCenter Props\n */\nexport interface NotificationCenterProps {\n /** 알림 목록 */\n notifications?: NotificationItem[];\n /** 서버 기반 미읽음 알림 수 */\n unreadCount?: number;\n /** 추가 로드 가능 여부 */\n hasMore?: boolean;\n /** 로딩 상태 */\n loading?: boolean;\n /** 알림 제목 텍스트 */\n titleText?: string;\n /** 알림 없음 텍스트 */\n emptyText?: string;\n /** \"모두 읽음\" 버튼 텍스트 */\n markAllReadText?: string;\n /** \"모두 삭제\" 버튼 텍스트 */\n deleteAllText?: string;\n /** \"미읽음만\" 체크박스 텍스트 */\n unreadOnlyText?: string;\n /** \"미읽음만\" 체크박스 상태 */\n unreadOnly?: boolean;\n /** 알림 클릭 핸들러 (알림 전체 객체 전달) */\n onNotificationClick?: (notification: NotificationItem) => void;\n /** 드롭다운 닫힐 때 (뷰포트에 들어온 미읽음 알림 ID 목록) */\n onClose?: (visibleUnreadIds: (string | number)[]) => void;\n /** 추가 로드 요청 */\n onLoadMore?: () => void;\n /** 모두 읽음 처리 요청 */\n onMarkAllRead?: () => void;\n /** 모두 삭제 요청 */\n onDeleteAll?: () => void;\n /** 개별 알림 삭제 요청 (알림 전체 객체 전달) */\n onDelete?: (notification: NotificationItem) => void;\n /** \"미읽음만\" 체크박스 토글 */\n onUnreadOnlyToggle?: (checked: boolean) => void;\n /** 드롭다운 정렬 방향 — \"right\"(기본): 우측 정렬되어 좌측으로 확장 / \"left\": 좌측 정렬되어 우측으로 확장 */\n dropdownAlign?: 'left' | 'right';\n className?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n\n/**\n * NotificationCenter 컴포넌트\n *\n * 알림 센터 - 알림 목록 표시, 무한스크롤, 읽음 추적\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport const NotificationCenter: React.FC = ({\n notifications = [],\n unreadCount = 0,\n hasMore = false,\n loading = false,\n titleText = 'Notifications',\n emptyText = 'No notifications',\n markAllReadText = 'Mark all as read',\n deleteAllText = 'Delete all',\n unreadOnlyText = 'Unread only',\n unreadOnly = false,\n onNotificationClick,\n onClose,\n onLoadMore,\n onMarkAllRead,\n onDeleteAll,\n onDelete,\n onUnreadOnlyToggle,\n dropdownAlign = 'right',\n className = '',\n id,\n editorAttrs,\n}) => {\n const [showNotifications, setShowNotifications] = useState(false);\n const notificationRef = useRef(null);\n const dropdownRef = useRef(null);\n const scrollRef = useRef(null);\n const sentinelRef = useRef(null);\n const visibleUnreadIdsRef = useRef>(new Set());\n\n // 무한스크롤 관련 최신 값 참조 (observer 재등록 없이 최신 prop 접근)\n const onLoadMoreRef = useRef(onLoadMore);\n const hasMoreRef = useRef(hasMore);\n const loadingRef = useRef(loading);\n const notificationsCountRef = useRef(notifications.length);\n // 마지막 onLoadMore 발행 시점의 notifications.length — 동일 길이에서 중복 발행 가드\n const lastRequestedCountRef = useRef(-1);\n\n useEffect(() => {\n onLoadMoreRef.current = onLoadMore;\n hasMoreRef.current = hasMore;\n loadingRef.current = loading;\n notificationsCountRef.current = notifications.length;\n });\n\n /**\n * 무한스크롤 — IntersectionObserver\n *\n * observer는 드롭다운 열림/닫힘 전환 시에만 재등록한다.\n * 목록이 append되거나 loading/hasMore가 바뀌어도 observer를 새로 만들지 않으므로,\n * sentinel이 이미 뷰포트에 있는 상태에서 재등록 시 즉시 intersecting callback이\n * 중복 발행되던 문제를 방지한다.\n */\n useEffect(() => {\n if (!sentinelRef.current || !scrollRef.current || !showNotifications) return;\n\n // 드롭다운이 새로 열릴 때마다 중복 가드 초기화\n lastRequestedCountRef.current = -1;\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (!entry.isIntersecting) return;\n if (loadingRef.current || !hasMoreRef.current) return;\n // B안 가드: 같은 length에서 이미 한 번 발행했다면 skip\n if (lastRequestedCountRef.current === notificationsCountRef.current) return;\n lastRequestedCountRef.current = notificationsCountRef.current;\n onLoadMoreRef.current?.();\n },\n { root: scrollRef.current, threshold: 0.1 }\n );\n\n observer.observe(sentinelRef.current);\n return () => observer.disconnect();\n }, [showNotifications]);\n\n /**\n * 뷰포트 내 미읽음 알림 추적 — IntersectionObserver\n */\n useEffect(() => {\n if (!scrollRef.current || !showNotifications) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n const id = entry.target.getAttribute('data-notification-id');\n const isUnread = entry.target.getAttribute('data-unread') === 'true';\n if (id && isUnread && entry.isIntersecting) {\n visibleUnreadIdsRef.current.add(id);\n }\n });\n },\n {\n root: scrollRef.current,\n threshold: 0.5,\n }\n );\n\n const items = scrollRef.current.querySelectorAll('[data-notification-id]');\n items.forEach((item) => observer.observe(item));\n\n return () => observer.disconnect();\n }, [showNotifications, notifications]);\n\n /**\n * 뱃지에 표시할 카운트 (handleClose 보다 먼저 선언 — deps 참조 순서)\n */\n const displayCount = unreadCount > 0 ? unreadCount : notifications.filter((n) => !n.read).length;\n\n /**\n * 드롭다운 닫기 — 뷰포트에 들어온 미읽음 알림 ID 전달\n *\n * 불필요한 read-batch 호출 방지:\n * 1) displayCount 가 0 이면 이미 모두 읽음/삭제 처리된 상태 → 전체 skip\n * 2) 뷰포트에 나타났던 ID 중 현재 notifications 기준 여전히 unread 인 것만 전달\n * (\"모두 읽음\" onSuccess 로 notifications 가 갱신되면 필터 결과가 빈 배열이 됨)\n */\n const handleClose = useCallback(() => {\n if (showNotifications) {\n if (displayCount > 0) {\n const unreadIds = Array.from(visibleUnreadIdsRef.current).filter((id) => {\n const n = notifications.find((item) => String(item.id) === String(id));\n return n && !n.read;\n });\n if (unreadIds.length > 0) {\n onClose?.(unreadIds);\n }\n }\n visibleUnreadIdsRef.current.clear();\n setShowNotifications(false);\n }\n }, [showNotifications, onClose, displayCount, notifications]);\n\n /**\n * 외부 클릭 감지 — 드롭다운이 열려 있을 때만 리스너 등록\n * (stale closure 방지: showNotifications 변경 시 리스너 재등록)\n * 주의: handleClose가 useCallback으로 선언된 이후에 위치해야 TDZ 위반 방지\n */\n useEffect(() => {\n if (!showNotifications) return;\n\n const handleClickOutside = (event: MouseEvent) => {\n if (\n notificationRef.current &&\n !notificationRef.current.contains(event.target as Node)\n ) {\n handleClose();\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, [showNotifications, handleClose]);\n\n /**\n * 드롭다운 토글\n */\n const handleToggle = useCallback(() => {\n if (showNotifications) {\n handleClose();\n } else {\n setShowNotifications(true);\n }\n }, [showNotifications, handleClose]);\n\n /**\n * 드롭다운 열릴 때 뷰포트 경계 검사 후 이탈량만큼 translateX 로 보정\n *\n * class 기반 left-0/right-0 전환은 부모 컨테이너 기준이라 부모가 뷰포트 가장자리에 있을 때\n * 여전히 이탈할 수 있다. 따라서 초기 렌더 후 드롭다운의 실제 뷰포트 좌표를 측정하여\n * 이탈한 픽셀만큼 반대 방향으로 이동시키는 방식을 사용한다.\n */\n useLayoutEffect(() => {\n const dropdown = dropdownRef.current;\n if (!showNotifications || !dropdown) return;\n\n // 측정 전 기존 transform 초기화 (재측정 시 누적 방지)\n dropdown.style.transform = '';\n const rect = dropdown.getBoundingClientRect();\n const vw = typeof window !== 'undefined' ? window.innerWidth : 0;\n\n // layout 미측정(jsdom 등)에서 rect 가 0 으로 반환되는 경우 보정 스킵\n if (rect.width === 0 && rect.height === 0) return;\n\n const margin = 8;\n let shift = 0;\n if (rect.right > vw - margin) {\n shift = -(rect.right - (vw - margin));\n } else if (rect.left < margin) {\n shift = margin - rect.left;\n }\n\n if (shift !== 0) {\n dropdown.style.transform = `translateX(${shift}px)`;\n }\n }, [showNotifications, dropdownAlign, notifications.length]);\n\n return (\n
\n \n \n {displayCount > 0 && (\n \n {displayCount > 99 ? '99+' : displayCount}\n \n )}\n \n\n {/* 알림 드롭다운 */}\n {showNotifications && (\n
\n
\n
\n {titleText}\n {onUnreadOnlyToggle && (\n onUnreadOnlyToggle(!unreadOnly)}\n >\n ) => {\n e.stopPropagation();\n onUnreadOnlyToggle(e.target.checked);\n }}\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className=\"w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer\"\n />\n {unreadOnlyText}\n
\n )}\n
\n
\n {onMarkAllRead && displayCount > 0 && (\n {\n onMarkAllRead();\n visibleUnreadIdsRef.current.clear();\n }}\n className=\"text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline cursor-pointer\"\n >\n {markAllReadText}\n \n )}\n {onDeleteAll && notifications.length > 0 && (\n {\n // 드롭다운은 닫지 않음 — 확인 모달이 닫히면 다시 자연스럽게 노출되어야 하고,\n // 사용자가 모달 취소 시에도 컨텍스트 유지 (요구사항)\n onDeleteAll();\n }}\n className=\"text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 hover:underline cursor-pointer\"\n >\n {deleteAllText}\n \n )}\n
\n
\n
\n {notifications.length === 0 && !loading ? (\n
\n {emptyText}\n
\n ) : (\n <>\n {notifications.map((notification) => {\n const isUnread = !notification.read;\n return (\n {\n notification.onClick?.();\n onNotificationClick?.(notification);\n setShowNotifications(false);\n }}\n className={`group flex items-start gap-3 px-4 py-3 text-left border-b border-gray-100 dark:border-gray-700 transition-colors cursor-pointer ${\n isUnread\n ? 'bg-blue-50/70 dark:bg-blue-900/20 hover:bg-blue-50 dark:hover:bg-blue-900/30'\n : 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 opacity-70'\n }`}\n >\n {notification.iconName && (\n \n )}\n
\n \n {notification.title}\n
\n \n {notification.message}\n
\n
\n {notification.time}\n
\n
\n
\n {isUnread && (\n \n )}\n {onDelete && (\n {\n e.stopPropagation();\n onDelete(notification);\n }}\n className=\"p-1 text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 rounded hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity\"\n aria-label=\"Delete notification\"\n >\n \n \n )}\n
\n \n );\n })}\n {/* 무한스크롤 sentinel */}\n {hasMore && (\n
\n {loading ? (\n ...\n ) : (\n \n )}\n
\n )}\n {loading && !hasMore && notifications.length > 0 && (\n
\n ...\n
\n )}\n \n )}\n \n \n )}\n \n );\n};\n","import React, { useState, useRef, useEffect } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { Img } from '../basic/Img';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:UserProfile')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:UserProfile]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:UserProfile]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:UserProfile]', ...args),\n};\n\n/**\n * 사용자 정보 인터페이스\n */\nexport interface User {\n id: number | string;\n uuid?: string;\n name: string;\n email: string;\n avatar?: string;\n role?: string;\n}\n\n/**\n * UserProfile Props\n */\nexport interface UserProfileProps {\n user: User;\n /** 프로필 설정 텍스트 */\n profileText?: string;\n /** 로그아웃 텍스트 */\n logoutText?: string;\n /** 언어 설정 텍스트 */\n languageText?: string;\n /** 사용 가능한 언어 목록 */\n availableLocales?: string[];\n onProfileClick?: () => void;\n onLogoutClick?: () => void;\n className?: string;\n /** 로그아웃 API 엔드포인트 (기본값: /api/admin/auth/logout) */\n logoutEndpoint?: string;\n /** 로그아웃 후 리다이렉션 경로 (기본값: /admin/login) */\n redirectPath?: string;\n /** Chevron 아이콘 표시 여부 (기본값: true) */\n showChevron?: boolean;\n /** 드롭다운 열림 방향 (기본값: 'up') */\n dropdownDirection?: 'up' | 'down';\n /** 드롭다운만 표시 (버튼 영역 숨김, 외부 요소 클릭 트리거용) */\n dropdownOnly?: boolean;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * UserProfile 컴포넌트\n *\n * 사용자 프로필 표시 및 드롭다운 메뉴 제공\n *\n * @example\n * ```tsx\n * console.log('프로필 클릭')}\n * onLogoutClick={() => console.log('로그아웃')}\n * />\n * ```\n */\nexport const UserProfile: React.FC = ({\n user = { name: '', email: '' },\n profileText = 'Profile Settings',\n logoutText = 'Logout',\n // languageText / availableLocales: 언어가 헤더로 일원화되어 드롭다운에서 제거됨.\n // props 는 인터페이스에 유지(하위호환)하되 본문에서 사용하지 않으므로 destructure 생략.\n onProfileClick,\n onLogoutClick,\n className = '',\n logoutEndpoint = '/api/admin/auth/logout',\n redirectPath = '/admin/login',\n showChevron = true,\n dropdownDirection = 'up',\n dropdownOnly = false,\n id,\n editorAttrs,\n}) => {\n const [showProfileMenu, setShowProfileMenu] = useState(false);\n const [isLoggingOut, setIsLoggingOut] = useState(false);\n const profileRef = useRef(null);\n\n /**\n * 외부 클릭 감지\n */\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (\n profileRef.current &&\n !profileRef.current.contains(event.target as Node)\n ) {\n setShowProfileMenu(false);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, []);\n\n /**\n * 쿠키에서 XSRF 토큰 읽기\n */\n const getXsrfToken = (): string | null => {\n const value = `; ${document.cookie}`;\n const parts = value.split(`; XSRF-TOKEN=`);\n if (parts.length === 2) {\n return decodeURIComponent(parts.pop()?.split(';').shift() || '');\n }\n return null;\n };\n\n /**\n * 로컬 스토리지에서 Bearer 토큰 읽기\n */\n const getBearerToken = (): string | null => {\n return localStorage.getItem('auth_token');\n };\n\n /**\n * 로그아웃 처리\n */\n const handleLogout = async () => {\n if (isLoggingOut) return;\n\n try {\n setIsLoggingOut(true);\n setShowProfileMenu(false);\n\n // 커스텀 로그아웃 핸들러 실행\n if (onLogoutClick) {\n onLogoutClick();\n return;\n }\n\n // 토큰 가져오기\n const xsrfToken = getXsrfToken();\n const bearerToken = getBearerToken();\n\n // API 호출\n const response = await fetch(logoutEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n ...(xsrfToken && { 'X-XSRF-TOKEN': xsrfToken }),\n ...(bearerToken && { Authorization: `Bearer ${bearerToken}` }),\n },\n credentials: 'include',\n });\n\n if (response.ok) {\n // 로그아웃 성공 - 로컬 스토리지에서 토큰 제거\n localStorage.removeItem('auth_token');\n // 로그인 페이지로 리다이렉션\n window.location.href = redirectPath;\n } else {\n logger.error('로그아웃 실패:', response.statusText);\n // 실패해도 토큰 제거 및 로그인 페이지로 이동 (토큰 만료 등의 경우)\n localStorage.removeItem('auth_token');\n window.location.href = redirectPath;\n }\n } catch (error) {\n logger.error('로그아웃 오류:', error);\n // 에러 발생 시에도 토큰 제거 및 로그인 페이지로 이동\n localStorage.removeItem('auth_token');\n window.location.href = redirectPath;\n } finally {\n setIsLoggingOut(false);\n }\n };\n\n return (\n
\n setShowProfileMenu(!showProfileMenu)}\n className={dropdownOnly\n ? \"w-full h-full opacity-0 cursor-pointer\"\n : \"flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors w-full\"}\n >\n {!dropdownOnly && (\n <>\n {user.avatar ? (\n \n ) : (\n
\n \n
\n )}\n
\n
{user.name}
\n {user.role && (\n
{user.role}
\n )}\n
\n {showChevron && (\n \n )}\n \n )}\n \n\n {/* 프로필 드롭다운 */}\n {showProfileMenu && (\n
\n {\n setShowProfileMenu(false);\n (window as any).G7Core?.dispatch({\n handler: 'navigate',\n params: { path: `/admin/users/${user.uuid}` },\n });\n }}\n >\n
{user.name}
\n
{user.email}
\n
\n {/* 언어 선택은 헤더 독립 버튼으로 일원화(드롭다운에서 제거) — 유저 템플릿 패리티(H-T7).\n languageText/availableLocales props 는 하위호환을 위해 유지하되 더 이상 사용하지 않는다. */}\n
\n {\n setShowProfileMenu(false);\n if (onProfileClick) {\n onProfileClick();\n } else {\n // 기본 동작: 사용자 수정 페이지로 이동 (SPA 네비게이션)\n (window as any).G7Core?.dispatch({\n handler: 'navigate',\n params: { path: `/admin/users/${user.uuid}/edit` },\n });\n }\n }}\n className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-start gap-3 transition-colors\"\n >\n \n {profileText}\n \n \n \n {isLoggingOut ? 'Logging out...' : logoutText}\n \n
\n
\n )}\n \n );\n};\n","import React, { useState, useRef, useEffect } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Span } from '../basic/Span';\nimport type { EditorAttrs } from '../../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:LanguageSelector')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:LanguageSelector]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:LanguageSelector]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:LanguageSelector]', ...args),\n};\n\n/**\n * 로케일 코드를 사람이 읽을 수 있는 이름으로 변환\n */\nconst getLocaleName = (locale: string): string => {\n // 활성 언어팩의 native_name 을 우선 사용 (LanguagePackServiceProvider 가 주입)\n const fromAppConfig = (window as any).G7Core?.state?.get?.('_global.appConfig.localeNames')?.[locale];\n if (fromAppConfig) {\n return fromAppConfig;\n }\n const fallback: Record = {\n ko: '한국어',\n en: 'English',\n ja: '日本語',\n zh: '中文',\n es: 'Español',\n fr: 'Français',\n de: 'Deutsch',\n };\n return fallback[locale] || locale;\n};\n\n/**\n * LanguageSelector Props\n */\nexport interface LanguageSelectorProps {\n /** 사용 가능한 언어 목록 (template.json의 locales에서 자동 바인딩) */\n availableLocales?: string[];\n /** 언어 설정 텍스트 */\n languageText?: string;\n /** 언어 변경 API 엔드포인트 */\n apiEndpoint?: string;\n /** 언어 변경 후 콜백 */\n onLanguageChange?: (locale: string) => void;\n /** 추가 CSS 클래스 */\n className?: string;\n /** 인라인 모드 (드롭다운 메뉴 내에서 사용) */\n inline?: boolean;\n /**\n * 독립 모드 버튼에 현재 선택 언어 코드를 globe 아이콘 옆에 표시한다.\n * 유저 템플릿 헤더 언어 버튼과 동일한 표기(아이콘 + 로케일 코드)를 위해 사용.\n */\n showCode?: boolean;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * LanguageSelector 컴포넌트\n *\n * 언어 전환 드롭다운 제공\n * - DB에 언어 설정 저장\n * - localStorage에 로케일 저장\n * - 새로고침 없이 UI 리렌더링\n *\n * @example\n * ```tsx\n * // 독립적으로 사용\n * \n *\n * // 인라인 모드 (다른 드롭다운 내에서 사용)\n * \n * ```\n */\nexport const LanguageSelector: React.FC = ({\n availableLocales = ['ko', 'en'],\n languageText = 'Language',\n apiEndpoint = '/api/admin/users/me/language',\n onLanguageChange,\n className = '',\n inline = false,\n showCode = false,\n id,\n editorAttrs,\n}) => {\n const [showMenu, setShowMenu] = useState(false);\n const [isChanging, setIsChanging] = useState(false);\n const [currentLocale, setCurrentLocale] = useState(() => {\n return localStorage.getItem('g7_locale') || 'ko';\n });\n const menuRef = useRef(null);\n\n /**\n * 외부 클릭 감지 (인라인 모드가 아닐 때만)\n */\n useEffect(() => {\n if (inline) return;\n\n const handleClickOutside = (event: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n setShowMenu(false);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, [inline]);\n\n /**\n * 쿠키에서 XSRF 토큰 읽기\n */\n const getXsrfToken = (): string | null => {\n const value = `; ${document.cookie}`;\n const parts = value.split(`; XSRF-TOKEN=`);\n if (parts.length === 2) {\n return decodeURIComponent(parts.pop()?.split(';').shift() || '');\n }\n return null;\n };\n\n /**\n * 로컬 스토리지에서 Bearer 토큰 읽기\n */\n const getBearerToken = (): string | null => {\n return localStorage.getItem('auth_token');\n };\n\n /**\n * 언어 변경 처리\n */\n const handleLanguageChange = async (locale: string) => {\n if (isChanging || locale === currentLocale) return;\n\n try {\n setIsChanging(true);\n\n const xsrfToken = getXsrfToken();\n const bearerToken = getBearerToken();\n\n // API 호출로 DB에 저장 (실패해도 UI는 변경)\n try {\n const response = await fetch(apiEndpoint, {\n method: 'PATCH',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n ...(xsrfToken && { 'X-XSRF-TOKEN': xsrfToken }),\n ...(bearerToken && { Authorization: `Bearer ${bearerToken}` }),\n },\n credentials: 'include',\n body: JSON.stringify({ language: locale }),\n });\n\n if (!response.ok) {\n logger.warn('언어 설정 저장 실패 (UI는 변경됨):', response.statusText);\n }\n } catch (apiError) {\n logger.warn('언어 설정 API 호출 실패 (UI는 변경됨):', apiError);\n }\n\n // UI 상태 업데이트\n setCurrentLocale(locale);\n setShowMenu(false);\n\n // 콜백 실행\n onLanguageChange?.(locale);\n\n // TemplateApp의 changeLocale 호출하여 UI 리렌더링\n if ((window as any).__templateApp) {\n await (window as any).__templateApp.changeLocale(locale);\n } else {\n logger.warn('TemplateApp not found, reloading page');\n window.location.reload();\n }\n } catch (error) {\n logger.error('언어 변경 오류:', error);\n } finally {\n setIsChanging(false);\n }\n };\n\n // 인라인 모드: 접기/펼치기 가능한 뱃지 방식\n if (inline) {\n return (\n
\n setShowMenu(!showMenu)}\n disabled={isChanging}\n className=\"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors\"\n >\n \n \n {languageText}\n \n \n {currentLocale.toUpperCase()}\n \n \n \n {showMenu && (\n
\n {availableLocales.map((locale) => (\n handleLanguageChange(locale)}\n disabled={isChanging}\n className={`\n px-3 py-1.5 text-sm font-medium rounded-md transition-colors\n ${\n currentLocale === locale\n ? 'bg-blue-600 text-white'\n : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'\n }\n ${isChanging ? 'opacity-50 cursor-not-allowed' : ''}\n `}\n >\n {getLocaleName(locale)}\n \n ))}\n
\n )}\n
\n );\n }\n\n // 독립 모드: 드롭다운 버튼과 메뉴\n return (\n
\n setShowMenu(!showMenu)}\n className={\n showCode\n ? 'flex items-center gap-1.5 px-2.5 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors cursor-pointer'\n : 'p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'\n }\n aria-haspopup=\"listbox\"\n aria-expanded={showMenu}\n aria-label={languageText}\n >\n \n {showCode && (\n {currentLocale}\n )}\n {showCode && (\n \n )}\n \n\n {showMenu && (\n
\n
\n {availableLocales.map((locale) => (\n handleLanguageChange(locale)}\n disabled={isChanging}\n className={`\n w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors\n ${currentLocale === locale ? 'bg-gray-50 dark:bg-gray-700' : ''}\n ${isChanging ? 'opacity-50 cursor-not-allowed' : ''}\n `}\n >\n \n {getLocaleName(locale)}\n \n {currentLocale === locale && (\n \n )}\n \n ))}\n
\n
\n )}\n
\n );\n};\n","import React, { useCallback } from 'react';\nimport { Div } from '../basic/Div';\nimport { A } from '../basic/A';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\n/**\n * 빠른 링크 인터페이스\n */\nexport interface QuickLink {\n id: string | number;\n label: string;\n url: string;\n iconName?: IconName;\n}\n\n/**\n * AdminFooter Props\n */\nexport interface AdminFooterProps {\n copyright?: string;\n version?: string;\n quickLinks?: QuickLink[];\n className?: string;\n copyrightModalId?: string;\n changelogModalId?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * AdminFooter 컴포넌트\n *\n * 관리자 푸터 - 버전 정보, 빠른 링크\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport const AdminFooter: React.FC = ({\n copyright = '© 2026 G7',\n version,\n quickLinks = [],\n className = '',\n changelogModalId,\n copyrightModalId,\n id,\n editorAttrs,\n}) => {\n const handleVersionClick = useCallback(() => {\n if (changelogModalId) {\n const G7Core = (window as any).G7Core;\n\n G7Core?.dispatch?.({\n handler: 'sequence',\n actions: [\n {\n handler: 'apiCall',\n auth_required: true,\n target: '/api/admin/changelog',\n params: {\n method: 'GET',\n },\n onSuccess: [\n {\n handler: 'setState',\n params: {\n target: 'global',\n coreChangelogContent: '{{response.data.content}}',\n },\n },\n {\n handler: 'openModal',\n target: changelogModalId,\n },\n ],\n onError: [\n {\n handler: 'openModal',\n target: changelogModalId,\n },\n ],\n },\n ],\n });\n }\n }, [changelogModalId]);\n\n const handleCopyrightClick = useCallback(() => {\n if (copyrightModalId) {\n const G7Core = (window as any).G7Core;\n\n G7Core?.dispatch?.({\n handler: 'sequence',\n actions: [\n {\n handler: 'apiCall',\n auth_required: true,\n target: '/api/admin/license',\n params: {\n method: 'GET',\n },\n onSuccess: [\n {\n handler: 'setState',\n params: {\n target: 'global',\n coreLicenseContent: '{{response.data.content}}',\n },\n },\n {\n handler: 'openModal',\n target: copyrightModalId,\n },\n ],\n onError: [\n {\n handler: 'openModal',\n target: copyrightModalId,\n },\n ],\n },\n ],\n });\n }\n }, [copyrightModalId]);\n\n return (\n
\n
\n {/* 왼쪽: 저작권 및 버전 정보 */}\n
\n \n {copyright}\n \n {version && (\n <>\n •\n \n \n v{version}\n \n \n )}\n
\n\n {/* 오른쪽: 빠른 링크 */}\n {quickLinks.length > 0 && (\n
\n {quickLinks.map((link) => (\n \n {link.iconName && }\n {link.label}\n \n ))}\n
\n )}\n
\n
\n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { H1 } from '../basic/H1';\nimport { P } from '../basic/P';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Breadcrumb, BreadcrumbItem } from './Breadcrumb';\nimport type { EditorAttrs } from '../../types';\n\n/**\n * 탭 아이템 인터페이스\n */\nexport interface TabItem {\n id: string | number;\n label: string;\n value: string;\n active?: boolean;\n badge?: string | number;\n}\n\n/**\n * 액션 버튼 인터페이스\n */\nexport interface ActionButton {\n id: string | number;\n label: string;\n onClick?: () => void;\n variant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n iconName?: IconName;\n disabled?: boolean;\n}\n\n/**\n * PageHeader Props\n */\nexport interface PageHeaderProps {\n title: string;\n description?: string;\n breadcrumbItems?: BreadcrumbItem[];\n tabs?: TabItem[];\n onTabChange?: (value: string) => void;\n actions?: ActionButton[];\n className?: string;\n children?: React.ReactNode;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * PageHeader 컴포넌트\n *\n * 페이지 헤더 - admin_dashboard.json의 page_header 스타일과 동일\n * 배경색 없음, flex items-center justify-between mb-6 레이아웃\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport const PageHeader: React.FC = ({\n title,\n description,\n breadcrumbItems,\n tabs,\n onTabChange,\n actions,\n className = '',\n children,\n id,\n editorAttrs,\n}) => {\n /**\n * Variant별 버튼 스타일\n */\n const getButtonClasses = (variant: ActionButton['variant'] = 'secondary'): string => {\n const variantClasses: Record, string> = {\n primary: 'page-action-primary',\n secondary: 'page-action-secondary',\n danger: 'page-action-danger',\n ghost: 'page-action-ghost',\n };\n\n return variantClasses[variant];\n };\n\n return (\n
\n {/* 브레드크럼 - Breadcrumb 컴포넌트 재사용 */}\n {breadcrumbItems && breadcrumbItems.length > 0 && (\n
\n \n
\n )}\n\n {/* 제목 및 액션 버튼 - admin_dashboard.json 스타일 */}\n
\n
\n

{title}

\n {description && (\n

{description}

\n )}\n
\n\n {/* 액션 버튼 또는 children */}\n {children ? (\n
{children}
\n ) : actions && actions.length > 0 ? (\n
\n {actions.map((action) => (\n \n {action.iconName && (\n \n )}\n {action.variant !== 'ghost' && action.label && (\n
{action.label}
\n )}\n \n ))}\n
\n ) : null}\n
\n\n {/* 탭 네비게이션 */}\n {tabs && tabs.length > 0 && (\n
\n {tabs.map((tab) => (\n onTabChange?.(tab.value)}\n className={tab.active ? 'tab-btn-active' : 'tab-btn'}\n >\n
{tab.label}
\n {tab.badge !== undefined && (\n
\n {tab.badge}\n
\n )}\n \n ))}\n
\n )}\n
\n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { H2 } from '../basic/H2';\nimport { P } from '../basic/P';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core 전역 객체의 스타일 헬퍼 접근\nconst G7Core = () => (window as any).G7Core;\n\nexport interface SectionHeaderProps {\n title?: string;\n description?: string;\n className?: string;\n style?: React.CSSProperties;\n children?: React.ReactNode;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * SectionHeader 집합 컴포넌트\n *\n * 페이지 내부의 섹션 (카드 / 탭 / 폼 그룹 등) 머리 부분을 표준화하는\n * 가벼운 헤더 시맨틱. 제목 (좌측) + 액션 자식 (우측) 의 균형 배치.\n *\n * PageHeader 와 다른 점:\n * - PageHeader 는 페이지 단위 머리 (제목 + description + actions array\n * + tabs + breadcrumb) 등 풍부한 구조.\n * - SectionHeader 는 카드/탭/폼 그룹 안의 섹션 머리 — 제목과 우측\n * children 슬롯만. 인라인 `flex items-center justify-between` 패턴\n * 중 \"제목 + 액션\" 묶음을 흡수.\n *\n * 기본 컴포넌트 조합: Div + H2 + P\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"SectionHeader\",\n * \"props\": { \"title\": \"최근 활동\", \"className\": \"mb-4\" },\n * \"children\": [\n * { \"type\": \"basic\", \"name\": \"Button\", ... }\n * ]\n * }\n */\nexport const SectionHeader: React.FC = ({\n title,\n description,\n className = '',\n style,\n children,\n id,\n editorAttrs,\n}) => {\n const baseClasses = 'flex items-center justify-between';\n const mergedClassName =\n G7Core()?.style?.mergeClasses?.(baseClasses, className) ?? `${baseClasses} ${className}`;\n\n return (\n
\n
\n {title &&

{title}

}\n {description && (\n

{description}

\n )}\n
\n {children &&
{children}
}\n
\n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { Img } from '../basic/Img';\nimport { H3 } from '../basic/H3';\nimport { P } from '../basic/P';\nimport { Span } from '../basic/Span';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { StatusBadge, StatusType } from './StatusBadge';\nimport { ActionMenu, ActionMenuItem } from './ActionMenu';\nimport type { EditorAttrs } from '../../types';\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n/**\n * 템플릿 상태\n */\nexport type TemplateStatus = 'active' | 'inactive' | 'pending' | 'error';\n\n/**\n * TemplateCard Props\n */\nexport interface TemplateCardProps {\n image?: string;\n imageAlt?: string;\n vendor: string;\n name: string;\n version: string;\n status: TemplateStatus;\n updateAvailable?: boolean;\n latestVersion?: string;\n dependencies?: string[];\n showLayoutEditButton?: boolean;\n actions?: ActionMenuItem[];\n onLayoutEditClick?: () => void;\n className?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * TemplateCard 컴포넌트\n *\n * 템플릿 카드 - 상태 뱃지, 미리보기 이미지, 퀵 액션 버튼(ActionMenu) 포함\n *\n * @example\n * ```tsx\n * console.log('Edit layouts')}\n * actions={[\n * { id: 'info', label: '정보 보기', iconName: IconName.InfoCircle, onClick: () => {} },\n * { id: 'update', label: '업데이트', iconName: IconName.Download, onClick: () => {} },\n * { id: 'remove', label: '제거', iconName: IconName.Trash, variant: 'danger', onClick: () => {} }\n * ]}\n * />\n * ```\n */\nexport const TemplateCard: React.FC = ({\n image,\n imageAlt,\n vendor,\n name,\n version,\n status,\n updateAvailable = false,\n latestVersion,\n dependencies = [],\n showLayoutEditButton = false,\n actions = [],\n onLayoutEditClick,\n className = '',\n id,\n editorAttrs,\n}) => {\n /**\n * 상태를 StatusType으로 매핑\n */\n const getStatusType = (status: TemplateStatus): StatusType => {\n const statusMap: Record = {\n active: 'success',\n inactive: 'default',\n pending: 'pending',\n error: 'error',\n };\n return statusMap[status];\n };\n\n /**\n * 상태 라벨 (다국어 지원)\n */\n const getStatusLabel = (status: TemplateStatus): string => {\n const labelMap: Record = {\n active: t('common.status_active'),\n inactive: t('common.status_inactive'),\n pending: t('common.status_pending'),\n error: t('common.status_error'),\n };\n return labelMap[status];\n };\n\n return (\n \n {/* 미리보기 이미지 */}\n {image && (\n
\n \n {/* 상태 뱃지 (이미지 위) */}\n
\n \n
\n {/* 업데이트 가능 뱃지 */}\n {updateAvailable && (\n
\n \n \n {t('common.update_available')}\n \n
\n )}\n
\n )}\n\n {/* 카드 본문 */}\n
\n {/* 헤더: 템플릿 정보 + 액션 메뉴 */}\n
\n
\n

\n {vendor}/{name}\n

\n
\n v{version}\n {updateAvailable && latestVersion && (\n <>\n →\n v{latestVersion}\n \n )}\n
\n {/* 이미지가 없을 때 상태 뱃지 표시 */}\n {!image && (\n
\n \n
\n )}\n
\n\n {/* 액션 메뉴 (ActionMenu 컴포넌트 재사용) */}\n {actions.length > 0 && (\n \n )}\n
\n\n {/* 의존성 정보 */}\n {dependencies.length > 0 && (\n
\n

{t('common.dependencies')}:

\n
\n {dependencies.map((dep, index) => (\n \n {dep}\n \n ))}\n
\n
\n )}\n\n {/* 레이아웃 편집 버튼 */}\n {showLayoutEditButton && onLayoutEditClick && (\n \n \n {t('common.edit_layout')}\n \n )}\n
\n \n );\n};\n","// e2e:allow편집기 추가 직후 type 미지정 시 크래시(\"컴포넌트 로드 실패\") 결함#4 수정(typeConfig 폴백). 라이브 검증은 Chrome MCP T1~T7(에디터 추가/속성편집/저장200/reload영속/게스트 사용자화면 렌더)로 수행, 단위 회귀는 Alert.test.tsx 결함#4 describe.\nimport React from 'react';\nimport { Div } from '../basic/Div';\nimport { P } from '../basic/P';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport type { EditorAttrs } from '../../types';\n\n/**\n * 알림 타입\n */\nexport type AlertType = 'info' | 'success' | 'warning' | 'error';\n\nexport interface AlertProps {\n /**\n * 알림 타입\n */\n type: AlertType;\n\n /**\n * 알림 메시지\n */\n message: string;\n\n /**\n * 닫기 버튼 표시 여부\n */\n dismissible?: boolean;\n\n /**\n * 닫기 버튼 클릭 시 콜백\n */\n onDismiss?: () => void;\n\n /**\n * 사용자 정의 클래스\n */\n className?: string;\n\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /**\n * 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread)\n */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * Alert 알림 컴포넌트\n *\n * 알림 메시지를 표시하는 composite 컴포넌트입니다.\n * type에 따라 다른 색상과 아이콘을 표시하며 dismissible 옵션을 지원합니다.\n *\n * @example\n * // 정보 알림\n * \n *\n * // 성공 알림 (닫기 버튼 포함)\n * console.log('dismissed')}\n * />\n *\n * // 경고 알림\n * \n *\n * // 에러 알림\n * \n */\nexport const Alert: React.FC = ({\n type,\n message,\n dismissible = false,\n onDismiss,\n className = '',\n id,\n editorAttrs,\n}) => {\n // 타입별 시맨틱 클래스 및 아이콘 매핑\n const typeConfig = {\n info: {\n containerClass: 'alert-info',\n iconClass: 'alert-icon-info',\n textClass: 'alert-text-info',\n icon: IconName.InfoCircle,\n },\n success: {\n containerClass: 'alert-success',\n iconClass: 'alert-icon-success',\n textClass: 'alert-text-success',\n icon: IconName.CheckCircle,\n },\n warning: {\n containerClass: 'alert-warning',\n iconClass: 'alert-icon-warning',\n textClass: 'alert-text-warning',\n icon: IconName.ExclamationTriangle,\n },\n error: {\n containerClass: 'alert-error',\n iconClass: 'alert-icon-error',\n textClass: 'alert-text-error',\n icon: IconName.TimesCircle,\n },\n };\n\n // type 미지정/미지원 값일 때도 크래시하지 않도록 info 로 폴백한다(레이아웃 편집기에서\n // 컴포넌트를 추가한 직후 작성자가 type 을 지정하기 전 상태를 안전하게 렌더).\n const config = typeConfig[type] ?? typeConfig.info;\n\n return (\n \n {/* 아이콘 */}\n \n\n {/* 메시지 */}\n

\n {message}\n

\n\n {/* 닫기 버튼 (dismissible이 true일 때만 표시) */}\n {dismissible && (\n \n \n \n )}\n \n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { H2 } from '../basic/H2';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\n\nexport interface LayoutEditorHeaderProps {\n /**\n * 레이아웃 이름\n */\n layoutName: string;\n\n /**\n * 뒤로가기 버튼 클릭 시 콜백\n */\n onBack: () => void;\n\n /**\n * 미리보기 버튼 클릭 시 콜백\n */\n onPreview: () => void;\n\n /**\n * 저장 버튼 클릭 시 콜백\n */\n onSave: () => void;\n\n /**\n * 저장 중 상태 여부\n */\n isSaving?: boolean;\n\n /**\n * 사용자 정의 클래스\n */\n className?: string;\n}\n\n/**\n * LayoutEditorHeader 레이아웃 편집 헤더 컴포넌트\n *\n * 레이아웃 편집 화면의 헤더 영역을 구성하는 composite 컴포넌트입니다.\n * 뒤로가기, 제목, 미리보기, 저장 버튼을 포함합니다.\n *\n * @example\n * // 기본 사용\n * console.log('back')}\n * onPreview={() => console.log('preview')}\n * onSave={() => console.log('save')}\n * />\n *\n * // 저장 중 상태\n * console.log('back')}\n * onPreview={() => console.log('preview')}\n * onSave={() => console.log('save')}\n * isSaving={true}\n * />\n */\nexport const LayoutEditorHeader: React.FC = ({\n layoutName,\n onBack,\n onPreview,\n onSave,\n isSaving = false,\n className = '',\n}) => {\n // 컨테이너 클래스 조합\n const containerClasses = `\n flex items-center justify-between gap-4 p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700\n ${className}\n `.trim().replace(/\\s+/g, ' ');\n\n return (\n
\n {/* 좌측 영역: 뒤로가기 버튼 + 제목 */}\n
\n {/* 뒤로가기 버튼 */}\n \n \n \n\n {/* 제목 */}\n

\n {layoutName}\n

\n
\n\n {/* 우측 영역: 미리보기 버튼 + 저장 버튼 */}\n
\n {/* 미리보기 버튼 */}\n \n
\n \n 미리보기\n
\n \n\n {/* 저장 버튼 */}\n \n
\n \n {isSaving ? '저장 중...' : '저장'}\n
\n \n
\n
\n );\n};\n","import React from 'react';\nimport { Div } from '../basic/Div';\nimport { Ul } from '../basic/Ul';\nimport { Li } from '../basic/Li';\nimport { Span } from '../basic/Span';\n\nexport interface LayoutFileItem {\n /**\n * 파일 ID\n */\n id: string | number;\n\n /**\n * 파일명\n */\n name: string;\n\n /**\n * 최종 수정 시간\n */\n updated_at: string;\n}\n\nexport interface LayoutFileListProps {\n /**\n * 파일 목록\n */\n files: LayoutFileItem[];\n\n /**\n * 선택된 파일 ID\n */\n selectedId?: string | number;\n\n /**\n * 파일 선택 시 콜백\n */\n onSelect: (id: string | number) => void;\n\n /**\n * 사용자 정의 클래스\n */\n className?: string;\n}\n\n/**\n * LayoutFileList 레이아웃 파일 목록 컴포넌트\n *\n * 레이아웃 파일 목록을 표시하고 선택 기능을 제공하는 composite 컴포넌트입니다.\n * 선택된 파일은 하이라이트되며, 접근성을 위해 ARIA 속성을 지원합니다.\n *\n * @example\n * // 기본 사용\n * console.log('Selected:', id)}\n * />\n *\n * // 빈 목록\n * {}} />\n */\nexport const LayoutFileList: React.FC = ({\n files,\n selectedId,\n onSelect,\n className = '',\n}) => {\n /**\n * ISO 날짜 문자열을 포맷팅\n */\n const formatDate = (dateString: string): string => {\n try {\n const date = new Date(dateString);\n\n // Invalid Date 체크\n if (isNaN(date.getTime())) {\n return dateString;\n }\n\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, '0');\n const day = String(date.getDate()).padStart(2, '0');\n const hours = String(date.getHours()).padStart(2, '0');\n const minutes = String(date.getMinutes()).padStart(2, '0');\n\n return `${year}-${month}-${day} ${hours}:${minutes}`;\n } catch {\n return dateString;\n }\n };\n\n /**\n * 파일 항목 클릭 핸들러\n */\n const handleFileClick = (id: string | number) => {\n onSelect(id);\n };\n\n // 컨테이너 클래스\n const containerClasses = `\n w-full\n ${className}\n `.trim().replace(/\\s+/g, ' ');\n\n return (\n
\n {files.length === 0 ? (\n
\n 파일이 없습니다.\n
\n ) : (\n \n {files.map((file) => {\n const isSelected = file.id === selectedId;\n\n // 항목 클래스 조합\n const itemClasses = `\n flex items-center justify-between p-4 cursor-pointer\n transition-colors duration-150\n ${\n isSelected\n ? 'bg-blue-100 dark:bg-blue-900 border-l-4 border-blue-500'\n : 'hover:bg-gray-100 dark:hover:bg-gray-800'\n }\n `.trim().replace(/\\s+/g, ' ');\n\n // 파일명 클래스\n const nameClasses = `\n font-medium\n ${\n isSelected\n ? 'text-blue-900 dark:text-blue-100'\n : 'text-gray-900 dark:text-gray-100'\n }\n `.trim().replace(/\\s+/g, ' ');\n\n // 날짜 클래스\n const dateClasses = `\n text-sm\n ${\n isSelected\n ? 'text-blue-700 dark:text-blue-300'\n : 'text-gray-500 dark:text-gray-400'\n }\n `.trim().replace(/\\s+/g, ' ');\n\n return (\n handleFileClick(file.id)}\n role=\"option\"\n aria-selected={isSelected}\n tabIndex={0}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n handleFileClick(file.id);\n }\n }}\n >\n
\n {file.name}\n \n {formatDate(file.updated_at)}\n \n
\n \n );\n })}\n \n )}\n
\n );\n};\n","function _arrayLikeToArray(r, a) {\n (null == a || a > r.length) && (a = r.length);\n for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];\n return n;\n}\nfunction _arrayWithHoles(r) {\n if (Array.isArray(r)) return r;\n}\nfunction _defineProperty(e, r, t) {\n return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {\n value: t,\n enumerable: true,\n configurable: true,\n writable: true\n }) : e[r] = t, e;\n}\nfunction _iterableToArrayLimit(r, l) {\n var t = null == r ? null : \"undefined\" != typeof Symbol && r[Symbol.iterator] || r[\"@@iterator\"];\n if (null != t) {\n var e,\n n,\n i,\n u,\n a = [],\n f = true,\n o = false;\n try {\n if (i = (t = t.call(r)).next, 0 === l) ; else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0);\n } catch (r) {\n o = true, n = r;\n } finally {\n try {\n if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return;\n } finally {\n if (o) throw n;\n }\n }\n return a;\n }\n}\nfunction _nonIterableRest() {\n throw new TypeError(\"Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\");\n}\nfunction ownKeys(e, r) {\n var t = Object.keys(e);\n if (Object.getOwnPropertySymbols) {\n var o = Object.getOwnPropertySymbols(e);\n r && (o = o.filter(function (r) {\n return Object.getOwnPropertyDescriptor(e, r).enumerable;\n })), t.push.apply(t, o);\n }\n return t;\n}\nfunction _objectSpread2(e) {\n for (var r = 1; r < arguments.length; r++) {\n var t = null != arguments[r] ? arguments[r] : {};\n r % 2 ? ownKeys(Object(t), true).forEach(function (r) {\n _defineProperty(e, r, t[r]);\n }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {\n Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));\n });\n }\n return e;\n}\nfunction _objectWithoutProperties(e, t) {\n if (null == e) return {};\n var o,\n r,\n i = _objectWithoutPropertiesLoose(e, t);\n if (Object.getOwnPropertySymbols) {\n var n = Object.getOwnPropertySymbols(e);\n for (r = 0; r < n.length; r++) o = n[r], -1 === t.indexOf(o) && {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]);\n }\n return i;\n}\nfunction _objectWithoutPropertiesLoose(r, e) {\n if (null == r) return {};\n var t = {};\n for (var n in r) if ({}.hasOwnProperty.call(r, n)) {\n if (-1 !== e.indexOf(n)) continue;\n t[n] = r[n];\n }\n return t;\n}\nfunction _slicedToArray(r, e) {\n return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();\n}\nfunction _toPrimitive(t, r) {\n if (\"object\" != typeof t || !t) return t;\n var e = t[Symbol.toPrimitive];\n if (void 0 !== e) {\n var i = e.call(t, r);\n if (\"object\" != typeof i) return i;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (\"string\" === r ? String : Number)(t);\n}\nfunction _toPropertyKey(t) {\n var i = _toPrimitive(t, \"string\");\n return \"symbol\" == typeof i ? i : i + \"\";\n}\nfunction _unsupportedIterableToArray(r, a) {\n if (r) {\n if (\"string\" == typeof r) return _arrayLikeToArray(r, a);\n var t = {}.toString.call(r).slice(8, -1);\n return \"Object\" === t && r.constructor && (t = r.constructor.name), \"Map\" === t || \"Set\" === t ? Array.from(r) : \"Arguments\" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;\n }\n}\n\nexport { _arrayLikeToArray as arrayLikeToArray, _arrayWithHoles as arrayWithHoles, _defineProperty as defineProperty, _iterableToArrayLimit as iterableToArrayLimit, _nonIterableRest as nonIterableRest, _objectSpread2 as objectSpread2, _objectWithoutProperties as objectWithoutProperties, _objectWithoutPropertiesLoose as objectWithoutPropertiesLoose, _slicedToArray as slicedToArray, _toPrimitive as toPrimitive, _toPropertyKey as toPropertyKey, _unsupportedIterableToArray as unsupportedIterableToArray };\n","function _defineProperty(obj, key, value) {\n if (key in obj) {\n Object.defineProperty(obj, key, {\n value: value,\n enumerable: true,\n configurable: true,\n writable: true\n });\n } else {\n obj[key] = value;\n }\n\n return obj;\n}\n\nfunction ownKeys(object, enumerableOnly) {\n var keys = Object.keys(object);\n\n if (Object.getOwnPropertySymbols) {\n var symbols = Object.getOwnPropertySymbols(object);\n if (enumerableOnly) symbols = symbols.filter(function (sym) {\n return Object.getOwnPropertyDescriptor(object, sym).enumerable;\n });\n keys.push.apply(keys, symbols);\n }\n\n return keys;\n}\n\nfunction _objectSpread2(target) {\n for (var i = 1; i < arguments.length; i++) {\n var source = arguments[i] != null ? arguments[i] : {};\n\n if (i % 2) {\n ownKeys(Object(source), true).forEach(function (key) {\n _defineProperty(target, key, source[key]);\n });\n } else if (Object.getOwnPropertyDescriptors) {\n Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));\n } else {\n ownKeys(Object(source)).forEach(function (key) {\n Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));\n });\n }\n }\n\n return target;\n}\n\nfunction compose() {\n for (var _len = arguments.length, fns = new Array(_len), _key = 0; _key < _len; _key++) {\n fns[_key] = arguments[_key];\n }\n\n return function (x) {\n return fns.reduceRight(function (y, f) {\n return f(y);\n }, x);\n };\n}\n\nfunction curry(fn) {\n return function curried() {\n var _this = this;\n\n for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {\n args[_key2] = arguments[_key2];\n }\n\n return args.length >= fn.length ? fn.apply(this, args) : function () {\n for (var _len3 = arguments.length, nextArgs = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) {\n nextArgs[_key3] = arguments[_key3];\n }\n\n return curried.apply(_this, [].concat(args, nextArgs));\n };\n };\n}\n\nfunction isObject(value) {\n return {}.toString.call(value).includes('Object');\n}\n\nfunction isEmpty(obj) {\n return !Object.keys(obj).length;\n}\n\nfunction isFunction(value) {\n return typeof value === 'function';\n}\n\nfunction hasOwnProperty(object, property) {\n return Object.prototype.hasOwnProperty.call(object, property);\n}\n\nfunction validateChanges(initial, changes) {\n if (!isObject(changes)) errorHandler('changeType');\n if (Object.keys(changes).some(function (field) {\n return !hasOwnProperty(initial, field);\n })) errorHandler('changeField');\n return changes;\n}\n\nfunction validateSelector(selector) {\n if (!isFunction(selector)) errorHandler('selectorType');\n}\n\nfunction validateHandler(handler) {\n if (!(isFunction(handler) || isObject(handler))) errorHandler('handlerType');\n if (isObject(handler) && Object.values(handler).some(function (_handler) {\n return !isFunction(_handler);\n })) errorHandler('handlersType');\n}\n\nfunction validateInitial(initial) {\n if (!initial) errorHandler('initialIsRequired');\n if (!isObject(initial)) errorHandler('initialType');\n if (isEmpty(initial)) errorHandler('initialContent');\n}\n\nfunction throwError(errorMessages, type) {\n throw new Error(errorMessages[type] || errorMessages[\"default\"]);\n}\n\nvar errorMessages = {\n initialIsRequired: 'initial state is required',\n initialType: 'initial state should be an object',\n initialContent: 'initial state shouldn\\'t be an empty object',\n handlerType: 'handler should be an object or a function',\n handlersType: 'all handlers should be a functions',\n selectorType: 'selector should be a function',\n changeType: 'provided value of changes should be an object',\n changeField: 'it seams you want to change a field in the state which is not specified in the \"initial\" state',\n \"default\": 'an unknown error accured in `state-local` package'\n};\nvar errorHandler = curry(throwError)(errorMessages);\nvar validators = {\n changes: validateChanges,\n selector: validateSelector,\n handler: validateHandler,\n initial: validateInitial\n};\n\nfunction create(initial) {\n var handler = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};\n validators.initial(initial);\n validators.handler(handler);\n var state = {\n current: initial\n };\n var didUpdate = curry(didStateUpdate)(state, handler);\n var update = curry(updateState)(state);\n var validate = curry(validators.changes)(initial);\n var getChanges = curry(extractChanges)(state);\n\n function getState() {\n var selector = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : function (state) {\n return state;\n };\n validators.selector(selector);\n return selector(state.current);\n }\n\n function setState(causedChanges) {\n compose(didUpdate, update, validate, getChanges)(causedChanges);\n }\n\n return [getState, setState];\n}\n\nfunction extractChanges(state, causedChanges) {\n return isFunction(causedChanges) ? causedChanges(state.current) : causedChanges;\n}\n\nfunction updateState(state, changes) {\n state.current = _objectSpread2(_objectSpread2({}, state.current), changes);\n return changes;\n}\n\nfunction didStateUpdate(state, handler, changes) {\n isFunction(handler) ? handler(state.current) : Object.keys(changes).forEach(function (field) {\n var _handler$field;\n\n return (_handler$field = handler[field]) === null || _handler$field === void 0 ? void 0 : _handler$field.call(handler, state.current[field]);\n });\n return changes;\n}\n\nvar index = {\n create: create\n};\n\nexport default index;\n","var config = {\n paths: {\n vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs'\n }\n};\n\nexport { config as default };\n","function curry(fn) {\n return function curried() {\n var _this = this;\n for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {\n args[_key] = arguments[_key];\n }\n return args.length >= fn.length ? fn.apply(this, args) : function () {\n for (var _len2 = arguments.length, nextArgs = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {\n nextArgs[_key2] = arguments[_key2];\n }\n return curried.apply(_this, [].concat(args, nextArgs));\n };\n };\n}\n\nexport { curry as default };\n","function isObject(value) {\n return {}.toString.call(value).includes('Object');\n}\n\nexport { isObject as default };\n","import curry from '../utils/curry.js';\nimport isObject from '../utils/isObject.js';\n\n/**\n * validates the configuration object and informs about deprecation\n * @param {Object} config - the configuration object \n * @return {Object} config - the validated configuration object\n */\nfunction validateConfig(config) {\n if (!config) errorHandler('configIsRequired');\n if (!isObject(config)) errorHandler('configType');\n if (config.urls) {\n informAboutDeprecation();\n return {\n paths: {\n vs: config.urls.monacoBase\n }\n };\n }\n return config;\n}\n\n/**\n * logs deprecation message\n */\nfunction informAboutDeprecation() {\n console.warn(errorMessages.deprecation);\n}\nfunction throwError(errorMessages, type) {\n throw new Error(errorMessages[type] || errorMessages[\"default\"]);\n}\nvar errorMessages = {\n configIsRequired: 'the configuration object is required',\n configType: 'the configuration object should be an object',\n \"default\": 'an unknown error accured in `@monaco-editor/loader` package',\n deprecation: \"Deprecation warning!\\n You are using deprecated way of configuration.\\n\\n Instead of using\\n monaco.config({ urls: { monacoBase: '...' } })\\n use\\n monaco.config({ paths: { vs: '...' } })\\n\\n For more please check the link https://github.com/suren-atoyan/monaco-loader#config\\n \"\n};\nvar errorHandler = curry(throwError)(errorMessages);\nvar validators = {\n config: validateConfig\n};\n\nexport { validators as default, errorHandler, errorMessages };\n","var compose = function compose() {\n for (var _len = arguments.length, fns = new Array(_len), _key = 0; _key < _len; _key++) {\n fns[_key] = arguments[_key];\n }\n return function (x) {\n return fns.reduceRight(function (y, f) {\n return f(y);\n }, x);\n };\n};\n\nexport { compose as default };\n","import { objectSpread2 as _objectSpread2 } from '../_virtual/_rollupPluginBabelHelpers.js';\n\nfunction merge(target, source) {\n Object.keys(source).forEach(function (key) {\n if (source[key] instanceof Object) {\n if (target[key]) {\n Object.assign(source[key], merge(target[key], source[key]));\n }\n }\n });\n return _objectSpread2(_objectSpread2({}, target), source);\n}\n\nexport { merge as default };\n","// The source (has been changed) is https://github.com/facebook/react/issues/5465#issuecomment-157888325\n\nvar CANCELATION_MESSAGE = {\n type: 'cancelation',\n msg: 'operation is manually canceled'\n};\nfunction makeCancelable(promise) {\n var hasCanceled_ = false;\n var wrappedPromise = new Promise(function (resolve, reject) {\n promise.then(function (val) {\n return hasCanceled_ ? reject(CANCELATION_MESSAGE) : resolve(val);\n });\n promise[\"catch\"](reject);\n });\n return wrappedPromise.cancel = function () {\n return hasCanceled_ = true;\n }, wrappedPromise;\n}\n\nexport { CANCELATION_MESSAGE, makeCancelable as default };\n","import { slicedToArray as _slicedToArray, objectWithoutProperties as _objectWithoutProperties } from '../_virtual/_rollupPluginBabelHelpers.js';\nimport state from 'state-local';\nimport config$1 from '../config/index.js';\nimport validators from '../validators/index.js';\nimport compose from '../utils/compose.js';\nimport merge from '../utils/deepMerge.js';\nimport makeCancelable from '../utils/makeCancelable.js';\n\nvar _excluded = [\"monaco\"];\n\n/** the local state of the module */\nvar _state$create = state.create({\n config: config$1,\n isInitialized: false,\n resolve: null,\n reject: null,\n monaco: null\n }),\n _state$create2 = _slicedToArray(_state$create, 2),\n getState = _state$create2[0],\n setState = _state$create2[1];\n\n/**\n * set the loader configuration\n * @param {Object} config - the configuration object\n */\nfunction config(globalConfig) {\n var _validators$config = validators.config(globalConfig),\n monaco = _validators$config.monaco,\n config = _objectWithoutProperties(_validators$config, _excluded);\n setState(function (state) {\n return {\n config: merge(state.config, config),\n monaco: monaco\n };\n });\n}\n\n/**\n * handles the initialization of the monaco-editor\n * @return {Promise} - returns an instance of monaco (with a cancelable promise)\n */\nfunction init() {\n var state = getState(function (_ref) {\n var monaco = _ref.monaco,\n isInitialized = _ref.isInitialized,\n resolve = _ref.resolve;\n return {\n monaco: monaco,\n isInitialized: isInitialized,\n resolve: resolve\n };\n });\n if (!state.isInitialized) {\n setState({\n isInitialized: true\n });\n if (state.monaco) {\n state.resolve(state.monaco);\n return makeCancelable(wrapperPromise);\n }\n if (window.monaco && window.monaco.editor) {\n storeMonacoInstance(window.monaco);\n state.resolve(window.monaco);\n return makeCancelable(wrapperPromise);\n }\n compose(injectScripts, getMonacoLoaderScript)(configureLoader);\n }\n return makeCancelable(wrapperPromise);\n}\n\n/**\n * injects provided scripts into the document.body\n * @param {Object} script - an HTML script element\n * @return {Object} - the injected HTML script element\n */\nfunction injectScripts(script) {\n return document.body.appendChild(script);\n}\n\n/**\n * creates an HTML script element with/without provided src\n * @param {string} [src] - the source path of the script\n * @return {Object} - the created HTML script element\n */\nfunction createScript(src) {\n var script = document.createElement('script');\n return src && (script.src = src), script;\n}\n\n/**\n * creates an HTML script element with the monaco loader src\n * @return {Object} - the created HTML script element\n */\nfunction getMonacoLoaderScript(configureLoader) {\n var state = getState(function (_ref2) {\n var config = _ref2.config,\n reject = _ref2.reject;\n return {\n config: config,\n reject: reject\n };\n });\n var loaderScript = createScript(\"\".concat(state.config.paths.vs, \"/loader.js\"));\n loaderScript.onload = function () {\n return configureLoader();\n };\n loaderScript.onerror = state.reject;\n return loaderScript;\n}\n\n/**\n * configures the monaco loader\n */\nfunction configureLoader() {\n var state = getState(function (_ref3) {\n var config = _ref3.config,\n resolve = _ref3.resolve,\n reject = _ref3.reject;\n return {\n config: config,\n resolve: resolve,\n reject: reject\n };\n });\n var require = window.require;\n require.config(state.config);\n require(['vs/editor/editor.main'], function (_ref4) {\n var monaco = _ref4.m;\n storeMonacoInstance(monaco);\n state.resolve(monaco);\n }, function (error) {\n state.reject(error);\n });\n}\n\n/**\n * store monaco instance in local state\n */\nfunction storeMonacoInstance(monaco) {\n if (!getState().monaco) {\n setState({\n monaco: monaco\n });\n }\n}\n\n/**\n * internal helper function\n * extracts stored monaco instance\n * @return {Object|null} - the monaco instance\n */\nfunction __getMonacoInstance() {\n return getState(function (_ref5) {\n var monaco = _ref5.monaco;\n return monaco;\n });\n}\nvar wrapperPromise = new Promise(function (resolve, reject) {\n return setState({\n resolve: resolve,\n reject: reject\n });\n});\nvar loader = {\n config: config,\n init: init,\n __getMonacoInstance: __getMonacoInstance\n};\n\nexport { loader as default };\n","import _t from\"@monaco-editor/loader\";import{memo as Te}from\"react\";import ke,{useState as re,useRef as S,useCallback as oe,useEffect as ne}from\"react\";import Se from\"@monaco-editor/loader\";import{memo as ye}from\"react\";import K from\"react\";var le={wrapper:{display:\"flex\",position:\"relative\",textAlign:\"initial\"},fullWidth:{width:\"100%\"},hide:{display:\"none\"}},v=le;import me from\"react\";var ae={container:{display:\"flex\",height:\"100%\",width:\"100%\",justifyContent:\"center\",alignItems:\"center\"}},Y=ae;function Me({children:e}){return me.createElement(\"div\",{style:Y.container},e)}var Z=Me;var $=Z;function Ee({width:e,height:r,isEditorReady:n,loading:t,_ref:a,className:m,wrapperProps:E}){return K.createElement(\"section\",{style:{...v.wrapper,width:e,height:r},...E},!n&&K.createElement($,null,t),K.createElement(\"div\",{ref:a,style:{...v.fullWidth,...!n&&v.hide},className:m}))}var ee=Ee;var H=ye(ee);import{useEffect as xe}from\"react\";function Ce(e){xe(e,[])}var k=Ce;import{useEffect as ge,useRef as Re}from\"react\";function he(e,r,n=!0){let t=Re(!0);ge(t.current||!n?()=>{t.current=!1}:e,r)}var l=he;function D(){}function h(e,r,n,t){return De(e,t)||be(e,r,n,t)}function De(e,r){return e.editor.getModel(te(e,r))}function be(e,r,n,t){return e.editor.createModel(r,n,t?te(e,t):void 0)}function te(e,r){return e.Uri.parse(r)}function Oe({original:e,modified:r,language:n,originalLanguage:t,modifiedLanguage:a,originalModelPath:m,modifiedModelPath:E,keepCurrentOriginalModel:g=!1,keepCurrentModifiedModel:N=!1,theme:x=\"light\",loading:P=\"Loading...\",options:y={},height:V=\"100%\",width:z=\"100%\",className:F,wrapperProps:j={},beforeMount:A=D,onMount:q=D}){let[M,O]=re(!1),[T,s]=re(!0),u=S(null),c=S(null),w=S(null),d=S(q),o=S(A),b=S(!1);k(()=>{let i=Se.init();return i.then(f=>(c.current=f)&&s(!1)).catch(f=>f?.type!==\"cancelation\"&&console.error(\"Monaco initialization: error:\",f)),()=>u.current?I():i.cancel()}),l(()=>{if(u.current&&c.current){let i=u.current.getOriginalEditor(),f=h(c.current,e||\"\",t||n||\"text\",m||\"\");f!==i.getModel()&&i.setModel(f)}},[m],M),l(()=>{if(u.current&&c.current){let i=u.current.getModifiedEditor(),f=h(c.current,r||\"\",a||n||\"text\",E||\"\");f!==i.getModel()&&i.setModel(f)}},[E],M),l(()=>{let i=u.current.getModifiedEditor();i.getOption(c.current.editor.EditorOption.readOnly)?i.setValue(r||\"\"):r!==i.getValue()&&(i.executeEdits(\"\",[{range:i.getModel().getFullModelRange(),text:r||\"\",forceMoveMarkers:!0}]),i.pushUndoStop())},[r],M),l(()=>{u.current?.getModel()?.original.setValue(e||\"\")},[e],M),l(()=>{let{original:i,modified:f}=u.current.getModel();c.current.editor.setModelLanguage(i,t||n||\"text\"),c.current.editor.setModelLanguage(f,a||n||\"text\")},[n,t,a],M),l(()=>{c.current?.editor.setTheme(x)},[x],M),l(()=>{u.current?.updateOptions(y)},[y],M);let L=oe(()=>{if(!c.current)return;o.current(c.current);let i=h(c.current,e||\"\",t||n||\"text\",m||\"\"),f=h(c.current,r||\"\",a||n||\"text\",E||\"\");u.current?.setModel({original:i,modified:f})},[n,r,a,e,t,m,E]),U=oe(()=>{!b.current&&w.current&&(u.current=c.current.editor.createDiffEditor(w.current,{automaticLayout:!0,...y}),L(),c.current?.editor.setTheme(x),O(!0),b.current=!0)},[y,x,L]);ne(()=>{M&&d.current(u.current,c.current)},[M]),ne(()=>{!T&&!M&&U()},[T,M,U]);function I(){let i=u.current?.getModel();g||i?.original?.dispose(),N||i?.modified?.dispose(),u.current?.dispose()}return ke.createElement(H,{width:z,height:V,isEditorReady:M,loading:P,_ref:w,className:F,wrapperProps:j})}var ie=Oe;var we=Te(ie);import{useState as Ie}from\"react\";import ce from\"@monaco-editor/loader\";function Pe(){let[e,r]=Ie(ce.__getMonacoInstance());return k(()=>{let n;return e||(n=ce.init(),n.then(t=>{r(t)})),()=>n?.cancel()}),e}var Le=Pe;import{memo as ze}from\"react\";import We,{useState as ue,useEffect as W,useRef as C,useCallback as _e}from\"react\";import Ne from\"@monaco-editor/loader\";import{useEffect as Ue,useRef as ve}from\"react\";function He(e){let r=ve();return Ue(()=>{r.current=e},[e]),r.current}var se=He;var _=new Map;function Ve({defaultValue:e,defaultLanguage:r,defaultPath:n,value:t,language:a,path:m,theme:E=\"light\",line:g,loading:N=\"Loading...\",options:x={},overrideServices:P={},saveViewState:y=!0,keepCurrentModel:V=!1,width:z=\"100%\",height:F=\"100%\",className:j,wrapperProps:A={},beforeMount:q=D,onMount:M=D,onChange:O,onValidate:T=D}){let[s,u]=ue(!1),[c,w]=ue(!0),d=C(null),o=C(null),b=C(null),L=C(M),U=C(q),I=C(),i=C(t),f=se(m),Q=C(!1),B=C(!1);k(()=>{let p=Ne.init();return p.then(R=>(d.current=R)&&w(!1)).catch(R=>R?.type!==\"cancelation\"&&console.error(\"Monaco initialization: error:\",R)),()=>o.current?pe():p.cancel()}),l(()=>{let p=h(d.current,e||t||\"\",r||a||\"\",m||n||\"\");p!==o.current?.getModel()&&(y&&_.set(f,o.current?.saveViewState()),o.current?.setModel(p),y&&o.current?.restoreViewState(_.get(m)))},[m],s),l(()=>{o.current?.updateOptions(x)},[x],s),l(()=>{!o.current||t===void 0||(o.current.getOption(d.current.editor.EditorOption.readOnly)?o.current.setValue(t):t!==o.current.getValue()&&(B.current=!0,o.current.executeEdits(\"\",[{range:o.current.getModel().getFullModelRange(),text:t,forceMoveMarkers:!0}]),o.current.pushUndoStop(),B.current=!1))},[t],s),l(()=>{let p=o.current?.getModel();p&&a&&d.current?.editor.setModelLanguage(p,a)},[a],s),l(()=>{g!==void 0&&o.current?.revealLine(g)},[g],s),l(()=>{d.current?.editor.setTheme(E)},[E],s);let X=_e(()=>{if(!(!b.current||!d.current)&&!Q.current){U.current(d.current);let p=m||n,R=h(d.current,t||e||\"\",r||a||\"\",p||\"\");o.current=d.current?.editor.create(b.current,{model:R,automaticLayout:!0,...x},P),y&&o.current.restoreViewState(_.get(p)),d.current.editor.setTheme(E),g!==void 0&&o.current.revealLine(g),u(!0),Q.current=!0}},[e,r,n,t,a,m,x,P,y,E,g]);W(()=>{s&&L.current(o.current,d.current)},[s]),W(()=>{!c&&!s&&X()},[c,s,X]),i.current=t,W(()=>{s&&O&&(I.current?.dispose(),I.current=o.current?.onDidChangeModelContent(p=>{B.current||O(o.current.getValue(),p)}))},[s,O]),W(()=>{if(s){let p=d.current.editor.onDidChangeMarkers(R=>{let G=o.current.getModel()?.uri;if(G&&R.find(J=>J.path===G.path)){let J=d.current.editor.getModelMarkers({resource:G});T?.(J)}});return()=>{p?.dispose()}}return()=>{}},[s,T]);function pe(){I.current?.dispose(),V?y&&_.set(m,o.current.saveViewState()):o.current.getModel()?.dispose(),o.current.dispose()}return We.createElement(H,{width:z,height:F,isEditorReady:s,loading:N,_ref:b,className:j,wrapperProps:A})}var fe=Ve;var de=ze(fe);var Ft=de;export{we as DiffEditor,de as Editor,Ft as default,_t as loader,Le as useMonaco};\n//# sourceMappingURL=index.mjs.map","import React, { useEffect, useRef } from 'react';\nimport Editor, { Monaco } from '@monaco-editor/react';\nimport * as monaco from 'monaco-editor';\n\nexport interface CodeEditorProps {\n value: string;\n onChange?: (event: { target: { value: string } }) => void;\n language?: string;\n height?: string;\n readOnly?: boolean;\n theme?: 'vs-dark' | 'vs-light';\n}\n\nexport const CodeEditor: React.FC = ({\n value,\n onChange,\n language = 'json',\n height = '100%',\n readOnly = false,\n theme = 'vs-dark',\n}) => {\n const editorRef = useRef(null);\n\n const handleEditorDidMount = (\n editor: monaco.editor.IStandaloneCodeEditor,\n monacoInstance: Monaco\n ) => {\n editorRef.current = editor;\n\n // JSON 스키마 검증 설정\n if (language === 'json') {\n monacoInstance.languages.json.jsonDefaults.setDiagnosticsOptions({\n validate: true,\n schemaValidation: 'error',\n allowComments: false,\n });\n }\n\n // 에디터 옵션 설정\n editor.updateOptions({\n readOnly,\n minimap: { enabled: false },\n scrollBeyondLastLine: false,\n fontSize: 14,\n lineNumbers: 'on',\n renderWhitespace: 'selection',\n automaticLayout: true,\n });\n };\n\n const handleChange = (value: string | undefined) => {\n if (onChange && value !== undefined) {\n // 엔진의 isCustomComponentEvent 경로로 처리되도록 { target: { value } } 패턴 사용\n onChange({ target: { value } });\n }\n };\n\n useEffect(() => {\n if (editorRef.current) {\n editorRef.current.updateOptions({ readOnly });\n }\n }, [readOnly]);\n\n return (\n
\n \n
\n );\n};\n","import React, { useCallback } from 'react';\nimport { Ul } from '../basic/Ul';\nimport { Li } from '../basic/Li';\nimport { Span } from '../basic/Span';\nimport { Div } from '../basic/Div';\n\nexport interface VersionItem {\n id: string | number;\n version: string;\n created_at: string;\n changes_summary: {\n added: number;\n removed: number;\n };\n}\n\nexport interface VersionListProps {\n versions: VersionItem[];\n selectedId?: string | number;\n onSelect?: (id: string | number) => void;\n}\n\nconst formatDate = (dateString: string): string => {\n const date = new Date(dateString);\n return new Intl.DateTimeFormat('ko-KR', {\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n }).format(date);\n};\n\ninterface VersionListItemProps {\n version: VersionItem;\n isSelected: boolean;\n onSelect?: (id: string | number) => void;\n}\n\nconst VersionListItem: React.FC = React.memo(({ version, isSelected, onSelect }) => {\n const handleClick = useCallback(() => {\n onSelect?.(version.id);\n }, [onSelect, version.id]);\n\n return (\n \n
\n
\n \n 버전 {version.version}\n \n \n {formatDate(version.created_at)}\n \n
\n\n
\n \n +{version.changes_summary.added}\n \n \n -{version.changes_summary.removed}\n \n
\n
\n \n );\n});\n\nVersionListItem.displayName = 'VersionListItem';\n\n/**\n * 레이아웃 버전 목록을 표시하는 컴포넌트\n * 각 버전의 변경 요약을 표시하고 선택 기능을 제공합니다.\n */\nexport const VersionList: React.FC = ({\n versions,\n selectedId,\n onSelect,\n}) => {\n if (versions.length === 0) {\n return (\n
\n 버전이 없습니다.\n
\n );\n }\n\n return (\n \n {versions.map((version) => (\n \n ))}\n \n );\n};\n","import React, { useState } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { VersionList, VersionItem } from './VersionList';\n\nexport interface LayoutHistoryPanelProps {\n /** 레이아웃 ID */\n layoutId: string | number;\n /** 버전 목록 */\n versions: VersionItem[];\n /** 버전 복원 콜백 */\n onRestore: (versionId: string | number) => void;\n}\n\n/**\n * 레이아웃 히스토리 패널 컴포넌트\n * 버전 목록을 표시하고 선택된 버전을 복원하는 기능을 제공합니다.\n */\nexport const LayoutHistoryPanel: React.FC = ({\n layoutId: _layoutId,\n versions,\n onRestore,\n}) => {\n const [selectedVersionId, setSelectedVersionId] = useState();\n\n const handleVersionSelect = (versionId: string | number): void => {\n setSelectedVersionId(versionId);\n };\n\n const handleRestore = (): void => {\n if (selectedVersionId !== undefined) {\n onRestore(selectedVersionId);\n }\n };\n\n const isRestoreDisabled = selectedVersionId === undefined;\n\n return (\n \n {/* 버전 목록 */}\n
\n \n
\n\n {/* 복원 버튼 */}\n
\n \n 복원\n \n
\n \n );\n};\n","import React, { useState, type FormEvent } from 'react';\nimport { Div } from '../basic/Div';\nimport { Form } from '../basic/Form';\nimport { Label } from '../basic/Label';\nimport { Input } from '../basic/Input';\nimport { Button } from '../basic/Button';\nimport { A } from '../basic/A';\nimport { P } from '../basic/P';\nimport { Svg } from '../basic/Svg';\n\n/**\n * LoginForm props 인터페이스\n */\nexport interface LoginFormProps {\n /** 이메일 라벨 텍스트 */\n emailLabel?: string;\n /** 비밀번호 라벨 텍스트 */\n passwordLabel?: string;\n /** 로그인 버튼 텍스트 */\n submitButtonText?: string;\n /** 처리 중 텍스트 */\n processingText?: string;\n /** 이메일 입력 필드 placeholder */\n emailPlaceholder?: string;\n /** 비밀번호 입력 필드 placeholder */\n passwordPlaceholder?: string;\n /** 비밀번호 찾기 텍스트 */\n forgotPasswordText?: string;\n /** 비밀번호 찾기 URL */\n forgotPasswordUrl?: string;\n /** 이메일 필수 입력 에러 메시지 */\n emailRequiredError?: string;\n /** 이메일 형식 에러 메시지 */\n emailInvalidError?: string;\n /** 비밀번호 필수 입력 에러 메시지 */\n passwordRequiredError?: string;\n /** 폼 제출 핸들러 */\n onSubmit?: (data: { email: string; password: string }) => void;\n /** 로딩 액션 맵 (ActionDispatcher에서 전달) */\n loadingActions?: Record;\n /** 외부에서 직접 로딩 상태 제어 */\n isLoading?: boolean;\n /** 외부에서 직접 비활성화 상태 제어 */\n disabled?: boolean;\n /** API 에러 메시지 (ActionDispatcher에서 전달) */\n apiError?: string;\n /** 커스텀 className */\n className?: string;\n /** 커스텀 스타일 */\n style?: React.CSSProperties;\n}\n\n/**\n * 에러 상태 인터페이스\n */\ninterface FormErrors {\n email?: string;\n password?: string;\n}\n\n/**\n * LoginForm 집합 컴포넌트\n *\n * 관리자 로그인 폼의 UI 및 상태 관리를 담당합니다.\n * - 이메일/비밀번호 입력 폼\n * - 유효성 검증\n * - 로딩 상태 표시\n * - 다크 모드 지원\n * - Laravel Sanctum API 통신\n */\nexport const LoginForm: React.FC = ({\n emailLabel = 'Email',\n passwordLabel = 'Password',\n submitButtonText = 'Sign In',\n processingText = 'Processing...',\n emailPlaceholder = 'Email',\n passwordPlaceholder = 'Password',\n forgotPasswordText = 'Forgot your password?',\n forgotPasswordUrl = '/admin/password/reset',\n emailRequiredError = 'Please enter your email.',\n emailInvalidError = 'Please enter a valid email address.',\n passwordRequiredError = 'Please enter your password.',\n onSubmit,\n loadingActions = {},\n isLoading: isLoadingProp = false,\n disabled: disabledProp = false,\n apiError: apiErrorProp = '',\n className = '',\n style,\n}) => {\n // 폼 상태 관리\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [errors, setErrors] = useState({});\n\n // ActionDispatcher에서 전달된 apiError를 사용 (prop이 우선)\n const apiError = apiErrorProp;\n\n // loadingActions로부터 isLoading 계산\n // 외부 isLoading prop 또는 loadingActions 중 하나라도 true이면 로딩 중\n const isLoadingFromActions = Object.values(loadingActions).some((loading) => loading === true);\n const isLoading = isLoadingProp || isLoadingFromActions;\n const isDisabled = disabledProp || isLoading;\n\n /**\n * 유효성 검증\n */\n const validate = (): boolean => {\n const newErrors: FormErrors = {};\n\n // 이메일 검증 (RFC 5322 기반 더 엄격한 정규식)\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n if (!email || email.trim() === '') {\n newErrors.email = emailRequiredError;\n } else if (!emailRegex.test(email)) {\n newErrors.email = emailInvalidError;\n }\n\n // 비밀번호 검증\n if (!password || password.trim() === '') {\n newErrors.password = passwordRequiredError;\n }\n\n setErrors(newErrors);\n return Object.keys(newErrors).length === 0;\n };\n\n /**\n * 폼 제출 핸들러 래퍼\n *\n * ActionDispatcher의 핸들러를 실행하기 전에 클라이언트 검증을 수행합니다.\n */\n const handleSubmit = async (e: FormEvent): Promise => {\n // 유효성 검증\n if (!validate()) {\n e.preventDefault();\n return;\n }\n\n // 에러 초기화 (클라이언트 검증 에러만)\n setErrors({});\n\n // ActionDispatcher의 핸들러 실행 (onSubmit prop으로 전달됨)\n if (onSubmit) {\n // onSubmit은 ActionDispatcher.createHandler가 생성한 핸들러\n // 내부적으로 event.preventDefault(), FormData 추출, API 호출을 처리함\n // onError 핸들러에서 setError를 사용하면 apiError prop이 업데이트됨\n await (onSubmit as any)(e);\n } else {\n // onSubmit이 없으면 기본 동작 방지\n e.preventDefault();\n }\n };\n\n return (\n
\n
\n {/* API 에러 메시지 */}\n {apiError && (\n
\n

{apiError}

\n
\n )}\n\n {/* 이메일 입력 필드 */}\n
\n \n {emailLabel}\n \n setEmail(e.target.value)}\n placeholder={emailPlaceholder}\n disabled={isDisabled}\n className={`\n w-full px-4 py-3 rounded-lg border\n ${\n errors.email\n ? 'border-red-500 focus:border-red-500 focus:ring-red-500'\n : 'border-gray-300 dark:border-gray-600 focus:border-blue-500 focus:ring-blue-500'\n }\n bg-white dark:bg-gray-800\n text-gray-900 dark:text-white\n placeholder-gray-400 dark:placeholder-gray-500\n focus:outline-none focus:ring-2\n disabled:opacity-50 disabled:cursor-not-allowed\n transition-colors duration-200\n `}\n />\n {errors.email && (\n

\n {errors.email}\n

\n )}\n
\n\n {/* 비밀번호 입력 필드 */}\n
\n \n {passwordLabel}\n \n setPassword(e.target.value)}\n placeholder={passwordPlaceholder}\n disabled={isDisabled}\n className={`\n w-full px-4 py-3 rounded-lg border\n ${\n errors.password\n ? 'border-red-500 focus:border-red-500 focus:ring-red-500'\n : 'border-gray-300 dark:border-gray-600 focus:border-blue-500 focus:ring-blue-500'\n }\n bg-white dark:bg-gray-800\n text-gray-900 dark:text-white\n placeholder-gray-400 dark:placeholder-gray-500\n focus:outline-none focus:ring-2\n disabled:opacity-50 disabled:cursor-not-allowed\n transition-colors duration-200\n `}\n />\n {errors.password && (\n

\n {errors.password}\n

\n )}\n
\n\n {/* 비밀번호 찾기 링크 */}\n {forgotPasswordUrl && (\n
\n \n {forgotPasswordText}\n \n
\n )}\n\n {/* 제출 버튼 */}\n \n {isLoading ? (\n <>\n \n \n \n \n {processingText}\n \n ) : (\n submitButtonText\n )}\n \n \n
\n );\n};\n","import React, { useState, useEffect, useRef } from 'react';\nimport { Div } from '../basic/Div';\nimport { Button } from '../basic/Button';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Span } from '../basic/Span';\nimport type { EditorAttrs } from '../../types';\n\n/**\n * 테마 모드 타입\n */\nexport type ThemeMode = 'auto' | 'light' | 'dark';\n\n/**\n * ThemeToggle Props\n */\nexport interface ThemeToggleProps {\n /** 테마 변경 콜백 */\n onThemeChange?: (theme: ThemeMode) => void;\n /** 추가 CSS 클래스 */\n className?: string;\n /** 자동 모드 텍스트 (다국어 키 사용 권장) */\n autoText?: string;\n /** 라이트 모드 텍스트 (다국어 키 사용 권장) */\n lightText?: string;\n /** 다크 모드 텍스트 (다국어 키 사용 권장) */\n darkText?: string;\n /**\n * DOM id 속성 (레이아웃 편집기 코어 일괄 ID)\n */\n id?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * ThemeToggle 컴포넌트\n *\n * 다크/라이트 모드 전환 버튼\n * - 3가지 모드: 자동(시스템 설정 따름), 라이트, 다크\n * - localStorage에 'g7_color_scheme' 키로 저장\n * - 시스템 prefers-color-scheme 감지 지원\n *\n * @example\n * ```json\n * {\n * \"type\": \"composite\",\n * \"name\": \"ThemeToggle\",\n * \"props\": {\n * \"autoText\": \"$t:admin.theme.auto\",\n * \"lightText\": \"$t:admin.theme.light\",\n * \"darkText\": \"$t:admin.theme.dark\"\n * }\n * }\n * ```\n */\n/**\n * 실제 적용될 테마 결정 (auto인 경우 시스템 설정 반영)\n */\nconst getEffectiveTheme = (mode: ThemeMode): 'light' | 'dark' => {\n if (mode === 'auto') {\n return window.matchMedia('(prefers-color-scheme: dark)').matches\n ? 'dark'\n : 'light';\n }\n return mode;\n};\n\n/**\n * 테마 적용\n */\nconst applyTheme = (mode: ThemeMode) => {\n const effectiveTheme = getEffectiveTheme(mode);\n document.documentElement.setAttribute('data-theme', effectiveTheme);\n\n // dark 클래스 토글 (Tailwind CSS dark: 모드 지원)\n if (effectiveTheme === 'dark') {\n document.documentElement.classList.add('dark');\n } else {\n document.documentElement.classList.remove('dark');\n }\n};\n\n/**\n * 초기 테마 로드 및 적용 (즉시 실행)\n */\nconst getInitialTheme = (): ThemeMode => {\n const savedTheme = localStorage.getItem('g7_color_scheme') as ThemeMode | null;\n if (savedTheme && ['auto', 'light', 'dark'].includes(savedTheme)) {\n return savedTheme;\n }\n return 'auto';\n};\n\nexport const ThemeToggle: React.FC = ({\n onThemeChange,\n className = '',\n autoText = 'System',\n lightText = 'Light',\n darkText = 'Dark',\n id,\n editorAttrs,\n}) => {\n // 초기 테마를 즉시 로드하고 적용\n const initialTheme = getInitialTheme();\n const [currentMode, setCurrentMode] = useState(initialTheme);\n const [showMenu, setShowMenu] = useState(false);\n const menuRef = useRef(null);\n\n /**\n * 컴포넌트 마운트 시 즉시 테마 적용\n */\n useEffect(() => {\n applyTheme(currentMode);\n }, []);\n\n /**\n * 시스템 테마 변경 감지\n */\n useEffect(() => {\n if (currentMode !== 'auto') return;\n\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n const handleChange = () => {\n applyTheme('auto');\n };\n\n mediaQuery.addEventListener('change', handleChange);\n return () => mediaQuery.removeEventListener('change', handleChange);\n }, [currentMode]);\n\n /**\n * 외부 클릭 감지\n */\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n setShowMenu(false);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, []);\n\n /**\n * 테마 변경 핸들러\n */\n const handleThemeChange = (mode: ThemeMode) => {\n setCurrentMode(mode);\n localStorage.setItem('g7_color_scheme', mode);\n applyTheme(mode);\n setShowMenu(false);\n onThemeChange?.(mode);\n };\n\n /**\n * 현재 표시할 아이콘 결정\n */\n const getCurrentIcon = (): IconName => {\n const effectiveTheme = getEffectiveTheme(currentMode);\n return effectiveTheme === 'dark' ? IconName.Moon : IconName.Sun;\n };\n\n return (\n
\n {/* 테마 토글 버튼 */}\n setShowMenu(!showMenu)}\n className=\"w-9 h-9 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors\"\n aria-label=\"Toggle theme\"\n >\n \n \n\n {/* 테마 선택 드롭다운 */}\n {showMenu && (\n
\n
\n {/* 자동 모드 */}\n handleThemeChange('auto')}\n className={`\n w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors\n ${currentMode === 'auto' ? 'bg-gray-50 dark:bg-gray-700' : ''}\n `}\n >\n \n {autoText}\n {currentMode === 'auto' && (\n \n )}\n \n\n {/* 라이트 모드 */}\n handleThemeChange('light')}\n className={`\n w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors\n ${currentMode === 'light' ? 'bg-gray-50 dark:bg-gray-700' : ''}\n `}\n >\n \n {lightText}\n {currentMode === 'light' && (\n \n )}\n \n\n {/* 다크 모드 */}\n handleThemeChange('dark')}\n className={`\n w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors\n ${currentMode === 'dark' ? 'bg-gray-50 dark:bg-gray-700' : ''}\n `}\n >\n \n {darkText}\n {currentMode === 'dark' && (\n \n )}\n \n
\n
\n )}\n
\n );\n};\n","import React, { useState, useRef, useEffect, useMemo } from 'react';\nimport { Button } from '../basic/Button';\nimport { Div } from '../basic/Div';\nimport { Span } from '../basic/Span';\nimport { Icon } from '../basic/Icon';\nimport { IconName } from '../basic/IconTypes';\nimport { Checkbox } from '../basic/Checkbox';\nimport type { EditorAttrs } from '../../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:ColumnSelector')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:ColumnSelector]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:ColumnSelector]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:ColumnSelector]', ...args),\n};\n\n// G7Core.t() 번역 함수 참조\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n/**\n * 전역 window 타입 확장\n * 그누보드7 Core의 AuthManager에 접근하기 위한 타입 선언\n */\ndeclare global {\n interface Window {\n G7Core?: {\n AuthManager?: {\n getInstance: () => {\n getUser: () => { uuid: string; [key: string]: any } | null;\n };\n };\n };\n }\n}\n\nexport interface ColumnItem {\n field: string;\n header: string;\n required?: boolean;\n visible?: boolean;\n}\n\n// G7Core 전역 객체의 스타일 헬퍼 접근\nconst G7Core = () => (window as any).G7Core;\n\nexport interface ColumnSelectorProps {\n /** 컴포넌트 고유 ID (레이아웃 JSON의 id 값, localStorage 키 생성에 사용) */\n id?: string;\n columns: ColumnItem[];\n visibleColumns?: string[];\n onColumnVisibilityChange?: (visibleFields: string[]) => void;\n triggerLabel?: string;\n triggerIconName?: IconName;\n position?: 'left' | 'right';\n className?: string;\n style?: React.CSSProperties;\n requiredText?: string;\n /** 드롭다운 메뉴 상단 제목 */\n menuTitle?: string;\n /** 드롭다운 메뉴 상단 설명 */\n menuDescription?: string;\n /** 기본 표시 컬럼 (localStorage에 값이 없을 때 사용) */\n defaultColumns?: string[];\n /** 트리거 버튼에 적용할 추가 className (기존 스타일과 병합됨) */\n triggerClassName?: string;\n /** 라벨 Span에 적용할 추가 className (기존 스타일과 병합됨) */\n labelClassName?: string;\n /**\n * 동적 컬럼 배열 (v1.4.0+)\n *\n * 기존 columns 배열에 동적으로 병합될 컬럼들입니다.\n * dynamicColumnsInsertAfter가 지정된 경우 해당 필드 뒤에 삽입되고,\n * 그렇지 않으면 columns 배열 끝에 추가됩니다.\n */\n dynamicColumns?: ColumnItem[];\n /**\n * 동적 컬럼 삽입 위치를 지정하는 필드명\n * 해당 필드 뒤에 dynamicColumns가 삽입됩니다.\n * 지정하지 않으면 columns 끝에 추가됩니다.\n */\n dynamicColumnsInsertAfter?: string;\n /** 레이아웃 편집기 주입 속성 (편집 모드 전용, 루트에 spread) */\n editorAttrs?: EditorAttrs;\n}\n\n/**\n * ColumnSelector 집합 컴포넌트\n *\n * 테이블 컬럼의 표시/숨김을 선택할 수 있는 드롭다운 컴포넌트입니다.\n * required 속성이 있는 컬럼은 항상 표시되며 비활성화됩니다.\n *\n * 기본 컴포넌트 조합: Button + Div + Span + Icon + Checkbox\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"ColumnSelector\",\n * \"props\": {\n * \"columns\": [\n * {\"field\": \"id\", \"header\": \"$t:admin.users.columns.id\", \"required\": true},\n * {\"field\": \"name\", \"header\": \"$t:admin.users.columns.name\", \"required\": true},\n * {\"field\": \"email\", \"header\": \"$t:admin.users.columns.email\"},\n * {\"field\": \"status\", \"header\": \"$t:admin.users.columns.status\"}\n * ],\n * \"visibleColumns\": \"{{_local.visibleColumns}}\",\n * \"triggerLabel\": \"$t:common.column_selector\",\n * \"requiredText\": \"$t:common.required\"\n * },\n * \"actions\": [\n * {\n * \"event\": \"onColumnVisibilityChange\",\n * \"type\": \"change\",\n * \"handler\": \"setState\",\n * \"params\": {\n * \"target\": \"local\",\n * \"visibleColumns\": \"{{$args[0]}}\"\n * }\n * }\n * ]\n * }\n */\nexport const ColumnSelector: React.FC = ({\n id,\n columns,\n visibleColumns: externalVisibleColumns,\n onColumnVisibilityChange,\n triggerLabel,\n triggerIconName = IconName.Sliders,\n position = 'right',\n className = '',\n style,\n requiredText,\n menuTitle,\n menuDescription,\n defaultColumns,\n triggerClassName,\n labelClassName,\n dynamicColumns,\n dynamicColumnsInsertAfter,\n editorAttrs,\n}) => {\n // props로 전달된 값이 없으면 다국어 키 사용\n const resolvedTriggerLabel = triggerLabel ?? t('common.column_selector');\n const resolvedRequiredText = requiredText ?? t('common.required');\n\n // 동적 컬럼 병합\n // dynamicColumns가 있으면 columns에 병합하고, dynamicColumnsInsertAfter 위치에 삽입\n const mergedColumns = useMemo(() => {\n if (!dynamicColumns || !Array.isArray(dynamicColumns) || dynamicColumns.length === 0) {\n return columns;\n }\n\n // 삽입 위치 찾기\n if (dynamicColumnsInsertAfter) {\n const insertIndex = columns.findIndex((col) => col.field === dynamicColumnsInsertAfter);\n if (insertIndex !== -1) {\n return [\n ...columns.slice(0, insertIndex + 1),\n ...dynamicColumns,\n ...columns.slice(insertIndex + 1),\n ];\n }\n }\n\n // 삽입 위치가 없으면 끝에 추가\n return [...columns, ...dynamicColumns];\n }, [columns, dynamicColumns, dynamicColumnsInsertAfter]);\n\n const [isOpen, setIsOpen] = useState(false);\n const menuRef = useRef(null);\n const dropdownMenuRef = useRef(null);\n const [isInitialized, setIsInitialized] = useState(false);\n const [adjustedPosition, setAdjustedPosition] = useState<'left' | 'right'>(position);\n\n // 그누보드7 Core의 AuthManager를 통해 현재 로그인한 사용자 ID 가져오기\n // window.G7Core.AuthManager는 런타임에 G7 코어 엔진에서 제공됨\n const userId = useMemo(() => {\n try {\n const authManager = window.G7Core?.AuthManager?.getInstance();\n if (authManager) {\n const user = authManager.getUser();\n return user?.uuid;\n }\n return undefined;\n } catch {\n return undefined;\n }\n }, []);\n\n // localStorage 키 생성 (g7_columns_{id}_{userId} 형식)\n // id가 없으면 저장 기능 비활성화, userId가 없으면 공유 설정\n const storageKey = id\n ? `g7_columns_${id}${userId !== undefined ? `_${userId}` : ''}`\n : null;\n\n // localStorage에서 저장된 컬럼 설정 불러오기\n const getStoredColumns = (): string[] | null => {\n if (!storageKey) return null;\n try {\n const stored = localStorage.getItem(storageKey);\n if (stored) {\n const parsed = JSON.parse(stored);\n if (Array.isArray(parsed) && parsed.length > 0) {\n return parsed;\n }\n }\n } catch (error) {\n logger.warn('Failed to load from localStorage:', error);\n }\n return null;\n };\n\n // localStorage에 컬럼 설정 저장\n const saveToStorage = (cols: string[]) => {\n if (!storageKey) return;\n try {\n localStorage.setItem(storageKey, JSON.stringify(cols));\n } catch (error) {\n logger.warn('Failed to save to localStorage:', error);\n }\n };\n\n // 초기 컬럼 결정 (우선순위: localStorage > externalVisibleColumns > defaultColumns > 모든 컬럼)\n const getInitialColumns = (): string[] => {\n // 1. localStorage에서 불러오기\n const stored = getStoredColumns();\n if (stored) return stored;\n\n // 2. 외부에서 전달된 값\n if (externalVisibleColumns && externalVisibleColumns.length > 0) {\n return externalVisibleColumns;\n }\n\n // 3. 기본 컬럼 설정\n if (defaultColumns && defaultColumns.length > 0) {\n return defaultColumns;\n }\n\n // 4. visible !== false인 모든 컬럼\n return mergedColumns.filter((col) => col.visible !== false).map((col) => col.field);\n };\n\n // 표시할 컬럼 관리\n const [internalVisibleColumns, setInternalVisibleColumns] = useState(getInitialColumns);\n\n // 초기 로드 시 결정된 컬럼으로 부모 상태 동기화\n useEffect(() => {\n if (!isInitialized) {\n // 초기 컬럼 값으로 부모 상태 동기화 (localStorage 값 또는 defaultColumns)\n if (onColumnVisibilityChange && internalVisibleColumns.length > 0) {\n onColumnVisibilityChange(internalVisibleColumns);\n }\n setIsInitialized(true);\n }\n }, [isInitialized, onColumnVisibilityChange, internalVisibleColumns]);\n\n // 외부 visibleColumns가 변경되면 내부 상태 동기화 (단, localStorage 값이 우선)\n useEffect(() => {\n if (isInitialized && externalVisibleColumns && externalVisibleColumns.length > 0) {\n // localStorage에 저장된 값이 없을 때만 외부 값으로 동기화\n const stored = getStoredColumns();\n if (!stored) {\n setInternalVisibleColumns(externalVisibleColumns);\n }\n }\n }, [externalVisibleColumns, isInitialized]);\n\n // 동적 컬럼이 새로 추가될 때만 visibleColumns에 자동 포함\n // localStorage에 저장된 값이 있으면 사용자의 선택을 존중하고 자동 추가하지 않음\n const prevDynamicFieldsRef = useRef([]);\n const hasStoredColumnsRef = useRef(null);\n\n useEffect(() => {\n if (!isInitialized) return; // 초기화 전에는 실행하지 않음\n\n if (dynamicColumns && dynamicColumns.length > 0) {\n const currentDynamicFields = dynamicColumns.map((col) => col.field);\n\n // 최초 실행 시 localStorage 존재 여부 확인 및 prevDynamicFields 초기화\n if (hasStoredColumnsRef.current === null) {\n const stored = getStoredColumns();\n hasStoredColumnsRef.current = stored !== null;\n\n // localStorage에 저장된 값이 있으면 현재 동적 필드를 \"이미 알려진\" 것으로 기록\n // 이렇게 하면 페이지 새로고침 시 동적 컬럼이 다시 자동 추가되지 않음\n if (stored !== null) {\n prevDynamicFieldsRef.current = currentDynamicFields;\n return; // localStorage 값 존중, 자동 추가 안 함\n }\n }\n\n const prevDynamicFields = prevDynamicFieldsRef.current;\n\n // 새로 추가된 동적 필드만 찾기 (기존에 없던 필드)\n const trulyNewFields = currentDynamicFields.filter(\n (field) => !prevDynamicFields.includes(field)\n );\n\n // 새로운 동적 필드가 있을 때만 자동 추가\n if (trulyNewFields.length > 0) {\n const currentVisible = externalVisibleColumns && externalVisibleColumns.length > 0\n ? externalVisibleColumns\n : internalVisibleColumns;\n\n // 현재 visible에 없는 새 필드만 추가\n const fieldsToAdd = trulyNewFields.filter((field) => !currentVisible.includes(field));\n\n if (fieldsToAdd.length > 0) {\n const updatedVisible = [...currentVisible, ...fieldsToAdd];\n setInternalVisibleColumns(updatedVisible);\n saveToStorage(updatedVisible);\n onColumnVisibilityChange?.(updatedVisible);\n }\n }\n\n // 현재 동적 필드 목록 저장\n prevDynamicFieldsRef.current = currentDynamicFields;\n }\n }, [dynamicColumns, isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const visibleColumns = externalVisibleColumns && externalVisibleColumns.length > 0\n ? externalVisibleColumns\n : internalVisibleColumns;\n\n // 외부 클릭 감지\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n setIsOpen(false);\n }\n };\n\n if (isOpen) {\n document.addEventListener('mousedown', handleClickOutside);\n }\n\n return () => {\n document.removeEventListener('mousedown', handleClickOutside);\n };\n }, [isOpen]);\n\n // 드롭다운 메뉴가 화면을 벗어나는지 감지하고 위치 조정\n useEffect(() => {\n if (isOpen && dropdownMenuRef.current && menuRef.current) {\n const menuRect = dropdownMenuRef.current.getBoundingClientRect();\n const triggerRect = menuRef.current.getBoundingClientRect();\n const viewportWidth = window.innerWidth;\n\n // 메뉴 너비 (w-64 = 16rem = 256px)\n const menuWidth = menuRect.width || 256;\n\n if (position === 'right') {\n // right 위치: 메뉴가 버튼의 오른쪽 끝에서 왼쪽으로 펼쳐짐\n // 메뉴의 왼쪽 끝이 화면 왼쪽을 벗어나는지 확인\n const menuLeft = triggerRect.right - menuWidth;\n if (menuLeft < 0) {\n setAdjustedPosition('left');\n } else {\n setAdjustedPosition('right');\n }\n } else {\n // left 위치: 메뉴가 버튼의 왼쪽 끝에서 오른쪽으로 펼쳐짐\n // 메뉴의 오른쪽 끝이 화면 오른쪽을 벗어나는지 확인\n const menuRight = triggerRect.left + menuWidth;\n if (menuRight > viewportWidth) {\n setAdjustedPosition('right');\n } else {\n setAdjustedPosition('left');\n }\n }\n }\n }, [isOpen, position]);\n\n const handleColumnToggle = (field: string) => {\n const column = mergedColumns.find((col) => col.field === field);\n if (column?.required) return; // required 컬럼은 토글 불가\n\n const newVisibleColumns = visibleColumns.includes(field)\n ? visibleColumns.filter((f) => f !== field)\n : [...visibleColumns, field];\n\n setInternalVisibleColumns(newVisibleColumns);\n saveToStorage(newVisibleColumns);\n onColumnVisibilityChange?.(newVisibleColumns);\n };\n\n const positionClasses = adjustedPosition === 'left' ? 'left-0' : 'right-0';\n\n return (\n \n {/* 트리거 버튼 */}\n setIsOpen(!isOpen)}\n className={G7Core()?.style?.mergeClasses?.(\n 'flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors',\n triggerClassName\n ) ?? `flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors ${triggerClassName ?? ''}`}\n >\n \n {resolvedTriggerLabel && (\n \n {resolvedTriggerLabel}\n \n )}\n \n\n {/* 드롭다운 메뉴 */}\n {isOpen && (\n \n {/* 헤더 영역 */}\n {(menuTitle || menuDescription) && (\n
\n {menuTitle && (\n \n {menuTitle}\n \n )}\n {menuDescription && (\n \n {menuDescription}\n \n )}\n
\n )}\n
\n {mergedColumns.map((col) => {\n const isVisible = visibleColumns.includes(col.field);\n const isRequired = col.required === true;\n\n return (\n !isRequired && handleColumnToggle(col.field)}\n className={`flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 ${\n isRequired ? 'cursor-default' : 'cursor-pointer'\n }`}\n >\n \n \n {col.header}\n \n {isRequired && resolvedRequiredText && (\n \n {resolvedRequiredText}\n \n )}\n
\n );\n })}\n \n \n )}\n \n );\n};","import React, { useState, useEffect } from 'react';\nimport { Div } from '../basic/Div';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('Comp:PageTransition')) ?? {\n log: (...args: unknown[]) => console.log('[Comp:PageTransition]', ...args),\n warn: (...args: unknown[]) => console.warn('[Comp:PageTransition]', ...args),\n error: (...args: unknown[]) => console.error('[Comp:PageTransition]', ...args),\n};\n\nexport interface PageTransitionIndicatorProps {\n className?: string;\n style?: React.CSSProperties;\n}\n\n/**\n * PageTransitionIndicator 컴포넌트\n *\n * TransitionManager의 isPending 상태를 구독하여 페이지 전환 시\n * 상단에 로딩 바를 표시합니다.\n *\n * @example\n * // 레이아웃 JSON 사용 예시\n * {\n * \"name\": \"PageTransitionIndicator\"\n * }\n */\nexport const PageTransitionIndicator: React.FC = ({\n className = '',\n style,\n}) => {\n const [isPending, setIsPending] = useState(false);\n\n useEffect(() => {\n // G7Core.TransitionManager 접근\n const transitionManager = (window as any).G7Core?.TransitionManager;\n\n if (!transitionManager) {\n logger.warn('TransitionManager를 찾을 수 없습니다.');\n return;\n }\n\n // 구독 및 초기 상태 설정\n setIsPending(transitionManager.getIsPending());\n\n const unsubscribe = transitionManager.subscribe((newIsPending: boolean) => {\n setIsPending(newIsPending);\n });\n\n // 정리 함수\n return unsubscribe;\n }, []);\n\n if (!isPending) {\n return null;\n }\n\n return (\n \n
\n \n
\n\n \n \n );\n};\n","import React from 'react';\n\n/**\n * 페이지 로딩 컴포넌트 Props (엔진에서 전달)\n *\n * @since engine-v1.29.0\n */\nexport interface PageLoadingProps {\n options?: {\n text?: string;\n };\n}\n\nconst t = (key: string, params?: Record) =>\n (window as any).G7Core?.t?.(key, params) ?? key;\n\n/**\n * 어드민 페이지 로딩 인디케이터 컴포넌트\n *\n * `transition_overlay` 의 spinner 스타일에서 사용된다.\n * 엔진은 타겟 요소 내부에 빈 컨테이너만 삽입하며, 포지셔닝/배경/z-index/다크모드\n * 등 모든 비주얼 스타일은 이 컴포넌트가 결정한다. React 트리 외부에 렌더링되므로\n * 인라인 스타일을 사용한다.\n *\n * @since engine-v1.29.0\n */\nconst PageLoading: React.FC = ({ options }) => {\n const isDark = document.documentElement.classList.contains('dark');\n\n const bgColor = isDark ? 'rgb(17,24,39)' : 'rgb(249,250,251)';\n const spinnerColor = isDark ? '#6b7280' : '#9ca3af';\n const textColor = isDark ? '#9ca3af' : '#6b7280';\n\n return (\n \n \n \n {options?.text || t('nav.loading')}\n \n \n );\n};\n\nexport default PageLoading;\n","function _typeof(o) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (o) {\n return typeof o;\n } : function (o) {\n return o && \"function\" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? \"symbol\" : typeof o;\n }, _typeof(o);\n}\nexport { _typeof as default };","import _typeof from \"./typeof.js\";\nfunction toPrimitive(t, r) {\n if (\"object\" != _typeof(t) || !t) return t;\n var e = t[Symbol.toPrimitive];\n if (void 0 !== e) {\n var i = e.call(t, r || \"default\");\n if (\"object\" != _typeof(i)) return i;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (\"string\" === r ? String : Number)(t);\n}\nexport { toPrimitive as default };","import _typeof from \"./typeof.js\";\nimport toPrimitive from \"./toPrimitive.js\";\nfunction toPropertyKey(t) {\n var i = toPrimitive(t, \"string\");\n return \"symbol\" == _typeof(i) ? i : i + \"\";\n}\nexport { toPropertyKey as default };","import toPropertyKey from \"./toPropertyKey.js\";\nfunction _defineProperty(e, r, t) {\n return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {\n value: t,\n enumerable: !0,\n configurable: !0,\n writable: !0\n }) : e[r] = t, e;\n}\nexport { _defineProperty as default };","import defineProperty from \"./defineProperty.js\";\nfunction ownKeys(e, r) {\n var t = Object.keys(e);\n if (Object.getOwnPropertySymbols) {\n var o = Object.getOwnPropertySymbols(e);\n r && (o = o.filter(function (r) {\n return Object.getOwnPropertyDescriptor(e, r).enumerable;\n })), t.push.apply(t, o);\n }\n return t;\n}\nfunction _objectSpread2(e) {\n for (var r = 1; r < arguments.length; r++) {\n var t = null != arguments[r] ? arguments[r] : {};\n r % 2 ? ownKeys(Object(t), !0).forEach(function (r) {\n defineProperty(e, r, t[r]);\n }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {\n Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));\n });\n }\n return e;\n}\nexport { _objectSpread2 as default };","function _arrayWithHoles(r) {\n if (Array.isArray(r)) return r;\n}\nexport { _arrayWithHoles as default };","function _iterableToArrayLimit(r, l) {\n var t = null == r ? null : \"undefined\" != typeof Symbol && r[Symbol.iterator] || r[\"@@iterator\"];\n if (null != t) {\n var e,\n n,\n i,\n u,\n a = [],\n f = !0,\n o = !1;\n try {\n if (i = (t = t.call(r)).next, 0 === l) {\n if (Object(t) !== t) return;\n f = !1;\n } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0);\n } catch (r) {\n o = !0, n = r;\n } finally {\n try {\n if (!f && null != t[\"return\"] && (u = t[\"return\"](), Object(u) !== u)) return;\n } finally {\n if (o) throw n;\n }\n }\n return a;\n }\n}\nexport { _iterableToArrayLimit as default };","function _arrayLikeToArray(r, a) {\n (null == a || a > r.length) && (a = r.length);\n for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];\n return n;\n}\nexport { _arrayLikeToArray as default };","import arrayLikeToArray from \"./arrayLikeToArray.js\";\nfunction _unsupportedIterableToArray(r, a) {\n if (r) {\n if (\"string\" == typeof r) return arrayLikeToArray(r, a);\n var t = {}.toString.call(r).slice(8, -1);\n return \"Object\" === t && r.constructor && (t = r.constructor.name), \"Map\" === t || \"Set\" === t ? Array.from(r) : \"Arguments\" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? arrayLikeToArray(r, a) : void 0;\n }\n}\nexport { _unsupportedIterableToArray as default };","function _nonIterableRest() {\n throw new TypeError(\"Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\");\n}\nexport { _nonIterableRest as default };","import arrayWithHoles from \"./arrayWithHoles.js\";\nimport iterableToArrayLimit from \"./iterableToArrayLimit.js\";\nimport unsupportedIterableToArray from \"./unsupportedIterableToArray.js\";\nimport nonIterableRest from \"./nonIterableRest.js\";\nfunction _slicedToArray(r, e) {\n return arrayWithHoles(r) || iterableToArrayLimit(r, e) || unsupportedIterableToArray(r, e) || nonIterableRest();\n}\nexport { _slicedToArray as default };","function _objectWithoutPropertiesLoose(r, e) {\n if (null == r) return {};\n var t = {};\n for (var n in r) if ({}.hasOwnProperty.call(r, n)) {\n if (-1 !== e.indexOf(n)) continue;\n t[n] = r[n];\n }\n return t;\n}\nexport { _objectWithoutPropertiesLoose as default };","import objectWithoutPropertiesLoose from \"./objectWithoutPropertiesLoose.js\";\nfunction _objectWithoutProperties(e, t) {\n if (null == e) return {};\n var o,\n r,\n i = objectWithoutPropertiesLoose(e, t);\n if (Object.getOwnPropertySymbols) {\n var n = Object.getOwnPropertySymbols(e);\n for (r = 0; r < n.length; r++) o = n[r], -1 === t.indexOf(o) && {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]);\n }\n return i;\n}\nexport { _objectWithoutProperties as default };","import _objectSpread from '@babel/runtime/helpers/esm/objectSpread2';\nimport _slicedToArray from '@babel/runtime/helpers/esm/slicedToArray';\nimport _objectWithoutProperties from '@babel/runtime/helpers/esm/objectWithoutProperties';\nimport { useState, useCallback } from 'react';\n\nvar _excluded = [\"defaultInputValue\", \"defaultMenuIsOpen\", \"defaultValue\", \"inputValue\", \"menuIsOpen\", \"onChange\", \"onInputChange\", \"onMenuClose\", \"onMenuOpen\", \"value\"];\nfunction useStateManager(_ref) {\n var _ref$defaultInputValu = _ref.defaultInputValue,\n defaultInputValue = _ref$defaultInputValu === void 0 ? '' : _ref$defaultInputValu,\n _ref$defaultMenuIsOpe = _ref.defaultMenuIsOpen,\n defaultMenuIsOpen = _ref$defaultMenuIsOpe === void 0 ? false : _ref$defaultMenuIsOpe,\n _ref$defaultValue = _ref.defaultValue,\n defaultValue = _ref$defaultValue === void 0 ? null : _ref$defaultValue,\n propsInputValue = _ref.inputValue,\n propsMenuIsOpen = _ref.menuIsOpen,\n propsOnChange = _ref.onChange,\n propsOnInputChange = _ref.onInputChange,\n propsOnMenuClose = _ref.onMenuClose,\n propsOnMenuOpen = _ref.onMenuOpen,\n propsValue = _ref.value,\n restSelectProps = _objectWithoutProperties(_ref, _excluded);\n var _useState = useState(propsInputValue !== undefined ? propsInputValue : defaultInputValue),\n _useState2 = _slicedToArray(_useState, 2),\n stateInputValue = _useState2[0],\n setStateInputValue = _useState2[1];\n var _useState3 = useState(propsMenuIsOpen !== undefined ? propsMenuIsOpen : defaultMenuIsOpen),\n _useState4 = _slicedToArray(_useState3, 2),\n stateMenuIsOpen = _useState4[0],\n setStateMenuIsOpen = _useState4[1];\n var _useState5 = useState(propsValue !== undefined ? propsValue : defaultValue),\n _useState6 = _slicedToArray(_useState5, 2),\n stateValue = _useState6[0],\n setStateValue = _useState6[1];\n var onChange = useCallback(function (value, actionMeta) {\n if (typeof propsOnChange === 'function') {\n propsOnChange(value, actionMeta);\n }\n setStateValue(value);\n }, [propsOnChange]);\n var onInputChange = useCallback(function (value, actionMeta) {\n var newValue;\n if (typeof propsOnInputChange === 'function') {\n newValue = propsOnInputChange(value, actionMeta);\n }\n setStateInputValue(newValue !== undefined ? newValue : value);\n }, [propsOnInputChange]);\n var onMenuOpen = useCallback(function () {\n if (typeof propsOnMenuOpen === 'function') {\n propsOnMenuOpen();\n }\n setStateMenuIsOpen(true);\n }, [propsOnMenuOpen]);\n var onMenuClose = useCallback(function () {\n if (typeof propsOnMenuClose === 'function') {\n propsOnMenuClose();\n }\n setStateMenuIsOpen(false);\n }, [propsOnMenuClose]);\n var inputValue = propsInputValue !== undefined ? propsInputValue : stateInputValue;\n var menuIsOpen = propsMenuIsOpen !== undefined ? propsMenuIsOpen : stateMenuIsOpen;\n var value = propsValue !== undefined ? propsValue : stateValue;\n return _objectSpread(_objectSpread({}, restSelectProps), {}, {\n inputValue: inputValue,\n menuIsOpen: menuIsOpen,\n onChange: onChange,\n onInputChange: onInputChange,\n onMenuClose: onMenuClose,\n onMenuOpen: onMenuOpen,\n value: value\n });\n}\n\nexport { useStateManager as u };\n","function _extends() {\n return _extends = Object.assign ? Object.assign.bind() : function (n) {\n for (var e = 1; e < arguments.length; e++) {\n var t = arguments[e];\n for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);\n }\n return n;\n }, _extends.apply(null, arguments);\n}\nexport { _extends as default };","function _classCallCheck(a, n) {\n if (!(a instanceof n)) throw new TypeError(\"Cannot call a class as a function\");\n}\nexport { _classCallCheck as default };","import toPropertyKey from \"./toPropertyKey.js\";\nfunction _defineProperties(e, r) {\n for (var t = 0; t < r.length; t++) {\n var o = r[t];\n o.enumerable = o.enumerable || !1, o.configurable = !0, \"value\" in o && (o.writable = !0), Object.defineProperty(e, toPropertyKey(o.key), o);\n }\n}\nfunction _createClass(e, r, t) {\n return r && _defineProperties(e.prototype, r), t && _defineProperties(e, t), Object.defineProperty(e, \"prototype\", {\n writable: !1\n }), e;\n}\nexport { _createClass as default };","function _setPrototypeOf(t, e) {\n return _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function (t, e) {\n return t.__proto__ = e, t;\n }, _setPrototypeOf(t, e);\n}\nexport { _setPrototypeOf as default };","import setPrototypeOf from \"./setPrototypeOf.js\";\nfunction _inherits(t, e) {\n if (\"function\" != typeof e && null !== e) throw new TypeError(\"Super expression must either be null or a function\");\n t.prototype = Object.create(e && e.prototype, {\n constructor: {\n value: t,\n writable: !0,\n configurable: !0\n }\n }), Object.defineProperty(t, \"prototype\", {\n writable: !1\n }), e && setPrototypeOf(t, e);\n}\nexport { _inherits as default };","function _getPrototypeOf(t) {\n return _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function (t) {\n return t.__proto__ || Object.getPrototypeOf(t);\n }, _getPrototypeOf(t);\n}\nexport { _getPrototypeOf as default };","function _isNativeReflectConstruct() {\n try {\n var t = !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {}));\n } catch (t) {}\n return (_isNativeReflectConstruct = function _isNativeReflectConstruct() {\n return !!t;\n })();\n}\nexport { _isNativeReflectConstruct as default };","function _assertThisInitialized(e) {\n if (void 0 === e) throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\");\n return e;\n}\nexport { _assertThisInitialized as default };","import _typeof from \"./typeof.js\";\nimport assertThisInitialized from \"./assertThisInitialized.js\";\nfunction _possibleConstructorReturn(t, e) {\n if (e && (\"object\" == _typeof(e) || \"function\" == typeof e)) return e;\n if (void 0 !== e) throw new TypeError(\"Derived constructors may only return object or undefined\");\n return assertThisInitialized(t);\n}\nexport { _possibleConstructorReturn as default };","import getPrototypeOf from \"./getPrototypeOf.js\";\nimport isNativeReflectConstruct from \"./isNativeReflectConstruct.js\";\nimport possibleConstructorReturn from \"./possibleConstructorReturn.js\";\nfunction _createSuper(t) {\n var r = isNativeReflectConstruct();\n return function () {\n var e,\n o = getPrototypeOf(t);\n if (r) {\n var s = getPrototypeOf(this).constructor;\n e = Reflect.construct(o, arguments, s);\n } else e = o.apply(this, arguments);\n return possibleConstructorReturn(this, e);\n };\n}\nexport { _createSuper as default };","import arrayLikeToArray from \"./arrayLikeToArray.js\";\nfunction _arrayWithoutHoles(r) {\n if (Array.isArray(r)) return arrayLikeToArray(r);\n}\nexport { _arrayWithoutHoles as default };","function _iterableToArray(r) {\n if (\"undefined\" != typeof Symbol && null != r[Symbol.iterator] || null != r[\"@@iterator\"]) return Array.from(r);\n}\nexport { _iterableToArray as default };","function _nonIterableSpread() {\n throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\");\n}\nexport { _nonIterableSpread as default };","import arrayWithoutHoles from \"./arrayWithoutHoles.js\";\nimport iterableToArray from \"./iterableToArray.js\";\nimport unsupportedIterableToArray from \"./unsupportedIterableToArray.js\";\nimport nonIterableSpread from \"./nonIterableSpread.js\";\nfunction _toConsumableArray(r) {\n return arrayWithoutHoles(r) || iterableToArray(r) || unsupportedIterableToArray(r) || nonIterableSpread();\n}\nexport { _toConsumableArray as default };","var isDevelopment = false;\n\n/*\n\nBased off glamor's StyleSheet, thanks Sunil ❤️\n\nhigh performance StyleSheet for css-in-js systems\n\n- uses multiple style tags behind the scenes for millions of rules\n- uses `insertRule` for appending in production for *much* faster performance\n\n// usage\n\nimport { StyleSheet } from '@emotion/sheet'\n\nlet styleSheet = new StyleSheet({ key: '', container: document.head })\n\nstyleSheet.insert('#box { border: 1px solid red; }')\n- appends a css rule into the stylesheet\n\nstyleSheet.flush()\n- empties the stylesheet of all its contents\n\n*/\n\nfunction sheetForTag(tag) {\n if (tag.sheet) {\n return tag.sheet;\n } // this weirdness brought to you by firefox\n\n /* istanbul ignore next */\n\n\n for (var i = 0; i < document.styleSheets.length; i++) {\n if (document.styleSheets[i].ownerNode === tag) {\n return document.styleSheets[i];\n }\n } // this function should always return with a value\n // TS can't understand it though so we make it stop complaining here\n\n\n return undefined;\n}\n\nfunction createStyleElement(options) {\n var tag = document.createElement('style');\n tag.setAttribute('data-emotion', options.key);\n\n if (options.nonce !== undefined) {\n tag.setAttribute('nonce', options.nonce);\n }\n\n tag.appendChild(document.createTextNode(''));\n tag.setAttribute('data-s', '');\n return tag;\n}\n\nvar StyleSheet = /*#__PURE__*/function () {\n // Using Node instead of HTMLElement since container may be a ShadowRoot\n function StyleSheet(options) {\n var _this = this;\n\n this._insertTag = function (tag) {\n var before;\n\n if (_this.tags.length === 0) {\n if (_this.insertionPoint) {\n before = _this.insertionPoint.nextSibling;\n } else if (_this.prepend) {\n before = _this.container.firstChild;\n } else {\n before = _this.before;\n }\n } else {\n before = _this.tags[_this.tags.length - 1].nextSibling;\n }\n\n _this.container.insertBefore(tag, before);\n\n _this.tags.push(tag);\n };\n\n this.isSpeedy = options.speedy === undefined ? !isDevelopment : options.speedy;\n this.tags = [];\n this.ctr = 0;\n this.nonce = options.nonce; // key is the value of the data-emotion attribute, it's used to identify different sheets\n\n this.key = options.key;\n this.container = options.container;\n this.prepend = options.prepend;\n this.insertionPoint = options.insertionPoint;\n this.before = null;\n }\n\n var _proto = StyleSheet.prototype;\n\n _proto.hydrate = function hydrate(nodes) {\n nodes.forEach(this._insertTag);\n };\n\n _proto.insert = function insert(rule) {\n // the max length is how many rules we have per style tag, it's 65000 in speedy mode\n // it's 1 in dev because we insert source maps that map a single rule to a location\n // and you can only have one source map per style tag\n if (this.ctr % (this.isSpeedy ? 65000 : 1) === 0) {\n this._insertTag(createStyleElement(this));\n }\n\n var tag = this.tags[this.tags.length - 1];\n\n if (this.isSpeedy) {\n var sheet = sheetForTag(tag);\n\n try {\n // this is the ultrafast version, works across browsers\n // the big drawback is that the css won't be editable in devtools\n sheet.insertRule(rule, sheet.cssRules.length);\n } catch (e) {\n }\n } else {\n tag.appendChild(document.createTextNode(rule));\n }\n\n this.ctr++;\n };\n\n _proto.flush = function flush() {\n this.tags.forEach(function (tag) {\n var _tag$parentNode;\n\n return (_tag$parentNode = tag.parentNode) == null ? void 0 : _tag$parentNode.removeChild(tag);\n });\n this.tags = [];\n this.ctr = 0;\n };\n\n return StyleSheet;\n}();\n\nexport { StyleSheet };\n","export var MS = '-ms-'\nexport var MOZ = '-moz-'\nexport var WEBKIT = '-webkit-'\n\nexport var COMMENT = 'comm'\nexport var RULESET = 'rule'\nexport var DECLARATION = 'decl'\n\nexport var PAGE = '@page'\nexport var MEDIA = '@media'\nexport var IMPORT = '@import'\nexport var CHARSET = '@charset'\nexport var VIEWPORT = '@viewport'\nexport var SUPPORTS = '@supports'\nexport var DOCUMENT = '@document'\nexport var NAMESPACE = '@namespace'\nexport var KEYFRAMES = '@keyframes'\nexport var FONT_FACE = '@font-face'\nexport var COUNTER_STYLE = '@counter-style'\nexport var FONT_FEATURE_VALUES = '@font-feature-values'\nexport var LAYER = '@layer'\n","/**\n * @param {number}\n * @return {number}\n */\nexport var abs = Math.abs\n\n/**\n * @param {number}\n * @return {string}\n */\nexport var from = String.fromCharCode\n\n/**\n * @param {object}\n * @return {object}\n */\nexport var assign = Object.assign\n\n/**\n * @param {string} value\n * @param {number} length\n * @return {number}\n */\nexport function hash (value, length) {\n\treturn charat(value, 0) ^ 45 ? (((((((length << 2) ^ charat(value, 0)) << 2) ^ charat(value, 1)) << 2) ^ charat(value, 2)) << 2) ^ charat(value, 3) : 0\n}\n\n/**\n * @param {string} value\n * @return {string}\n */\nexport function trim (value) {\n\treturn value.trim()\n}\n\n/**\n * @param {string} value\n * @param {RegExp} pattern\n * @return {string?}\n */\nexport function match (value, pattern) {\n\treturn (value = pattern.exec(value)) ? value[0] : value\n}\n\n/**\n * @param {string} value\n * @param {(string|RegExp)} pattern\n * @param {string} replacement\n * @return {string}\n */\nexport function replace (value, pattern, replacement) {\n\treturn value.replace(pattern, replacement)\n}\n\n/**\n * @param {string} value\n * @param {string} search\n * @return {number}\n */\nexport function indexof (value, search) {\n\treturn value.indexOf(search)\n}\n\n/**\n * @param {string} value\n * @param {number} index\n * @return {number}\n */\nexport function charat (value, index) {\n\treturn value.charCodeAt(index) | 0\n}\n\n/**\n * @param {string} value\n * @param {number} begin\n * @param {number} end\n * @return {string}\n */\nexport function substr (value, begin, end) {\n\treturn value.slice(begin, end)\n}\n\n/**\n * @param {string} value\n * @return {number}\n */\nexport function strlen (value) {\n\treturn value.length\n}\n\n/**\n * @param {any[]} value\n * @return {number}\n */\nexport function sizeof (value) {\n\treturn value.length\n}\n\n/**\n * @param {any} value\n * @param {any[]} array\n * @return {any}\n */\nexport function append (value, array) {\n\treturn array.push(value), value\n}\n\n/**\n * @param {string[]} array\n * @param {function} callback\n * @return {string}\n */\nexport function combine (array, callback) {\n\treturn array.map(callback).join('')\n}\n","import {from, trim, charat, strlen, substr, append, assign} from './Utility.js'\n\nexport var line = 1\nexport var column = 1\nexport var length = 0\nexport var position = 0\nexport var character = 0\nexport var characters = ''\n\n/**\n * @param {string} value\n * @param {object | null} root\n * @param {object | null} parent\n * @param {string} type\n * @param {string[] | string} props\n * @param {object[] | string} children\n * @param {number} length\n */\nexport function node (value, root, parent, type, props, children, length) {\n\treturn {value: value, root: root, parent: parent, type: type, props: props, children: children, line: line, column: column, length: length, return: ''}\n}\n\n/**\n * @param {object} root\n * @param {object} props\n * @return {object}\n */\nexport function copy (root, props) {\n\treturn assign(node('', null, null, '', null, null, 0), root, {length: -root.length}, props)\n}\n\n/**\n * @return {number}\n */\nexport function char () {\n\treturn character\n}\n\n/**\n * @return {number}\n */\nexport function prev () {\n\tcharacter = position > 0 ? charat(characters, --position) : 0\n\n\tif (column--, character === 10)\n\t\tcolumn = 1, line--\n\n\treturn character\n}\n\n/**\n * @return {number}\n */\nexport function next () {\n\tcharacter = position < length ? charat(characters, position++) : 0\n\n\tif (column++, character === 10)\n\t\tcolumn = 1, line++\n\n\treturn character\n}\n\n/**\n * @return {number}\n */\nexport function peek () {\n\treturn charat(characters, position)\n}\n\n/**\n * @return {number}\n */\nexport function caret () {\n\treturn position\n}\n\n/**\n * @param {number} begin\n * @param {number} end\n * @return {string}\n */\nexport function slice (begin, end) {\n\treturn substr(characters, begin, end)\n}\n\n/**\n * @param {number} type\n * @return {number}\n */\nexport function token (type) {\n\tswitch (type) {\n\t\t// \\0 \\t \\n \\r \\s whitespace token\n\t\tcase 0: case 9: case 10: case 13: case 32:\n\t\t\treturn 5\n\t\t// ! + , / > @ ~ isolate token\n\t\tcase 33: case 43: case 44: case 47: case 62: case 64: case 126:\n\t\t// ; { } breakpoint token\n\t\tcase 59: case 123: case 125:\n\t\t\treturn 4\n\t\t// : accompanied token\n\t\tcase 58:\n\t\t\treturn 3\n\t\t// \" ' ( [ opening delimit token\n\t\tcase 34: case 39: case 40: case 91:\n\t\t\treturn 2\n\t\t// ) ] closing delimit token\n\t\tcase 41: case 93:\n\t\t\treturn 1\n\t}\n\n\treturn 0\n}\n\n/**\n * @param {string} value\n * @return {any[]}\n */\nexport function alloc (value) {\n\treturn line = column = 1, length = strlen(characters = value), position = 0, []\n}\n\n/**\n * @param {any} value\n * @return {any}\n */\nexport function dealloc (value) {\n\treturn characters = '', value\n}\n\n/**\n * @param {number} type\n * @return {string}\n */\nexport function delimit (type) {\n\treturn trim(slice(position - 1, delimiter(type === 91 ? type + 2 : type === 40 ? type + 1 : type)))\n}\n\n/**\n * @param {string} value\n * @return {string[]}\n */\nexport function tokenize (value) {\n\treturn dealloc(tokenizer(alloc(value)))\n}\n\n/**\n * @param {number} type\n * @return {string}\n */\nexport function whitespace (type) {\n\twhile (character = peek())\n\t\tif (character < 33)\n\t\t\tnext()\n\t\telse\n\t\t\tbreak\n\n\treturn token(type) > 2 || token(character) > 3 ? '' : ' '\n}\n\n/**\n * @param {string[]} children\n * @return {string[]}\n */\nexport function tokenizer (children) {\n\twhile (next())\n\t\tswitch (token(character)) {\n\t\t\tcase 0: append(identifier(position - 1), children)\n\t\t\t\tbreak\n\t\t\tcase 2: append(delimit(character), children)\n\t\t\t\tbreak\n\t\t\tdefault: append(from(character), children)\n\t\t}\n\n\treturn children\n}\n\n/**\n * @param {number} index\n * @param {number} count\n * @return {string}\n */\nexport function escaping (index, count) {\n\twhile (--count && next())\n\t\t// not 0-9 A-F a-f\n\t\tif (character < 48 || character > 102 || (character > 57 && character < 65) || (character > 70 && character < 97))\n\t\t\tbreak\n\n\treturn slice(index, caret() + (count < 6 && peek() == 32 && next() == 32))\n}\n\n/**\n * @param {number} type\n * @return {number}\n */\nexport function delimiter (type) {\n\twhile (next())\n\t\tswitch (character) {\n\t\t\t// ] ) \" '\n\t\t\tcase type:\n\t\t\t\treturn position\n\t\t\t// \" '\n\t\t\tcase 34: case 39:\n\t\t\t\tif (type !== 34 && type !== 39)\n\t\t\t\t\tdelimiter(character)\n\t\t\t\tbreak\n\t\t\t// (\n\t\t\tcase 40:\n\t\t\t\tif (type === 41)\n\t\t\t\t\tdelimiter(type)\n\t\t\t\tbreak\n\t\t\t// \\\n\t\t\tcase 92:\n\t\t\t\tnext()\n\t\t\t\tbreak\n\t\t}\n\n\treturn position\n}\n\n/**\n * @param {number} type\n * @param {number} index\n * @return {number}\n */\nexport function commenter (type, index) {\n\twhile (next())\n\t\t// //\n\t\tif (type + character === 47 + 10)\n\t\t\tbreak\n\t\t// /*\n\t\telse if (type + character === 42 + 42 && peek() === 47)\n\t\t\tbreak\n\n\treturn '/*' + slice(index, position - 1) + '*' + from(type === 47 ? type : next())\n}\n\n/**\n * @param {number} index\n * @return {string}\n */\nexport function identifier (index) {\n\twhile (!token(peek()))\n\t\tnext()\n\n\treturn slice(index, position)\n}\n","import {COMMENT, RULESET, DECLARATION} from './Enum.js'\nimport {abs, charat, trim, from, sizeof, strlen, substr, append, replace, indexof} from './Utility.js'\nimport {node, char, prev, next, peek, caret, alloc, dealloc, delimit, whitespace, escaping, identifier, commenter} from './Tokenizer.js'\n\n/**\n * @param {string} value\n * @return {object[]}\n */\nexport function compile (value) {\n\treturn dealloc(parse('', null, null, null, [''], value = alloc(value), 0, [0], value))\n}\n\n/**\n * @param {string} value\n * @param {object} root\n * @param {object?} parent\n * @param {string[]} rule\n * @param {string[]} rules\n * @param {string[]} rulesets\n * @param {number[]} pseudo\n * @param {number[]} points\n * @param {string[]} declarations\n * @return {object}\n */\nexport function parse (value, root, parent, rule, rules, rulesets, pseudo, points, declarations) {\n\tvar index = 0\n\tvar offset = 0\n\tvar length = pseudo\n\tvar atrule = 0\n\tvar property = 0\n\tvar previous = 0\n\tvar variable = 1\n\tvar scanning = 1\n\tvar ampersand = 1\n\tvar character = 0\n\tvar type = ''\n\tvar props = rules\n\tvar children = rulesets\n\tvar reference = rule\n\tvar characters = type\n\n\twhile (scanning)\n\t\tswitch (previous = character, character = next()) {\n\t\t\t// (\n\t\t\tcase 40:\n\t\t\t\tif (previous != 108 && charat(characters, length - 1) == 58) {\n\t\t\t\t\tif (indexof(characters += replace(delimit(character), '&', '&\\f'), '&\\f') != -1)\n\t\t\t\t\t\tampersand = -1\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t// \" ' [\n\t\t\tcase 34: case 39: case 91:\n\t\t\t\tcharacters += delimit(character)\n\t\t\t\tbreak\n\t\t\t// \\t \\n \\r \\s\n\t\t\tcase 9: case 10: case 13: case 32:\n\t\t\t\tcharacters += whitespace(previous)\n\t\t\t\tbreak\n\t\t\t// \\\n\t\t\tcase 92:\n\t\t\t\tcharacters += escaping(caret() - 1, 7)\n\t\t\t\tcontinue\n\t\t\t// /\n\t\t\tcase 47:\n\t\t\t\tswitch (peek()) {\n\t\t\t\t\tcase 42: case 47:\n\t\t\t\t\t\tappend(comment(commenter(next(), caret()), root, parent), declarations)\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tcharacters += '/'\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t// {\n\t\t\tcase 123 * variable:\n\t\t\t\tpoints[index++] = strlen(characters) * ampersand\n\t\t\t// } ; \\0\n\t\t\tcase 125 * variable: case 59: case 0:\n\t\t\t\tswitch (character) {\n\t\t\t\t\t// \\0 }\n\t\t\t\t\tcase 0: case 125: scanning = 0\n\t\t\t\t\t// ;\n\t\t\t\t\tcase 59 + offset: if (ampersand == -1) characters = replace(characters, /\\f/g, '')\n\t\t\t\t\t\tif (property > 0 && (strlen(characters) - length))\n\t\t\t\t\t\t\tappend(property > 32 ? declaration(characters + ';', rule, parent, length - 1) : declaration(replace(characters, ' ', '') + ';', rule, parent, length - 2), declarations)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t// @ ;\n\t\t\t\t\tcase 59: characters += ';'\n\t\t\t\t\t// { rule/at-rule\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tappend(reference = ruleset(characters, root, parent, index, offset, rules, points, type, props = [], children = [], length), rulesets)\n\n\t\t\t\t\t\tif (character === 123)\n\t\t\t\t\t\t\tif (offset === 0)\n\t\t\t\t\t\t\t\tparse(characters, root, reference, reference, props, rulesets, length, points, children)\n\t\t\t\t\t\t\telse\n\t\t\t\t\t\t\t\tswitch (atrule === 99 && charat(characters, 3) === 110 ? 100 : atrule) {\n\t\t\t\t\t\t\t\t\t// d l m s\n\t\t\t\t\t\t\t\t\tcase 100: case 108: case 109: case 115:\n\t\t\t\t\t\t\t\t\t\tparse(value, reference, reference, rule && append(ruleset(value, reference, reference, 0, 0, rules, points, type, rules, props = [], length), children), rules, children, length, points, rule ? props : children)\n\t\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\t\t\tparse(characters, reference, reference, reference, [''], children, 0, points, children)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tindex = offset = property = 0, variable = ampersand = 1, type = characters = '', length = pseudo\n\t\t\t\tbreak\n\t\t\t// :\n\t\t\tcase 58:\n\t\t\t\tlength = 1 + strlen(characters), property = previous\n\t\t\tdefault:\n\t\t\t\tif (variable < 1)\n\t\t\t\t\tif (character == 123)\n\t\t\t\t\t\t--variable\n\t\t\t\t\telse if (character == 125 && variable++ == 0 && prev() == 125)\n\t\t\t\t\t\tcontinue\n\n\t\t\t\tswitch (characters += from(character), character * variable) {\n\t\t\t\t\t// &\n\t\t\t\t\tcase 38:\n\t\t\t\t\t\tampersand = offset > 0 ? 1 : (characters += '\\f', -1)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t// ,\n\t\t\t\t\tcase 44:\n\t\t\t\t\t\tpoints[index++] = (strlen(characters) - 1) * ampersand, ampersand = 1\n\t\t\t\t\t\tbreak\n\t\t\t\t\t// @\n\t\t\t\t\tcase 64:\n\t\t\t\t\t\t// -\n\t\t\t\t\t\tif (peek() === 45)\n\t\t\t\t\t\t\tcharacters += delimit(next())\n\n\t\t\t\t\t\tatrule = peek(), offset = length = strlen(type = characters += identifier(caret())), character++\n\t\t\t\t\t\tbreak\n\t\t\t\t\t// -\n\t\t\t\t\tcase 45:\n\t\t\t\t\t\tif (previous === 45 && strlen(characters) == 2)\n\t\t\t\t\t\t\tvariable = 0\n\t\t\t\t}\n\t\t}\n\n\treturn rulesets\n}\n\n/**\n * @param {string} value\n * @param {object} root\n * @param {object?} parent\n * @param {number} index\n * @param {number} offset\n * @param {string[]} rules\n * @param {number[]} points\n * @param {string} type\n * @param {string[]} props\n * @param {string[]} children\n * @param {number} length\n * @return {object}\n */\nexport function ruleset (value, root, parent, index, offset, rules, points, type, props, children, length) {\n\tvar post = offset - 1\n\tvar rule = offset === 0 ? rules : ['']\n\tvar size = sizeof(rule)\n\n\tfor (var i = 0, j = 0, k = 0; i < index; ++i)\n\t\tfor (var x = 0, y = substr(value, post + 1, post = abs(j = points[i])), z = value; x < size; ++x)\n\t\t\tif (z = trim(j > 0 ? rule[x] + ' ' + y : replace(y, /&\\f/g, rule[x])))\n\t\t\t\tprops[k++] = z\n\n\treturn node(value, root, parent, offset === 0 ? RULESET : type, props, children, length)\n}\n\n/**\n * @param {number} value\n * @param {object} root\n * @param {object?} parent\n * @return {object}\n */\nexport function comment (value, root, parent) {\n\treturn node(value, root, parent, COMMENT, from(char()), substr(value, 2, -2), 0)\n}\n\n/**\n * @param {string} value\n * @param {object} root\n * @param {object?} parent\n * @param {number} length\n * @return {object}\n */\nexport function declaration (value, root, parent, length) {\n\treturn node(value, root, parent, DECLARATION, substr(value, 0, length), substr(value, length + 1, -1), length)\n}\n","import {IMPORT, LAYER, COMMENT, RULESET, DECLARATION, KEYFRAMES} from './Enum.js'\nimport {strlen, sizeof} from './Utility.js'\n\n/**\n * @param {object[]} children\n * @param {function} callback\n * @return {string}\n */\nexport function serialize (children, callback) {\n\tvar output = ''\n\tvar length = sizeof(children)\n\n\tfor (var i = 0; i < length; i++)\n\t\toutput += callback(children[i], i, children, callback) || ''\n\n\treturn output\n}\n\n/**\n * @param {object} element\n * @param {number} index\n * @param {object[]} children\n * @param {function} callback\n * @return {string}\n */\nexport function stringify (element, index, children, callback) {\n\tswitch (element.type) {\n\t\tcase LAYER: if (element.children.length) break\n\t\tcase IMPORT: case DECLARATION: return element.return = element.return || element.value\n\t\tcase COMMENT: return ''\n\t\tcase KEYFRAMES: return element.return = element.value + '{' + serialize(element.children, callback) + '}'\n\t\tcase RULESET: element.value = element.props.join(',')\n\t}\n\n\treturn strlen(children = serialize(element.children, callback)) ? element.return = element.value + '{' + children + '}' : ''\n}\n","import {MS, MOZ, WEBKIT, RULESET, KEYFRAMES, DECLARATION} from './Enum.js'\nimport {match, charat, substr, strlen, sizeof, replace, combine} from './Utility.js'\nimport {copy, tokenize} from './Tokenizer.js'\nimport {serialize} from './Serializer.js'\nimport {prefix} from './Prefixer.js'\n\n/**\n * @param {function[]} collection\n * @return {function}\n */\nexport function middleware (collection) {\n\tvar length = sizeof(collection)\n\n\treturn function (element, index, children, callback) {\n\t\tvar output = ''\n\n\t\tfor (var i = 0; i < length; i++)\n\t\t\toutput += collection[i](element, index, children, callback) || ''\n\n\t\treturn output\n\t}\n}\n\n/**\n * @param {function} callback\n * @return {function}\n */\nexport function rulesheet (callback) {\n\treturn function (element) {\n\t\tif (!element.root)\n\t\t\tif (element = element.return)\n\t\t\t\tcallback(element)\n\t}\n}\n\n/**\n * @param {object} element\n * @param {number} index\n * @param {object[]} children\n * @param {function} callback\n */\nexport function prefixer (element, index, children, callback) {\n\tif (element.length > -1)\n\t\tif (!element.return)\n\t\t\tswitch (element.type) {\n\t\t\t\tcase DECLARATION: element.return = prefix(element.value, element.length, children)\n\t\t\t\t\treturn\n\t\t\t\tcase KEYFRAMES:\n\t\t\t\t\treturn serialize([copy(element, {value: replace(element.value, '@', '@' + WEBKIT)})], callback)\n\t\t\t\tcase RULESET:\n\t\t\t\t\tif (element.length)\n\t\t\t\t\t\treturn combine(element.props, function (value) {\n\t\t\t\t\t\t\tswitch (match(value, /(::plac\\w+|:read-\\w+)/)) {\n\t\t\t\t\t\t\t\t// :read-(only|write)\n\t\t\t\t\t\t\t\tcase ':read-only': case ':read-write':\n\t\t\t\t\t\t\t\t\treturn serialize([copy(element, {props: [replace(value, /:(read-\\w+)/, ':' + MOZ + '$1')]})], callback)\n\t\t\t\t\t\t\t\t// :placeholder\n\t\t\t\t\t\t\t\tcase '::placeholder':\n\t\t\t\t\t\t\t\t\treturn serialize([\n\t\t\t\t\t\t\t\t\t\tcopy(element, {props: [replace(value, /:(plac\\w+)/, ':' + WEBKIT + 'input-$1')]}),\n\t\t\t\t\t\t\t\t\t\tcopy(element, {props: [replace(value, /:(plac\\w+)/, ':' + MOZ + '$1')]}),\n\t\t\t\t\t\t\t\t\t\tcopy(element, {props: [replace(value, /:(plac\\w+)/, MS + 'input-$1')]})\n\t\t\t\t\t\t\t\t\t], callback)\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\treturn ''\n\t\t\t\t\t\t})\n\t\t\t}\n}\n\n/**\n * @param {object} element\n * @param {number} index\n * @param {object[]} children\n */\nexport function namespace (element) {\n\tswitch (element.type) {\n\t\tcase RULESET:\n\t\t\telement.props = element.props.map(function (value) {\n\t\t\t\treturn combine(tokenize(value), function (value, index, children) {\n\t\t\t\t\tswitch (charat(value, 0)) {\n\t\t\t\t\t\t// \\f\n\t\t\t\t\t\tcase 12:\n\t\t\t\t\t\t\treturn substr(value, 1, strlen(value))\n\t\t\t\t\t\t// \\0 ( + > ~\n\t\t\t\t\t\tcase 0: case 40: case 43: case 62: case 126:\n\t\t\t\t\t\t\treturn value\n\t\t\t\t\t\t// :\n\t\t\t\t\t\tcase 58:\n\t\t\t\t\t\t\tif (children[++index] === 'global')\n\t\t\t\t\t\t\t\tchildren[index] = '', children[++index] = '\\f' + substr(children[index], index = 1, -1)\n\t\t\t\t\t\t// \\s\n\t\t\t\t\t\tcase 32:\n\t\t\t\t\t\t\treturn index === 1 ? '' : value\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\tswitch (index) {\n\t\t\t\t\t\t\t\tcase 0: element = value\n\t\t\t\t\t\t\t\t\treturn sizeof(children) > 1 ? '' : value\n\t\t\t\t\t\t\t\tcase index = sizeof(children) - 1: case 2:\n\t\t\t\t\t\t\t\t\treturn index === 2 ? value + element + element : value + element\n\t\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\t\treturn value\n\t\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t})\n\t}\n}\n","function memoize(fn) {\n var cache = Object.create(null);\n return function (arg) {\n if (cache[arg] === undefined) cache[arg] = fn(arg);\n return cache[arg];\n };\n}\n\nexport { memoize as default };\n","import { StyleSheet } from '@emotion/sheet';\nimport { dealloc, alloc, next, token, from, peek, delimit, slice, position, RULESET, combine, match, serialize, copy, replace, WEBKIT, MOZ, MS, KEYFRAMES, DECLARATION, hash, charat, strlen, indexof, stringify, rulesheet, middleware, compile } from 'stylis';\nimport '@emotion/weak-memoize';\nimport '@emotion/memoize';\n\nvar identifierWithPointTracking = function identifierWithPointTracking(begin, points, index) {\n var previous = 0;\n var character = 0;\n\n while (true) {\n previous = character;\n character = peek(); // &\\f\n\n if (previous === 38 && character === 12) {\n points[index] = 1;\n }\n\n if (token(character)) {\n break;\n }\n\n next();\n }\n\n return slice(begin, position);\n};\n\nvar toRules = function toRules(parsed, points) {\n // pretend we've started with a comma\n var index = -1;\n var character = 44;\n\n do {\n switch (token(character)) {\n case 0:\n // &\\f\n if (character === 38 && peek() === 12) {\n // this is not 100% correct, we don't account for literal sequences here - like for example quoted strings\n // stylis inserts \\f after & to know when & where it should replace this sequence with the context selector\n // and when it should just concatenate the outer and inner selectors\n // it's very unlikely for this sequence to actually appear in a different context, so we just leverage this fact here\n points[index] = 1;\n }\n\n parsed[index] += identifierWithPointTracking(position - 1, points, index);\n break;\n\n case 2:\n parsed[index] += delimit(character);\n break;\n\n case 4:\n // comma\n if (character === 44) {\n // colon\n parsed[++index] = peek() === 58 ? '&\\f' : '';\n points[index] = parsed[index].length;\n break;\n }\n\n // fallthrough\n\n default:\n parsed[index] += from(character);\n }\n } while (character = next());\n\n return parsed;\n};\n\nvar getRules = function getRules(value, points) {\n return dealloc(toRules(alloc(value), points));\n}; // WeakSet would be more appropriate, but only WeakMap is supported in IE11\n\n\nvar fixedElements = /* #__PURE__ */new WeakMap();\nvar compat = function compat(element) {\n if (element.type !== 'rule' || !element.parent || // positive .length indicates that this rule contains pseudo\n // negative .length indicates that this rule has been already prefixed\n element.length < 1) {\n return;\n }\n\n var value = element.value;\n var parent = element.parent;\n var isImplicitRule = element.column === parent.column && element.line === parent.line;\n\n while (parent.type !== 'rule') {\n parent = parent.parent;\n if (!parent) return;\n } // short-circuit for the simplest case\n\n\n if (element.props.length === 1 && value.charCodeAt(0) !== 58\n /* colon */\n && !fixedElements.get(parent)) {\n return;\n } // if this is an implicitly inserted rule (the one eagerly inserted at the each new nested level)\n // then the props has already been manipulated beforehand as they that array is shared between it and its \"rule parent\"\n\n\n if (isImplicitRule) {\n return;\n }\n\n fixedElements.set(element, true);\n var points = [];\n var rules = getRules(value, points);\n var parentRules = parent.props;\n\n for (var i = 0, k = 0; i < rules.length; i++) {\n for (var j = 0; j < parentRules.length; j++, k++) {\n element.props[k] = points[i] ? rules[i].replace(/&\\f/g, parentRules[j]) : parentRules[j] + \" \" + rules[i];\n }\n }\n};\nvar removeLabel = function removeLabel(element) {\n if (element.type === 'decl') {\n var value = element.value;\n\n if ( // charcode for l\n value.charCodeAt(0) === 108 && // charcode for b\n value.charCodeAt(2) === 98) {\n // this ignores label\n element[\"return\"] = '';\n element.value = '';\n }\n }\n};\n\n/* eslint-disable no-fallthrough */\n\nfunction prefix(value, length) {\n switch (hash(value, length)) {\n // color-adjust\n case 5103:\n return WEBKIT + 'print-' + value + value;\n // animation, animation-(delay|direction|duration|fill-mode|iteration-count|name|play-state|timing-function)\n\n case 5737:\n case 4201:\n case 3177:\n case 3433:\n case 1641:\n case 4457:\n case 2921: // text-decoration, filter, clip-path, backface-visibility, column, box-decoration-break\n\n case 5572:\n case 6356:\n case 5844:\n case 3191:\n case 6645:\n case 3005: // mask, mask-image, mask-(mode|clip|size), mask-(repeat|origin), mask-position, mask-composite,\n\n case 6391:\n case 5879:\n case 5623:\n case 6135:\n case 4599:\n case 4855: // background-clip, columns, column-(count|fill|gap|rule|rule-color|rule-style|rule-width|span|width)\n\n case 4215:\n case 6389:\n case 5109:\n case 5365:\n case 5621:\n case 3829:\n return WEBKIT + value + value;\n // appearance, user-select, transform, hyphens, text-size-adjust\n\n case 5349:\n case 4246:\n case 4810:\n case 6968:\n case 2756:\n return WEBKIT + value + MOZ + value + MS + value + value;\n // flex, flex-direction\n\n case 6828:\n case 4268:\n return WEBKIT + value + MS + value + value;\n // order\n\n case 6165:\n return WEBKIT + value + MS + 'flex-' + value + value;\n // align-items\n\n case 5187:\n return WEBKIT + value + replace(value, /(\\w+).+(:[^]+)/, WEBKIT + 'box-$1$2' + MS + 'flex-$1$2') + value;\n // align-self\n\n case 5443:\n return WEBKIT + value + MS + 'flex-item-' + replace(value, /flex-|-self/, '') + value;\n // align-content\n\n case 4675:\n return WEBKIT + value + MS + 'flex-line-pack' + replace(value, /align-content|flex-|-self/, '') + value;\n // flex-shrink\n\n case 5548:\n return WEBKIT + value + MS + replace(value, 'shrink', 'negative') + value;\n // flex-basis\n\n case 5292:\n return WEBKIT + value + MS + replace(value, 'basis', 'preferred-size') + value;\n // flex-grow\n\n case 6060:\n return WEBKIT + 'box-' + replace(value, '-grow', '') + WEBKIT + value + MS + replace(value, 'grow', 'positive') + value;\n // transition\n\n case 4554:\n return WEBKIT + replace(value, /([^-])(transform)/g, '$1' + WEBKIT + '$2') + value;\n // cursor\n\n case 6187:\n return replace(replace(replace(value, /(zoom-|grab)/, WEBKIT + '$1'), /(image-set)/, WEBKIT + '$1'), value, '') + value;\n // background, background-image\n\n case 5495:\n case 3959:\n return replace(value, /(image-set\\([^]*)/, WEBKIT + '$1' + '$`$1');\n // justify-content\n\n case 4968:\n return replace(replace(value, /(.+:)(flex-)?(.*)/, WEBKIT + 'box-pack:$3' + MS + 'flex-pack:$3'), /s.+-b[^;]+/, 'justify') + WEBKIT + value + value;\n // (margin|padding)-inline-(start|end)\n\n case 4095:\n case 3583:\n case 4068:\n case 2532:\n return replace(value, /(.+)-inline(.+)/, WEBKIT + '$1$2') + value;\n // (min|max)?(width|height|inline-size|block-size)\n\n case 8116:\n case 7059:\n case 5753:\n case 5535:\n case 5445:\n case 5701:\n case 4933:\n case 4677:\n case 5533:\n case 5789:\n case 5021:\n case 4765:\n // stretch, max-content, min-content, fill-available\n if (strlen(value) - 1 - length > 6) switch (charat(value, length + 1)) {\n // (m)ax-content, (m)in-content\n case 109:\n // -\n if (charat(value, length + 4) !== 45) break;\n // (f)ill-available, (f)it-content\n\n case 102:\n return replace(value, /(.+:)(.+)-([^]+)/, '$1' + WEBKIT + '$2-$3' + '$1' + MOZ + (charat(value, length + 3) == 108 ? '$3' : '$2-$3')) + value;\n // (s)tretch\n\n case 115:\n return ~indexof(value, 'stretch') ? prefix(replace(value, 'stretch', 'fill-available'), length) + value : value;\n }\n break;\n // position: sticky\n\n case 4949:\n // (s)ticky?\n if (charat(value, length + 1) !== 115) break;\n // display: (flex|inline-flex)\n\n case 6444:\n switch (charat(value, strlen(value) - 3 - (~indexof(value, '!important') && 10))) {\n // stic(k)y\n case 107:\n return replace(value, ':', ':' + WEBKIT) + value;\n // (inline-)?fl(e)x\n\n case 101:\n return replace(value, /(.+:)([^;!]+)(;|!.+)?/, '$1' + WEBKIT + (charat(value, 14) === 45 ? 'inline-' : '') + 'box$3' + '$1' + WEBKIT + '$2$3' + '$1' + MS + '$2box$3') + value;\n }\n\n break;\n // writing-mode\n\n case 5936:\n switch (charat(value, length + 11)) {\n // vertical-l(r)\n case 114:\n return WEBKIT + value + MS + replace(value, /[svh]\\w+-[tblr]{2}/, 'tb') + value;\n // vertical-r(l)\n\n case 108:\n return WEBKIT + value + MS + replace(value, /[svh]\\w+-[tblr]{2}/, 'tb-rl') + value;\n // horizontal(-)tb\n\n case 45:\n return WEBKIT + value + MS + replace(value, /[svh]\\w+-[tblr]{2}/, 'lr') + value;\n }\n\n return WEBKIT + value + MS + value + value;\n }\n\n return value;\n}\n\nvar prefixer = function prefixer(element, index, children, callback) {\n if (element.length > -1) if (!element[\"return\"]) switch (element.type) {\n case DECLARATION:\n element[\"return\"] = prefix(element.value, element.length);\n break;\n\n case KEYFRAMES:\n return serialize([copy(element, {\n value: replace(element.value, '@', '@' + WEBKIT)\n })], callback);\n\n case RULESET:\n if (element.length) return combine(element.props, function (value) {\n switch (match(value, /(::plac\\w+|:read-\\w+)/)) {\n // :read-(only|write)\n case ':read-only':\n case ':read-write':\n return serialize([copy(element, {\n props: [replace(value, /:(read-\\w+)/, ':' + MOZ + '$1')]\n })], callback);\n // :placeholder\n\n case '::placeholder':\n return serialize([copy(element, {\n props: [replace(value, /:(plac\\w+)/, ':' + WEBKIT + 'input-$1')]\n }), copy(element, {\n props: [replace(value, /:(plac\\w+)/, ':' + MOZ + '$1')]\n }), copy(element, {\n props: [replace(value, /:(plac\\w+)/, MS + 'input-$1')]\n })], callback);\n }\n\n return '';\n });\n }\n};\n\nvar defaultStylisPlugins = [prefixer];\n\nvar createCache = function createCache(options) {\n var key = options.key;\n\n if (key === 'css') {\n var ssrStyles = document.querySelectorAll(\"style[data-emotion]:not([data-s])\"); // get SSRed styles out of the way of React's hydration\n // document.head is a safe place to move them to(though note document.head is not necessarily the last place they will be)\n // note this very very intentionally targets all style elements regardless of the key to ensure\n // that creating a cache works inside of render of a React component\n\n Array.prototype.forEach.call(ssrStyles, function (node) {\n // we want to only move elements which have a space in the data-emotion attribute value\n // because that indicates that it is an Emotion 11 server-side rendered style elements\n // while we will already ignore Emotion 11 client-side inserted styles because of the :not([data-s]) part in the selector\n // Emotion 10 client-side inserted styles did not have data-s (but importantly did not have a space in their data-emotion attributes)\n // so checking for the space ensures that loading Emotion 11 after Emotion 10 has inserted some styles\n // will not result in the Emotion 10 styles being destroyed\n var dataEmotionAttribute = node.getAttribute('data-emotion');\n\n if (dataEmotionAttribute.indexOf(' ') === -1) {\n return;\n }\n\n document.head.appendChild(node);\n node.setAttribute('data-s', '');\n });\n }\n\n var stylisPlugins = options.stylisPlugins || defaultStylisPlugins;\n\n var inserted = {};\n var container;\n var nodesToHydrate = [];\n\n {\n container = options.container || document.head;\n Array.prototype.forEach.call( // this means we will ignore elements which don't have a space in them which\n // means that the style elements we're looking at are only Emotion 11 server-rendered style elements\n document.querySelectorAll(\"style[data-emotion^=\\\"\" + key + \" \\\"]\"), function (node) {\n var attrib = node.getAttribute(\"data-emotion\").split(' ');\n\n for (var i = 1; i < attrib.length; i++) {\n inserted[attrib[i]] = true;\n }\n\n nodesToHydrate.push(node);\n });\n }\n\n var _insert;\n\n var omnipresentPlugins = [compat, removeLabel];\n\n {\n var currentSheet;\n var finalizingPlugins = [stringify, rulesheet(function (rule) {\n currentSheet.insert(rule);\n })];\n var serializer = middleware(omnipresentPlugins.concat(stylisPlugins, finalizingPlugins));\n\n var stylis = function stylis(styles) {\n return serialize(compile(styles), serializer);\n };\n\n _insert = function insert(selector, serialized, sheet, shouldCache) {\n currentSheet = sheet;\n\n stylis(selector ? selector + \"{\" + serialized.styles + \"}\" : serialized.styles);\n\n if (shouldCache) {\n cache.inserted[serialized.name] = true;\n }\n };\n }\n\n var cache = {\n key: key,\n sheet: new StyleSheet({\n key: key,\n container: container,\n nonce: options.nonce,\n speedy: options.speedy,\n prepend: options.prepend,\n insertionPoint: options.insertionPoint\n }),\n nonce: options.nonce,\n inserted: inserted,\n registered: {},\n insert: _insert\n };\n cache.sheet.hydrate(nodesToHydrate);\n return cache;\n};\n\nexport { createCache as default };\n","/** @license React v16.13.1\n * react-is.production.min.js\n *\n * Copyright (c) Facebook, Inc. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n */\n\n'use strict';var b=\"function\"===typeof Symbol&&Symbol.for,c=b?Symbol.for(\"react.element\"):60103,d=b?Symbol.for(\"react.portal\"):60106,e=b?Symbol.for(\"react.fragment\"):60107,f=b?Symbol.for(\"react.strict_mode\"):60108,g=b?Symbol.for(\"react.profiler\"):60114,h=b?Symbol.for(\"react.provider\"):60109,k=b?Symbol.for(\"react.context\"):60110,l=b?Symbol.for(\"react.async_mode\"):60111,m=b?Symbol.for(\"react.concurrent_mode\"):60111,n=b?Symbol.for(\"react.forward_ref\"):60112,p=b?Symbol.for(\"react.suspense\"):60113,q=b?\nSymbol.for(\"react.suspense_list\"):60120,r=b?Symbol.for(\"react.memo\"):60115,t=b?Symbol.for(\"react.lazy\"):60116,v=b?Symbol.for(\"react.block\"):60121,w=b?Symbol.for(\"react.fundamental\"):60117,x=b?Symbol.for(\"react.responder\"):60118,y=b?Symbol.for(\"react.scope\"):60119;\nfunction z(a){if(\"object\"===typeof a&&null!==a){var u=a.$$typeof;switch(u){case c:switch(a=a.type,a){case l:case m:case e:case g:case f:case p:return a;default:switch(a=a&&a.$$typeof,a){case k:case n:case t:case r:case h:return a;default:return u}}case d:return u}}}function A(a){return z(a)===m}exports.AsyncMode=l;exports.ConcurrentMode=m;exports.ContextConsumer=k;exports.ContextProvider=h;exports.Element=c;exports.ForwardRef=n;exports.Fragment=e;exports.Lazy=t;exports.Memo=r;exports.Portal=d;\nexports.Profiler=g;exports.StrictMode=f;exports.Suspense=p;exports.isAsyncMode=function(a){return A(a)||z(a)===l};exports.isConcurrentMode=A;exports.isContextConsumer=function(a){return z(a)===k};exports.isContextProvider=function(a){return z(a)===h};exports.isElement=function(a){return\"object\"===typeof a&&null!==a&&a.$$typeof===c};exports.isForwardRef=function(a){return z(a)===n};exports.isFragment=function(a){return z(a)===e};exports.isLazy=function(a){return z(a)===t};\nexports.isMemo=function(a){return z(a)===r};exports.isPortal=function(a){return z(a)===d};exports.isProfiler=function(a){return z(a)===g};exports.isStrictMode=function(a){return z(a)===f};exports.isSuspense=function(a){return z(a)===p};\nexports.isValidElementType=function(a){return\"string\"===typeof a||\"function\"===typeof a||a===e||a===m||a===g||a===f||a===p||a===q||\"object\"===typeof a&&null!==a&&(a.$$typeof===t||a.$$typeof===r||a.$$typeof===h||a.$$typeof===k||a.$$typeof===n||a.$$typeof===w||a.$$typeof===x||a.$$typeof===y||a.$$typeof===v)};exports.typeOf=z;\n","'use strict';\n\nif (process.env.NODE_ENV === 'production') {\n module.exports = require('./cjs/react-is.production.min.js');\n} else {\n module.exports = require('./cjs/react-is.development.js');\n}\n","'use strict';\n\nvar reactIs = require('react-is');\n\n/**\n * Copyright 2015, Yahoo! Inc.\n * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.\n */\nvar REACT_STATICS = {\n childContextTypes: true,\n contextType: true,\n contextTypes: true,\n defaultProps: true,\n displayName: true,\n getDefaultProps: true,\n getDerivedStateFromError: true,\n getDerivedStateFromProps: true,\n mixins: true,\n propTypes: true,\n type: true\n};\nvar KNOWN_STATICS = {\n name: true,\n length: true,\n prototype: true,\n caller: true,\n callee: true,\n arguments: true,\n arity: true\n};\nvar FORWARD_REF_STATICS = {\n '$$typeof': true,\n render: true,\n defaultProps: true,\n displayName: true,\n propTypes: true\n};\nvar MEMO_STATICS = {\n '$$typeof': true,\n compare: true,\n defaultProps: true,\n displayName: true,\n propTypes: true,\n type: true\n};\nvar TYPE_STATICS = {};\nTYPE_STATICS[reactIs.ForwardRef] = FORWARD_REF_STATICS;\nTYPE_STATICS[reactIs.Memo] = MEMO_STATICS;\n\nfunction getStatics(component) {\n // React v16.11 and below\n if (reactIs.isMemo(component)) {\n return MEMO_STATICS;\n } // React v16.12 and above\n\n\n return TYPE_STATICS[component['$$typeof']] || REACT_STATICS;\n}\n\nvar defineProperty = Object.defineProperty;\nvar getOwnPropertyNames = Object.getOwnPropertyNames;\nvar getOwnPropertySymbols = Object.getOwnPropertySymbols;\nvar getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;\nvar getPrototypeOf = Object.getPrototypeOf;\nvar objectPrototype = Object.prototype;\nfunction hoistNonReactStatics(targetComponent, sourceComponent, blacklist) {\n if (typeof sourceComponent !== 'string') {\n // don't hoist over string (html) components\n if (objectPrototype) {\n var inheritedComponent = getPrototypeOf(sourceComponent);\n\n if (inheritedComponent && inheritedComponent !== objectPrototype) {\n hoistNonReactStatics(targetComponent, inheritedComponent, blacklist);\n }\n }\n\n var keys = getOwnPropertyNames(sourceComponent);\n\n if (getOwnPropertySymbols) {\n keys = keys.concat(getOwnPropertySymbols(sourceComponent));\n }\n\n var targetStatics = getStatics(targetComponent);\n var sourceStatics = getStatics(sourceComponent);\n\n for (var i = 0; i < keys.length; ++i) {\n var key = keys[i];\n\n if (!KNOWN_STATICS[key] && !(blacklist && blacklist[key]) && !(sourceStatics && sourceStatics[key]) && !(targetStatics && targetStatics[key])) {\n var descriptor = getOwnPropertyDescriptor(sourceComponent, key);\n\n try {\n // Avoid failures from read-only properties\n defineProperty(targetComponent, key, descriptor);\n } catch (e) {}\n }\n }\n }\n\n return targetComponent;\n}\n\nmodule.exports = hoistNonReactStatics;\n","var isBrowser = true;\n\nfunction getRegisteredStyles(registered, registeredStyles, classNames) {\n var rawClassName = '';\n classNames.split(' ').forEach(function (className) {\n if (registered[className] !== undefined) {\n registeredStyles.push(registered[className] + \";\");\n } else if (className) {\n rawClassName += className + \" \";\n }\n });\n return rawClassName;\n}\nvar registerStyles = function registerStyles(cache, serialized, isStringTag) {\n var className = cache.key + \"-\" + serialized.name;\n\n if ( // we only need to add the styles to the registered cache if the\n // class name could be used further down\n // the tree but if it's a string tag, we know it won't\n // so we don't have to add it to registered cache.\n // this improves memory usage since we can avoid storing the whole style string\n (isStringTag === false || // we need to always store it if we're in compat mode and\n // in node since emotion-server relies on whether a style is in\n // the registered cache to know whether a style is global or not\n // also, note that this check will be dead code eliminated in the browser\n isBrowser === false ) && cache.registered[className] === undefined) {\n cache.registered[className] = serialized.styles;\n }\n};\nvar insertStyles = function insertStyles(cache, serialized, isStringTag) {\n registerStyles(cache, serialized, isStringTag);\n var className = cache.key + \"-\" + serialized.name;\n\n if (cache.inserted[serialized.name] === undefined) {\n var current = serialized;\n\n do {\n cache.insert(serialized === current ? \".\" + className : '', current, cache.sheet, true);\n\n current = current.next;\n } while (current !== undefined);\n }\n};\n\nexport { getRegisteredStyles, insertStyles, registerStyles };\n","/* eslint-disable */\n// Inspired by https://github.com/garycourt/murmurhash-js\n// Ported from https://github.com/aappleby/smhasher/blob/61a0530f28277f2e850bfc39600ce61d02b518de/src/MurmurHash2.cpp#L37-L86\nfunction murmur2(str) {\n // 'm' and 'r' are mixing constants generated offline.\n // They're not really 'magic', they just happen to work well.\n // const m = 0x5bd1e995;\n // const r = 24;\n // Initialize the hash\n var h = 0; // Mix 4 bytes at a time into the hash\n\n var k,\n i = 0,\n len = str.length;\n\n for (; len >= 4; ++i, len -= 4) {\n k = str.charCodeAt(i) & 0xff | (str.charCodeAt(++i) & 0xff) << 8 | (str.charCodeAt(++i) & 0xff) << 16 | (str.charCodeAt(++i) & 0xff) << 24;\n k =\n /* Math.imul(k, m): */\n (k & 0xffff) * 0x5bd1e995 + ((k >>> 16) * 0xe995 << 16);\n k ^=\n /* k >>> r: */\n k >>> 24;\n h =\n /* Math.imul(k, m): */\n (k & 0xffff) * 0x5bd1e995 + ((k >>> 16) * 0xe995 << 16) ^\n /* Math.imul(h, m): */\n (h & 0xffff) * 0x5bd1e995 + ((h >>> 16) * 0xe995 << 16);\n } // Handle the last few bytes of the input array\n\n\n switch (len) {\n case 3:\n h ^= (str.charCodeAt(i + 2) & 0xff) << 16;\n\n case 2:\n h ^= (str.charCodeAt(i + 1) & 0xff) << 8;\n\n case 1:\n h ^= str.charCodeAt(i) & 0xff;\n h =\n /* Math.imul(h, m): */\n (h & 0xffff) * 0x5bd1e995 + ((h >>> 16) * 0xe995 << 16);\n } // Do a few final mixes of the hash to ensure the last few\n // bytes are well-incorporated.\n\n\n h ^= h >>> 13;\n h =\n /* Math.imul(h, m): */\n (h & 0xffff) * 0x5bd1e995 + ((h >>> 16) * 0xe995 << 16);\n return ((h ^ h >>> 15) >>> 0).toString(36);\n}\n\nexport { murmur2 as default };\n","var unitlessKeys = {\n animationIterationCount: 1,\n aspectRatio: 1,\n borderImageOutset: 1,\n borderImageSlice: 1,\n borderImageWidth: 1,\n boxFlex: 1,\n boxFlexGroup: 1,\n boxOrdinalGroup: 1,\n columnCount: 1,\n columns: 1,\n flex: 1,\n flexGrow: 1,\n flexPositive: 1,\n flexShrink: 1,\n flexNegative: 1,\n flexOrder: 1,\n gridRow: 1,\n gridRowEnd: 1,\n gridRowSpan: 1,\n gridRowStart: 1,\n gridColumn: 1,\n gridColumnEnd: 1,\n gridColumnSpan: 1,\n gridColumnStart: 1,\n msGridRow: 1,\n msGridRowSpan: 1,\n msGridColumn: 1,\n msGridColumnSpan: 1,\n fontWeight: 1,\n lineHeight: 1,\n opacity: 1,\n order: 1,\n orphans: 1,\n scale: 1,\n tabSize: 1,\n widows: 1,\n zIndex: 1,\n zoom: 1,\n WebkitLineClamp: 1,\n // SVG-related properties\n fillOpacity: 1,\n floodOpacity: 1,\n stopOpacity: 1,\n strokeDasharray: 1,\n strokeDashoffset: 1,\n strokeMiterlimit: 1,\n strokeOpacity: 1,\n strokeWidth: 1\n};\n\nexport { unitlessKeys as default };\n","import hashString from '@emotion/hash';\nimport unitless from '@emotion/unitless';\nimport memoize from '@emotion/memoize';\n\nvar isDevelopment = false;\n\nvar hyphenateRegex = /[A-Z]|^ms/g;\nvar animationRegex = /_EMO_([^_]+?)_([^]*?)_EMO_/g;\n\nvar isCustomProperty = function isCustomProperty(property) {\n return property.charCodeAt(1) === 45;\n};\n\nvar isProcessableValue = function isProcessableValue(value) {\n return value != null && typeof value !== 'boolean';\n};\n\nvar processStyleName = /* #__PURE__ */memoize(function (styleName) {\n return isCustomProperty(styleName) ? styleName : styleName.replace(hyphenateRegex, '-$&').toLowerCase();\n});\n\nvar processStyleValue = function processStyleValue(key, value) {\n switch (key) {\n case 'animation':\n case 'animationName':\n {\n if (typeof value === 'string') {\n return value.replace(animationRegex, function (match, p1, p2) {\n cursor = {\n name: p1,\n styles: p2,\n next: cursor\n };\n return p1;\n });\n }\n }\n }\n\n if (unitless[key] !== 1 && !isCustomProperty(key) && typeof value === 'number' && value !== 0) {\n return value + 'px';\n }\n\n return value;\n};\n\nvar noComponentSelectorMessage = 'Component selectors can only be used in conjunction with ' + '@emotion/babel-plugin, the swc Emotion plugin, or another Emotion-aware ' + 'compiler transform.';\n\nfunction handleInterpolation(mergedProps, registered, interpolation) {\n if (interpolation == null) {\n return '';\n }\n\n var componentSelector = interpolation;\n\n if (componentSelector.__emotion_styles !== undefined) {\n\n return componentSelector;\n }\n\n switch (typeof interpolation) {\n case 'boolean':\n {\n return '';\n }\n\n case 'object':\n {\n var keyframes = interpolation;\n\n if (keyframes.anim === 1) {\n cursor = {\n name: keyframes.name,\n styles: keyframes.styles,\n next: cursor\n };\n return keyframes.name;\n }\n\n var serializedStyles = interpolation;\n\n if (serializedStyles.styles !== undefined) {\n var next = serializedStyles.next;\n\n if (next !== undefined) {\n // not the most efficient thing ever but this is a pretty rare case\n // and there will be very few iterations of this generally\n while (next !== undefined) {\n cursor = {\n name: next.name,\n styles: next.styles,\n next: cursor\n };\n next = next.next;\n }\n }\n\n var styles = serializedStyles.styles + \";\";\n return styles;\n }\n\n return createStringFromObject(mergedProps, registered, interpolation);\n }\n\n case 'function':\n {\n if (mergedProps !== undefined) {\n var previousCursor = cursor;\n var result = interpolation(mergedProps);\n cursor = previousCursor;\n return handleInterpolation(mergedProps, registered, result);\n }\n\n break;\n }\n } // finalize string values (regular strings and functions interpolated into css calls)\n\n\n var asString = interpolation;\n\n if (registered == null) {\n return asString;\n }\n\n var cached = registered[asString];\n return cached !== undefined ? cached : asString;\n}\n\nfunction createStringFromObject(mergedProps, registered, obj) {\n var string = '';\n\n if (Array.isArray(obj)) {\n for (var i = 0; i < obj.length; i++) {\n string += handleInterpolation(mergedProps, registered, obj[i]) + \";\";\n }\n } else {\n for (var key in obj) {\n var value = obj[key];\n\n if (typeof value !== 'object') {\n var asString = value;\n\n if (registered != null && registered[asString] !== undefined) {\n string += key + \"{\" + registered[asString] + \"}\";\n } else if (isProcessableValue(asString)) {\n string += processStyleName(key) + \":\" + processStyleValue(key, asString) + \";\";\n }\n } else {\n if (key === 'NO_COMPONENT_SELECTOR' && isDevelopment) {\n throw new Error(noComponentSelectorMessage);\n }\n\n if (Array.isArray(value) && typeof value[0] === 'string' && (registered == null || registered[value[0]] === undefined)) {\n for (var _i = 0; _i < value.length; _i++) {\n if (isProcessableValue(value[_i])) {\n string += processStyleName(key) + \":\" + processStyleValue(key, value[_i]) + \";\";\n }\n }\n } else {\n var interpolated = handleInterpolation(mergedProps, registered, value);\n\n switch (key) {\n case 'animation':\n case 'animationName':\n {\n string += processStyleName(key) + \":\" + interpolated + \";\";\n break;\n }\n\n default:\n {\n\n string += key + \"{\" + interpolated + \"}\";\n }\n }\n }\n }\n }\n }\n\n return string;\n}\n\nvar labelPattern = /label:\\s*([^\\s;{]+)\\s*(;|$)/g; // this is the cursor for keyframes\n// keyframes are stored on the SerializedStyles object as a linked list\n\nvar cursor;\nfunction serializeStyles(args, registered, mergedProps) {\n if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null && args[0].styles !== undefined) {\n return args[0];\n }\n\n var stringMode = true;\n var styles = '';\n cursor = undefined;\n var strings = args[0];\n\n if (strings == null || strings.raw === undefined) {\n stringMode = false;\n styles += handleInterpolation(mergedProps, registered, strings);\n } else {\n var asTemplateStringsArr = strings;\n\n styles += asTemplateStringsArr[0];\n } // we start at 1 since we've already handled the first arg\n\n\n for (var i = 1; i < args.length; i++) {\n styles += handleInterpolation(mergedProps, registered, args[i]);\n\n if (stringMode) {\n var templateStringsArr = strings;\n\n styles += templateStringsArr[i];\n }\n } // using a global regex with .exec is stateful so lastIndex has to be reset each time\n\n\n labelPattern.lastIndex = 0;\n var identifierName = '';\n var match; // https://esbench.com/bench/5b809c2cf2949800a0f61fb5\n\n while ((match = labelPattern.exec(styles)) !== null) {\n identifierName += '-' + match[1];\n }\n\n var name = hashString(styles) + identifierName;\n\n return {\n name: name,\n styles: styles,\n next: cursor\n };\n}\n\nexport { serializeStyles };\n","import * as React from 'react';\n\nvar syncFallback = function syncFallback(create) {\n return create();\n};\n\nvar useInsertionEffect = React['useInsertion' + 'Effect'] ? React['useInsertion' + 'Effect'] : false;\nvar useInsertionEffectAlwaysWithSyncFallback = useInsertionEffect || syncFallback;\nvar useInsertionEffectWithLayoutFallback = useInsertionEffect || React.useLayoutEffect;\n\nexport { useInsertionEffectAlwaysWithSyncFallback, useInsertionEffectWithLayoutFallback };\n","import * as React from 'react';\nimport { useContext, forwardRef } from 'react';\nimport createCache from '@emotion/cache';\nimport _extends from '@babel/runtime/helpers/esm/extends';\nimport weakMemoize from '@emotion/weak-memoize';\nimport hoistNonReactStatics from '../_isolated-hnrs/dist/emotion-react-_isolated-hnrs.browser.esm.js';\nimport { getRegisteredStyles, registerStyles, insertStyles } from '@emotion/utils';\nimport { serializeStyles } from '@emotion/serialize';\nimport { useInsertionEffectAlwaysWithSyncFallback } from '@emotion/use-insertion-effect-with-fallbacks';\n\nvar isDevelopment = false;\n\nvar EmotionCacheContext = /* #__PURE__ */React.createContext( // we're doing this to avoid preconstruct's dead code elimination in this one case\n// because this module is primarily intended for the browser and node\n// but it's also required in react native and similar environments sometimes\n// and we could have a special build just for that\n// but this is much easier and the native packages\n// might use a different theme context in the future anyway\ntypeof HTMLElement !== 'undefined' ? /* #__PURE__ */createCache({\n key: 'css'\n}) : null);\n\nvar CacheProvider = EmotionCacheContext.Provider;\nvar __unsafe_useEmotionCache = function useEmotionCache() {\n return useContext(EmotionCacheContext);\n};\n\nvar withEmotionCache = function withEmotionCache(func) {\n return /*#__PURE__*/forwardRef(function (props, ref) {\n // the cache will never be null in the browser\n var cache = useContext(EmotionCacheContext);\n return func(props, cache, ref);\n });\n};\n\nvar ThemeContext = /* #__PURE__ */React.createContext({});\n\nvar useTheme = function useTheme() {\n return React.useContext(ThemeContext);\n};\n\nvar getTheme = function getTheme(outerTheme, theme) {\n if (typeof theme === 'function') {\n var mergedTheme = theme(outerTheme);\n\n return mergedTheme;\n }\n\n return _extends({}, outerTheme, theme);\n};\n\nvar createCacheWithTheme = /* #__PURE__ */weakMemoize(function (outerTheme) {\n return weakMemoize(function (theme) {\n return getTheme(outerTheme, theme);\n });\n});\nvar ThemeProvider = function ThemeProvider(props) {\n var theme = React.useContext(ThemeContext);\n\n if (props.theme !== theme) {\n theme = createCacheWithTheme(theme)(props.theme);\n }\n\n return /*#__PURE__*/React.createElement(ThemeContext.Provider, {\n value: theme\n }, props.children);\n};\nfunction withTheme(Component) {\n var componentName = Component.displayName || Component.name || 'Component';\n var WithTheme = /*#__PURE__*/React.forwardRef(function render(props, ref) {\n var theme = React.useContext(ThemeContext);\n return /*#__PURE__*/React.createElement(Component, _extends({\n theme: theme,\n ref: ref\n }, props));\n });\n WithTheme.displayName = \"WithTheme(\" + componentName + \")\";\n return hoistNonReactStatics(WithTheme, Component);\n}\n\nvar hasOwn = {}.hasOwnProperty;\n\nvar typePropName = '__EMOTION_TYPE_PLEASE_DO_NOT_USE__';\nvar createEmotionProps = function createEmotionProps(type, props) {\n\n var newProps = {};\n\n for (var _key in props) {\n if (hasOwn.call(props, _key)) {\n newProps[_key] = props[_key];\n }\n }\n\n newProps[typePropName] = type; // Runtime labeling is an opt-in feature because:\n\n return newProps;\n};\n\nvar Insertion = function Insertion(_ref) {\n var cache = _ref.cache,\n serialized = _ref.serialized,\n isStringTag = _ref.isStringTag;\n registerStyles(cache, serialized, isStringTag);\n useInsertionEffectAlwaysWithSyncFallback(function () {\n return insertStyles(cache, serialized, isStringTag);\n });\n\n return null;\n};\n\nvar Emotion = /* #__PURE__ */withEmotionCache(function (props, cache, ref) {\n var cssProp = props.css; // so that using `css` from `emotion` and passing the result to the css prop works\n // not passing the registered cache to serializeStyles because it would\n // make certain babel optimisations not possible\n\n if (typeof cssProp === 'string' && cache.registered[cssProp] !== undefined) {\n cssProp = cache.registered[cssProp];\n }\n\n var WrappedComponent = props[typePropName];\n var registeredStyles = [cssProp];\n var className = '';\n\n if (typeof props.className === 'string') {\n className = getRegisteredStyles(cache.registered, registeredStyles, props.className);\n } else if (props.className != null) {\n className = props.className + \" \";\n }\n\n var serialized = serializeStyles(registeredStyles, undefined, React.useContext(ThemeContext));\n\n className += cache.key + \"-\" + serialized.name;\n var newProps = {};\n\n for (var _key2 in props) {\n if (hasOwn.call(props, _key2) && _key2 !== 'css' && _key2 !== typePropName && (!isDevelopment )) {\n newProps[_key2] = props[_key2];\n }\n }\n\n newProps.className = className;\n\n if (ref) {\n newProps.ref = ref;\n }\n\n return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Insertion, {\n cache: cache,\n serialized: serialized,\n isStringTag: typeof WrappedComponent === 'string'\n }), /*#__PURE__*/React.createElement(WrappedComponent, newProps));\n});\n\nvar Emotion$1 = Emotion;\n\nexport { CacheProvider as C, Emotion$1 as E, ThemeContext as T, __unsafe_useEmotionCache as _, ThemeProvider as a, withTheme as b, createEmotionProps as c, hasOwn as h, isDevelopment as i, useTheme as u, withEmotionCache as w };\n","import { h as hasOwn, E as Emotion, c as createEmotionProps, w as withEmotionCache, T as ThemeContext, i as isDevelopment } from './emotion-element-f0de968e.browser.esm.js';\nexport { C as CacheProvider, T as ThemeContext, a as ThemeProvider, _ as __unsafe_useEmotionCache, u as useTheme, w as withEmotionCache, b as withTheme } from './emotion-element-f0de968e.browser.esm.js';\nimport * as React from 'react';\nimport { insertStyles, registerStyles, getRegisteredStyles } from '@emotion/utils';\nimport { useInsertionEffectWithLayoutFallback, useInsertionEffectAlwaysWithSyncFallback } from '@emotion/use-insertion-effect-with-fallbacks';\nimport { serializeStyles } from '@emotion/serialize';\nimport '@emotion/cache';\nimport '@babel/runtime/helpers/extends';\nimport '@emotion/weak-memoize';\nimport '../_isolated-hnrs/dist/emotion-react-_isolated-hnrs.browser.esm.js';\nimport 'hoist-non-react-statics';\n\nvar jsx = function jsx(type, props) {\n // eslint-disable-next-line prefer-rest-params\n var args = arguments;\n\n if (props == null || !hasOwn.call(props, 'css')) {\n return React.createElement.apply(undefined, args);\n }\n\n var argsLength = args.length;\n var createElementArgArray = new Array(argsLength);\n createElementArgArray[0] = Emotion;\n createElementArgArray[1] = createEmotionProps(type, props);\n\n for (var i = 2; i < argsLength; i++) {\n createElementArgArray[i] = args[i];\n }\n\n return React.createElement.apply(null, createElementArgArray);\n};\n\n(function (_jsx) {\n var JSX;\n\n (function (_JSX) {})(JSX || (JSX = _jsx.JSX || (_jsx.JSX = {})));\n})(jsx || (jsx = {}));\n\n// initial render from browser, insertBefore context.sheet.tags[0] or if a style hasn't been inserted there yet, appendChild\n// initial client-side render from SSR, use place of hydrating tag\n\nvar Global = /* #__PURE__ */withEmotionCache(function (props, cache) {\n\n var styles = props.styles;\n var serialized = serializeStyles([styles], undefined, React.useContext(ThemeContext));\n // but it is based on a constant that will never change at runtime\n // it's effectively like having two implementations and switching them out\n // so it's not actually breaking anything\n\n\n var sheetRef = React.useRef();\n useInsertionEffectWithLayoutFallback(function () {\n var key = cache.key + \"-global\"; // use case of https://github.com/emotion-js/emotion/issues/2675\n\n var sheet = new cache.sheet.constructor({\n key: key,\n nonce: cache.sheet.nonce,\n container: cache.sheet.container,\n speedy: cache.sheet.isSpeedy\n });\n var rehydrating = false;\n var node = document.querySelector(\"style[data-emotion=\\\"\" + key + \" \" + serialized.name + \"\\\"]\");\n\n if (cache.sheet.tags.length) {\n sheet.before = cache.sheet.tags[0];\n }\n\n if (node !== null) {\n rehydrating = true; // clear the hash so this node won't be recognizable as rehydratable by other s\n\n node.setAttribute('data-emotion', key);\n sheet.hydrate([node]);\n }\n\n sheetRef.current = [sheet, rehydrating];\n return function () {\n sheet.flush();\n };\n }, [cache]);\n useInsertionEffectWithLayoutFallback(function () {\n var sheetRefCurrent = sheetRef.current;\n var sheet = sheetRefCurrent[0],\n rehydrating = sheetRefCurrent[1];\n\n if (rehydrating) {\n sheetRefCurrent[1] = false;\n return;\n }\n\n if (serialized.next !== undefined) {\n // insert keyframes\n insertStyles(cache, serialized.next, true);\n }\n\n if (sheet.tags.length) {\n // if this doesn't exist then it will be null so the style element will be appended\n var element = sheet.tags[sheet.tags.length - 1].nextElementSibling;\n sheet.before = element;\n sheet.flush();\n }\n\n cache.insert(\"\", serialized, sheet, false);\n }, [cache, serialized.name]);\n return null;\n});\n\nfunction css() {\n for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {\n args[_key] = arguments[_key];\n }\n\n return serializeStyles(args);\n}\n\nfunction keyframes() {\n var insertable = css.apply(void 0, arguments);\n var name = \"animation-\" + insertable.name;\n return {\n name: name,\n styles: \"@keyframes \" + name + \"{\" + insertable.styles + \"}\",\n anim: 1,\n toString: function toString() {\n return \"_EMO_\" + this.name + \"_\" + this.styles + \"_EMO_\";\n }\n };\n}\n\nvar classnames = function classnames(args) {\n var len = args.length;\n var i = 0;\n var cls = '';\n\n for (; i < len; i++) {\n var arg = args[i];\n if (arg == null) continue;\n var toAdd = void 0;\n\n switch (typeof arg) {\n case 'boolean':\n break;\n\n case 'object':\n {\n if (Array.isArray(arg)) {\n toAdd = classnames(arg);\n } else {\n\n toAdd = '';\n\n for (var k in arg) {\n if (arg[k] && k) {\n toAdd && (toAdd += ' ');\n toAdd += k;\n }\n }\n }\n\n break;\n }\n\n default:\n {\n toAdd = arg;\n }\n }\n\n if (toAdd) {\n cls && (cls += ' ');\n cls += toAdd;\n }\n }\n\n return cls;\n};\n\nfunction merge(registered, css, className) {\n var registeredStyles = [];\n var rawClassName = getRegisteredStyles(registered, registeredStyles, className);\n\n if (registeredStyles.length < 2) {\n return className;\n }\n\n return rawClassName + css(registeredStyles);\n}\n\nvar Insertion = function Insertion(_ref) {\n var cache = _ref.cache,\n serializedArr = _ref.serializedArr;\n useInsertionEffectAlwaysWithSyncFallback(function () {\n\n for (var i = 0; i < serializedArr.length; i++) {\n insertStyles(cache, serializedArr[i], false);\n }\n });\n\n return null;\n};\n\nvar ClassNames = /* #__PURE__ */withEmotionCache(function (props, cache) {\n var hasRendered = false;\n var serializedArr = [];\n\n var css = function css() {\n if (hasRendered && isDevelopment) {\n throw new Error('css can only be used during render');\n }\n\n for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {\n args[_key] = arguments[_key];\n }\n\n var serialized = serializeStyles(args, cache.registered);\n serializedArr.push(serialized); // registration has to happen here as the result of this might get consumed by `cx`\n\n registerStyles(cache, serialized, false);\n return cache.key + \"-\" + serialized.name;\n };\n\n var cx = function cx() {\n if (hasRendered && isDevelopment) {\n throw new Error('cx can only be used during render');\n }\n\n for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {\n args[_key2] = arguments[_key2];\n }\n\n return merge(cache.registered, css, classnames(args));\n };\n\n var content = {\n css: css,\n cx: cx,\n theme: React.useContext(ThemeContext)\n };\n var ele = props.children(content);\n hasRendered = true;\n return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Insertion, {\n cache: cache,\n serializedArr: serializedArr\n }), ele);\n});\n\nexport { ClassNames, Global, jsx as createElement, css, jsx, keyframes };\n","function _taggedTemplateLiteral(e, t) {\n return t || (t = e.slice(0)), Object.freeze(Object.defineProperties(e, {\n raw: {\n value: Object.freeze(t)\n }\n }));\n}\nexport { _taggedTemplateLiteral as default };","/**\n * Custom positioning reference element.\n * @see https://floating-ui.com/docs/virtual-elements\n */\n\nconst sides = ['top', 'right', 'bottom', 'left'];\nconst alignments = ['start', 'end'];\nconst placements = /*#__PURE__*/sides.reduce((acc, side) => acc.concat(side, side + \"-\" + alignments[0], side + \"-\" + alignments[1]), []);\nconst min = Math.min;\nconst max = Math.max;\nconst round = Math.round;\nconst floor = Math.floor;\nconst createCoords = v => ({\n x: v,\n y: v\n});\nconst oppositeSideMap = {\n left: 'right',\n right: 'left',\n bottom: 'top',\n top: 'bottom'\n};\nconst oppositeAlignmentMap = {\n start: 'end',\n end: 'start'\n};\nfunction clamp(start, value, end) {\n return max(start, min(value, end));\n}\nfunction evaluate(value, param) {\n return typeof value === 'function' ? value(param) : value;\n}\nfunction getSide(placement) {\n return placement.split('-')[0];\n}\nfunction getAlignment(placement) {\n return placement.split('-')[1];\n}\nfunction getOppositeAxis(axis) {\n return axis === 'x' ? 'y' : 'x';\n}\nfunction getAxisLength(axis) {\n return axis === 'y' ? 'height' : 'width';\n}\nconst yAxisSides = /*#__PURE__*/new Set(['top', 'bottom']);\nfunction getSideAxis(placement) {\n return yAxisSides.has(getSide(placement)) ? 'y' : 'x';\n}\nfunction getAlignmentAxis(placement) {\n return getOppositeAxis(getSideAxis(placement));\n}\nfunction getAlignmentSides(placement, rects, rtl) {\n if (rtl === void 0) {\n rtl = false;\n }\n const alignment = getAlignment(placement);\n const alignmentAxis = getAlignmentAxis(placement);\n const length = getAxisLength(alignmentAxis);\n let mainAlignmentSide = alignmentAxis === 'x' ? alignment === (rtl ? 'end' : 'start') ? 'right' : 'left' : alignment === 'start' ? 'bottom' : 'top';\n if (rects.reference[length] > rects.floating[length]) {\n mainAlignmentSide = getOppositePlacement(mainAlignmentSide);\n }\n return [mainAlignmentSide, getOppositePlacement(mainAlignmentSide)];\n}\nfunction getExpandedPlacements(placement) {\n const oppositePlacement = getOppositePlacement(placement);\n return [getOppositeAlignmentPlacement(placement), oppositePlacement, getOppositeAlignmentPlacement(oppositePlacement)];\n}\nfunction getOppositeAlignmentPlacement(placement) {\n return placement.replace(/start|end/g, alignment => oppositeAlignmentMap[alignment]);\n}\nconst lrPlacement = ['left', 'right'];\nconst rlPlacement = ['right', 'left'];\nconst tbPlacement = ['top', 'bottom'];\nconst btPlacement = ['bottom', 'top'];\nfunction getSideList(side, isStart, rtl) {\n switch (side) {\n case 'top':\n case 'bottom':\n if (rtl) return isStart ? rlPlacement : lrPlacement;\n return isStart ? lrPlacement : rlPlacement;\n case 'left':\n case 'right':\n return isStart ? tbPlacement : btPlacement;\n default:\n return [];\n }\n}\nfunction getOppositeAxisPlacements(placement, flipAlignment, direction, rtl) {\n const alignment = getAlignment(placement);\n let list = getSideList(getSide(placement), direction === 'start', rtl);\n if (alignment) {\n list = list.map(side => side + \"-\" + alignment);\n if (flipAlignment) {\n list = list.concat(list.map(getOppositeAlignmentPlacement));\n }\n }\n return list;\n}\nfunction getOppositePlacement(placement) {\n return placement.replace(/left|right|bottom|top/g, side => oppositeSideMap[side]);\n}\nfunction expandPaddingObject(padding) {\n return {\n top: 0,\n right: 0,\n bottom: 0,\n left: 0,\n ...padding\n };\n}\nfunction getPaddingObject(padding) {\n return typeof padding !== 'number' ? expandPaddingObject(padding) : {\n top: padding,\n right: padding,\n bottom: padding,\n left: padding\n };\n}\nfunction rectToClientRect(rect) {\n const {\n x,\n y,\n width,\n height\n } = rect;\n return {\n width,\n height,\n top: y,\n left: x,\n right: x + width,\n bottom: y + height,\n x,\n y\n };\n}\n\nexport { alignments, clamp, createCoords, evaluate, expandPaddingObject, floor, getAlignment, getAlignmentAxis, getAlignmentSides, getAxisLength, getExpandedPlacements, getOppositeAlignmentPlacement, getOppositeAxis, getOppositeAxisPlacements, getOppositePlacement, getPaddingObject, getSide, getSideAxis, max, min, placements, rectToClientRect, round, sides };\n","function hasWindow() {\n return typeof window !== 'undefined';\n}\nfunction getNodeName(node) {\n if (isNode(node)) {\n return (node.nodeName || '').toLowerCase();\n }\n // Mocked nodes in testing environments may not be instances of Node. By\n // returning `#document` an infinite loop won't occur.\n // https://github.com/floating-ui/floating-ui/issues/2317\n return '#document';\n}\nfunction getWindow(node) {\n var _node$ownerDocument;\n return (node == null || (_node$ownerDocument = node.ownerDocument) == null ? void 0 : _node$ownerDocument.defaultView) || window;\n}\nfunction getDocumentElement(node) {\n var _ref;\n return (_ref = (isNode(node) ? node.ownerDocument : node.document) || window.document) == null ? void 0 : _ref.documentElement;\n}\nfunction isNode(value) {\n if (!hasWindow()) {\n return false;\n }\n return value instanceof Node || value instanceof getWindow(value).Node;\n}\nfunction isElement(value) {\n if (!hasWindow()) {\n return false;\n }\n return value instanceof Element || value instanceof getWindow(value).Element;\n}\nfunction isHTMLElement(value) {\n if (!hasWindow()) {\n return false;\n }\n return value instanceof HTMLElement || value instanceof getWindow(value).HTMLElement;\n}\nfunction isShadowRoot(value) {\n if (!hasWindow() || typeof ShadowRoot === 'undefined') {\n return false;\n }\n return value instanceof ShadowRoot || value instanceof getWindow(value).ShadowRoot;\n}\nconst invalidOverflowDisplayValues = /*#__PURE__*/new Set(['inline', 'contents']);\nfunction isOverflowElement(element) {\n const {\n overflow,\n overflowX,\n overflowY,\n display\n } = getComputedStyle(element);\n return /auto|scroll|overlay|hidden|clip/.test(overflow + overflowY + overflowX) && !invalidOverflowDisplayValues.has(display);\n}\nconst tableElements = /*#__PURE__*/new Set(['table', 'td', 'th']);\nfunction isTableElement(element) {\n return tableElements.has(getNodeName(element));\n}\nconst topLayerSelectors = [':popover-open', ':modal'];\nfunction isTopLayer(element) {\n return topLayerSelectors.some(selector => {\n try {\n return element.matches(selector);\n } catch (_e) {\n return false;\n }\n });\n}\nconst transformProperties = ['transform', 'translate', 'scale', 'rotate', 'perspective'];\nconst willChangeValues = ['transform', 'translate', 'scale', 'rotate', 'perspective', 'filter'];\nconst containValues = ['paint', 'layout', 'strict', 'content'];\nfunction isContainingBlock(elementOrCss) {\n const webkit = isWebKit();\n const css = isElement(elementOrCss) ? getComputedStyle(elementOrCss) : elementOrCss;\n\n // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block\n // https://drafts.csswg.org/css-transforms-2/#individual-transforms\n return transformProperties.some(value => css[value] ? css[value] !== 'none' : false) || (css.containerType ? css.containerType !== 'normal' : false) || !webkit && (css.backdropFilter ? css.backdropFilter !== 'none' : false) || !webkit && (css.filter ? css.filter !== 'none' : false) || willChangeValues.some(value => (css.willChange || '').includes(value)) || containValues.some(value => (css.contain || '').includes(value));\n}\nfunction getContainingBlock(element) {\n let currentNode = getParentNode(element);\n while (isHTMLElement(currentNode) && !isLastTraversableNode(currentNode)) {\n if (isContainingBlock(currentNode)) {\n return currentNode;\n } else if (isTopLayer(currentNode)) {\n return null;\n }\n currentNode = getParentNode(currentNode);\n }\n return null;\n}\nfunction isWebKit() {\n if (typeof CSS === 'undefined' || !CSS.supports) return false;\n return CSS.supports('-webkit-backdrop-filter', 'none');\n}\nconst lastTraversableNodeNames = /*#__PURE__*/new Set(['html', 'body', '#document']);\nfunction isLastTraversableNode(node) {\n return lastTraversableNodeNames.has(getNodeName(node));\n}\nfunction getComputedStyle(element) {\n return getWindow(element).getComputedStyle(element);\n}\nfunction getNodeScroll(element) {\n if (isElement(element)) {\n return {\n scrollLeft: element.scrollLeft,\n scrollTop: element.scrollTop\n };\n }\n return {\n scrollLeft: element.scrollX,\n scrollTop: element.scrollY\n };\n}\nfunction getParentNode(node) {\n if (getNodeName(node) === 'html') {\n return node;\n }\n const result =\n // Step into the shadow DOM of the parent of a slotted node.\n node.assignedSlot ||\n // DOM Element detected.\n node.parentNode ||\n // ShadowRoot detected.\n isShadowRoot(node) && node.host ||\n // Fallback.\n getDocumentElement(node);\n return isShadowRoot(result) ? result.host : result;\n}\nfunction getNearestOverflowAncestor(node) {\n const parentNode = getParentNode(node);\n if (isLastTraversableNode(parentNode)) {\n return node.ownerDocument ? node.ownerDocument.body : node.body;\n }\n if (isHTMLElement(parentNode) && isOverflowElement(parentNode)) {\n return parentNode;\n }\n return getNearestOverflowAncestor(parentNode);\n}\nfunction getOverflowAncestors(node, list, traverseIframes) {\n var _node$ownerDocument2;\n if (list === void 0) {\n list = [];\n }\n if (traverseIframes === void 0) {\n traverseIframes = true;\n }\n const scrollableAncestor = getNearestOverflowAncestor(node);\n const isBody = scrollableAncestor === ((_node$ownerDocument2 = node.ownerDocument) == null ? void 0 : _node$ownerDocument2.body);\n const win = getWindow(scrollableAncestor);\n if (isBody) {\n const frameElement = getFrameElement(win);\n return list.concat(win, win.visualViewport || [], isOverflowElement(scrollableAncestor) ? scrollableAncestor : [], frameElement && traverseIframes ? getOverflowAncestors(frameElement) : []);\n }\n return list.concat(scrollableAncestor, getOverflowAncestors(scrollableAncestor, [], traverseIframes));\n}\nfunction getFrameElement(win) {\n return win.parent && Object.getPrototypeOf(win.parent) ? win.frameElement : null;\n}\n\nexport { getComputedStyle, getContainingBlock, getDocumentElement, getFrameElement, getNearestOverflowAncestor, getNodeName, getNodeScroll, getOverflowAncestors, getParentNode, getWindow, isContainingBlock, isElement, isHTMLElement, isLastTraversableNode, isNode, isOverflowElement, isShadowRoot, isTableElement, isTopLayer, isWebKit };\n","import { rectToClientRect, arrow as arrow$1, autoPlacement as autoPlacement$1, detectOverflow as detectOverflow$1, flip as flip$1, hide as hide$1, inline as inline$1, limitShift as limitShift$1, offset as offset$1, shift as shift$1, size as size$1, computePosition as computePosition$1 } from '@floating-ui/core';\nimport { round, createCoords, max, min, floor } from '@floating-ui/utils';\nimport { getComputedStyle as getComputedStyle$1, isHTMLElement, isElement, getWindow, isWebKit, getFrameElement, getNodeScroll, getDocumentElement, isTopLayer, getNodeName, isOverflowElement, getOverflowAncestors, getParentNode, isLastTraversableNode, isContainingBlock, isTableElement, getContainingBlock } from '@floating-ui/utils/dom';\nexport { getOverflowAncestors } from '@floating-ui/utils/dom';\n\nfunction getCssDimensions(element) {\n const css = getComputedStyle$1(element);\n // In testing environments, the `width` and `height` properties are empty\n // strings for SVG elements, returning NaN. Fallback to `0` in this case.\n let width = parseFloat(css.width) || 0;\n let height = parseFloat(css.height) || 0;\n const hasOffset = isHTMLElement(element);\n const offsetWidth = hasOffset ? element.offsetWidth : width;\n const offsetHeight = hasOffset ? element.offsetHeight : height;\n const shouldFallback = round(width) !== offsetWidth || round(height) !== offsetHeight;\n if (shouldFallback) {\n width = offsetWidth;\n height = offsetHeight;\n }\n return {\n width,\n height,\n $: shouldFallback\n };\n}\n\nfunction unwrapElement(element) {\n return !isElement(element) ? element.contextElement : element;\n}\n\nfunction getScale(element) {\n const domElement = unwrapElement(element);\n if (!isHTMLElement(domElement)) {\n return createCoords(1);\n }\n const rect = domElement.getBoundingClientRect();\n const {\n width,\n height,\n $\n } = getCssDimensions(domElement);\n let x = ($ ? round(rect.width) : rect.width) / width;\n let y = ($ ? round(rect.height) : rect.height) / height;\n\n // 0, NaN, or Infinity should always fallback to 1.\n\n if (!x || !Number.isFinite(x)) {\n x = 1;\n }\n if (!y || !Number.isFinite(y)) {\n y = 1;\n }\n return {\n x,\n y\n };\n}\n\nconst noOffsets = /*#__PURE__*/createCoords(0);\nfunction getVisualOffsets(element) {\n const win = getWindow(element);\n if (!isWebKit() || !win.visualViewport) {\n return noOffsets;\n }\n return {\n x: win.visualViewport.offsetLeft,\n y: win.visualViewport.offsetTop\n };\n}\nfunction shouldAddVisualOffsets(element, isFixed, floatingOffsetParent) {\n if (isFixed === void 0) {\n isFixed = false;\n }\n if (!floatingOffsetParent || isFixed && floatingOffsetParent !== getWindow(element)) {\n return false;\n }\n return isFixed;\n}\n\nfunction getBoundingClientRect(element, includeScale, isFixedStrategy, offsetParent) {\n if (includeScale === void 0) {\n includeScale = false;\n }\n if (isFixedStrategy === void 0) {\n isFixedStrategy = false;\n }\n const clientRect = element.getBoundingClientRect();\n const domElement = unwrapElement(element);\n let scale = createCoords(1);\n if (includeScale) {\n if (offsetParent) {\n if (isElement(offsetParent)) {\n scale = getScale(offsetParent);\n }\n } else {\n scale = getScale(element);\n }\n }\n const visualOffsets = shouldAddVisualOffsets(domElement, isFixedStrategy, offsetParent) ? getVisualOffsets(domElement) : createCoords(0);\n let x = (clientRect.left + visualOffsets.x) / scale.x;\n let y = (clientRect.top + visualOffsets.y) / scale.y;\n let width = clientRect.width / scale.x;\n let height = clientRect.height / scale.y;\n if (domElement) {\n const win = getWindow(domElement);\n const offsetWin = offsetParent && isElement(offsetParent) ? getWindow(offsetParent) : offsetParent;\n let currentWin = win;\n let currentIFrame = getFrameElement(currentWin);\n while (currentIFrame && offsetParent && offsetWin !== currentWin) {\n const iframeScale = getScale(currentIFrame);\n const iframeRect = currentIFrame.getBoundingClientRect();\n const css = getComputedStyle$1(currentIFrame);\n const left = iframeRect.left + (currentIFrame.clientLeft + parseFloat(css.paddingLeft)) * iframeScale.x;\n const top = iframeRect.top + (currentIFrame.clientTop + parseFloat(css.paddingTop)) * iframeScale.y;\n x *= iframeScale.x;\n y *= iframeScale.y;\n width *= iframeScale.x;\n height *= iframeScale.y;\n x += left;\n y += top;\n currentWin = getWindow(currentIFrame);\n currentIFrame = getFrameElement(currentWin);\n }\n }\n return rectToClientRect({\n width,\n height,\n x,\n y\n });\n}\n\n// If has a CSS width greater than the viewport, then this will be\n// incorrect for RTL.\nfunction getWindowScrollBarX(element, rect) {\n const leftScroll = getNodeScroll(element).scrollLeft;\n if (!rect) {\n return getBoundingClientRect(getDocumentElement(element)).left + leftScroll;\n }\n return rect.left + leftScroll;\n}\n\nfunction getHTMLOffset(documentElement, scroll) {\n const htmlRect = documentElement.getBoundingClientRect();\n const x = htmlRect.left + scroll.scrollLeft - getWindowScrollBarX(documentElement, htmlRect);\n const y = htmlRect.top + scroll.scrollTop;\n return {\n x,\n y\n };\n}\n\nfunction convertOffsetParentRelativeRectToViewportRelativeRect(_ref) {\n let {\n elements,\n rect,\n offsetParent,\n strategy\n } = _ref;\n const isFixed = strategy === 'fixed';\n const documentElement = getDocumentElement(offsetParent);\n const topLayer = elements ? isTopLayer(elements.floating) : false;\n if (offsetParent === documentElement || topLayer && isFixed) {\n return rect;\n }\n let scroll = {\n scrollLeft: 0,\n scrollTop: 0\n };\n let scale = createCoords(1);\n const offsets = createCoords(0);\n const isOffsetParentAnElement = isHTMLElement(offsetParent);\n if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) {\n if (getNodeName(offsetParent) !== 'body' || isOverflowElement(documentElement)) {\n scroll = getNodeScroll(offsetParent);\n }\n if (isHTMLElement(offsetParent)) {\n const offsetRect = getBoundingClientRect(offsetParent);\n scale = getScale(offsetParent);\n offsets.x = offsetRect.x + offsetParent.clientLeft;\n offsets.y = offsetRect.y + offsetParent.clientTop;\n }\n }\n const htmlOffset = documentElement && !isOffsetParentAnElement && !isFixed ? getHTMLOffset(documentElement, scroll) : createCoords(0);\n return {\n width: rect.width * scale.x,\n height: rect.height * scale.y,\n x: rect.x * scale.x - scroll.scrollLeft * scale.x + offsets.x + htmlOffset.x,\n y: rect.y * scale.y - scroll.scrollTop * scale.y + offsets.y + htmlOffset.y\n };\n}\n\nfunction getClientRects(element) {\n return Array.from(element.getClientRects());\n}\n\n// Gets the entire size of the scrollable document area, even extending outside\n// of the `` and `` rect bounds if horizontally scrollable.\nfunction getDocumentRect(element) {\n const html = getDocumentElement(element);\n const scroll = getNodeScroll(element);\n const body = element.ownerDocument.body;\n const width = max(html.scrollWidth, html.clientWidth, body.scrollWidth, body.clientWidth);\n const height = max(html.scrollHeight, html.clientHeight, body.scrollHeight, body.clientHeight);\n let x = -scroll.scrollLeft + getWindowScrollBarX(element);\n const y = -scroll.scrollTop;\n if (getComputedStyle$1(body).direction === 'rtl') {\n x += max(html.clientWidth, body.clientWidth) - width;\n }\n return {\n width,\n height,\n x,\n y\n };\n}\n\n// Safety check: ensure the scrollbar space is reasonable in case this\n// calculation is affected by unusual styles.\n// Most scrollbars leave 15-18px of space.\nconst SCROLLBAR_MAX = 25;\nfunction getViewportRect(element, strategy) {\n const win = getWindow(element);\n const html = getDocumentElement(element);\n const visualViewport = win.visualViewport;\n let width = html.clientWidth;\n let height = html.clientHeight;\n let x = 0;\n let y = 0;\n if (visualViewport) {\n width = visualViewport.width;\n height = visualViewport.height;\n const visualViewportBased = isWebKit();\n if (!visualViewportBased || visualViewportBased && strategy === 'fixed') {\n x = visualViewport.offsetLeft;\n y = visualViewport.offsetTop;\n }\n }\n const windowScrollbarX = getWindowScrollBarX(html);\n // `overflow: hidden` + `scrollbar-gutter: stable` reduces the\n // visual width of the but this is not considered in the size\n // of `html.clientWidth`.\n if (windowScrollbarX <= 0) {\n const doc = html.ownerDocument;\n const body = doc.body;\n const bodyStyles = getComputedStyle(body);\n const bodyMarginInline = doc.compatMode === 'CSS1Compat' ? parseFloat(bodyStyles.marginLeft) + parseFloat(bodyStyles.marginRight) || 0 : 0;\n const clippingStableScrollbarWidth = Math.abs(html.clientWidth - body.clientWidth - bodyMarginInline);\n if (clippingStableScrollbarWidth <= SCROLLBAR_MAX) {\n width -= clippingStableScrollbarWidth;\n }\n } else if (windowScrollbarX <= SCROLLBAR_MAX) {\n // If the scrollbar is on the left, the width needs to be extended\n // by the scrollbar amount so there isn't extra space on the right.\n width += windowScrollbarX;\n }\n return {\n width,\n height,\n x,\n y\n };\n}\n\nconst absoluteOrFixed = /*#__PURE__*/new Set(['absolute', 'fixed']);\n// Returns the inner client rect, subtracting scrollbars if present.\nfunction getInnerBoundingClientRect(element, strategy) {\n const clientRect = getBoundingClientRect(element, true, strategy === 'fixed');\n const top = clientRect.top + element.clientTop;\n const left = clientRect.left + element.clientLeft;\n const scale = isHTMLElement(element) ? getScale(element) : createCoords(1);\n const width = element.clientWidth * scale.x;\n const height = element.clientHeight * scale.y;\n const x = left * scale.x;\n const y = top * scale.y;\n return {\n width,\n height,\n x,\n y\n };\n}\nfunction getClientRectFromClippingAncestor(element, clippingAncestor, strategy) {\n let rect;\n if (clippingAncestor === 'viewport') {\n rect = getViewportRect(element, strategy);\n } else if (clippingAncestor === 'document') {\n rect = getDocumentRect(getDocumentElement(element));\n } else if (isElement(clippingAncestor)) {\n rect = getInnerBoundingClientRect(clippingAncestor, strategy);\n } else {\n const visualOffsets = getVisualOffsets(element);\n rect = {\n x: clippingAncestor.x - visualOffsets.x,\n y: clippingAncestor.y - visualOffsets.y,\n width: clippingAncestor.width,\n height: clippingAncestor.height\n };\n }\n return rectToClientRect(rect);\n}\nfunction hasFixedPositionAncestor(element, stopNode) {\n const parentNode = getParentNode(element);\n if (parentNode === stopNode || !isElement(parentNode) || isLastTraversableNode(parentNode)) {\n return false;\n }\n return getComputedStyle$1(parentNode).position === 'fixed' || hasFixedPositionAncestor(parentNode, stopNode);\n}\n\n// A \"clipping ancestor\" is an `overflow` element with the characteristic of\n// clipping (or hiding) child elements. This returns all clipping ancestors\n// of the given element up the tree.\nfunction getClippingElementAncestors(element, cache) {\n const cachedResult = cache.get(element);\n if (cachedResult) {\n return cachedResult;\n }\n let result = getOverflowAncestors(element, [], false).filter(el => isElement(el) && getNodeName(el) !== 'body');\n let currentContainingBlockComputedStyle = null;\n const elementIsFixed = getComputedStyle$1(element).position === 'fixed';\n let currentNode = elementIsFixed ? getParentNode(element) : element;\n\n // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block\n while (isElement(currentNode) && !isLastTraversableNode(currentNode)) {\n const computedStyle = getComputedStyle$1(currentNode);\n const currentNodeIsContaining = isContainingBlock(currentNode);\n if (!currentNodeIsContaining && computedStyle.position === 'fixed') {\n currentContainingBlockComputedStyle = null;\n }\n const shouldDropCurrentNode = elementIsFixed ? !currentNodeIsContaining && !currentContainingBlockComputedStyle : !currentNodeIsContaining && computedStyle.position === 'static' && !!currentContainingBlockComputedStyle && absoluteOrFixed.has(currentContainingBlockComputedStyle.position) || isOverflowElement(currentNode) && !currentNodeIsContaining && hasFixedPositionAncestor(element, currentNode);\n if (shouldDropCurrentNode) {\n // Drop non-containing blocks.\n result = result.filter(ancestor => ancestor !== currentNode);\n } else {\n // Record last containing block for next iteration.\n currentContainingBlockComputedStyle = computedStyle;\n }\n currentNode = getParentNode(currentNode);\n }\n cache.set(element, result);\n return result;\n}\n\n// Gets the maximum area that the element is visible in due to any number of\n// clipping ancestors.\nfunction getClippingRect(_ref) {\n let {\n element,\n boundary,\n rootBoundary,\n strategy\n } = _ref;\n const elementClippingAncestors = boundary === 'clippingAncestors' ? isTopLayer(element) ? [] : getClippingElementAncestors(element, this._c) : [].concat(boundary);\n const clippingAncestors = [...elementClippingAncestors, rootBoundary];\n const firstClippingAncestor = clippingAncestors[0];\n const clippingRect = clippingAncestors.reduce((accRect, clippingAncestor) => {\n const rect = getClientRectFromClippingAncestor(element, clippingAncestor, strategy);\n accRect.top = max(rect.top, accRect.top);\n accRect.right = min(rect.right, accRect.right);\n accRect.bottom = min(rect.bottom, accRect.bottom);\n accRect.left = max(rect.left, accRect.left);\n return accRect;\n }, getClientRectFromClippingAncestor(element, firstClippingAncestor, strategy));\n return {\n width: clippingRect.right - clippingRect.left,\n height: clippingRect.bottom - clippingRect.top,\n x: clippingRect.left,\n y: clippingRect.top\n };\n}\n\nfunction getDimensions(element) {\n const {\n width,\n height\n } = getCssDimensions(element);\n return {\n width,\n height\n };\n}\n\nfunction getRectRelativeToOffsetParent(element, offsetParent, strategy) {\n const isOffsetParentAnElement = isHTMLElement(offsetParent);\n const documentElement = getDocumentElement(offsetParent);\n const isFixed = strategy === 'fixed';\n const rect = getBoundingClientRect(element, true, isFixed, offsetParent);\n let scroll = {\n scrollLeft: 0,\n scrollTop: 0\n };\n const offsets = createCoords(0);\n\n // If the scrollbar appears on the left (e.g. RTL systems). Use\n // Firefox with layout.scrollbar.side = 3 in about:config to test this.\n function setLeftRTLScrollbarOffset() {\n offsets.x = getWindowScrollBarX(documentElement);\n }\n if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) {\n if (getNodeName(offsetParent) !== 'body' || isOverflowElement(documentElement)) {\n scroll = getNodeScroll(offsetParent);\n }\n if (isOffsetParentAnElement) {\n const offsetRect = getBoundingClientRect(offsetParent, true, isFixed, offsetParent);\n offsets.x = offsetRect.x + offsetParent.clientLeft;\n offsets.y = offsetRect.y + offsetParent.clientTop;\n } else if (documentElement) {\n setLeftRTLScrollbarOffset();\n }\n }\n if (isFixed && !isOffsetParentAnElement && documentElement) {\n setLeftRTLScrollbarOffset();\n }\n const htmlOffset = documentElement && !isOffsetParentAnElement && !isFixed ? getHTMLOffset(documentElement, scroll) : createCoords(0);\n const x = rect.left + scroll.scrollLeft - offsets.x - htmlOffset.x;\n const y = rect.top + scroll.scrollTop - offsets.y - htmlOffset.y;\n return {\n x,\n y,\n width: rect.width,\n height: rect.height\n };\n}\n\nfunction isStaticPositioned(element) {\n return getComputedStyle$1(element).position === 'static';\n}\n\nfunction getTrueOffsetParent(element, polyfill) {\n if (!isHTMLElement(element) || getComputedStyle$1(element).position === 'fixed') {\n return null;\n }\n if (polyfill) {\n return polyfill(element);\n }\n let rawOffsetParent = element.offsetParent;\n\n // Firefox returns the element as the offsetParent if it's non-static,\n // while Chrome and Safari return the element. The element must\n // be used to perform the correct calculations even if the element is\n // non-static.\n if (getDocumentElement(element) === rawOffsetParent) {\n rawOffsetParent = rawOffsetParent.ownerDocument.body;\n }\n return rawOffsetParent;\n}\n\n// Gets the closest ancestor positioned element. Handles some edge cases,\n// such as table ancestors and cross browser bugs.\nfunction getOffsetParent(element, polyfill) {\n const win = getWindow(element);\n if (isTopLayer(element)) {\n return win;\n }\n if (!isHTMLElement(element)) {\n let svgOffsetParent = getParentNode(element);\n while (svgOffsetParent && !isLastTraversableNode(svgOffsetParent)) {\n if (isElement(svgOffsetParent) && !isStaticPositioned(svgOffsetParent)) {\n return svgOffsetParent;\n }\n svgOffsetParent = getParentNode(svgOffsetParent);\n }\n return win;\n }\n let offsetParent = getTrueOffsetParent(element, polyfill);\n while (offsetParent && isTableElement(offsetParent) && isStaticPositioned(offsetParent)) {\n offsetParent = getTrueOffsetParent(offsetParent, polyfill);\n }\n if (offsetParent && isLastTraversableNode(offsetParent) && isStaticPositioned(offsetParent) && !isContainingBlock(offsetParent)) {\n return win;\n }\n return offsetParent || getContainingBlock(element) || win;\n}\n\nconst getElementRects = async function (data) {\n const getOffsetParentFn = this.getOffsetParent || getOffsetParent;\n const getDimensionsFn = this.getDimensions;\n const floatingDimensions = await getDimensionsFn(data.floating);\n return {\n reference: getRectRelativeToOffsetParent(data.reference, await getOffsetParentFn(data.floating), data.strategy),\n floating: {\n x: 0,\n y: 0,\n width: floatingDimensions.width,\n height: floatingDimensions.height\n }\n };\n};\n\nfunction isRTL(element) {\n return getComputedStyle$1(element).direction === 'rtl';\n}\n\nconst platform = {\n convertOffsetParentRelativeRectToViewportRelativeRect,\n getDocumentElement,\n getClippingRect,\n getOffsetParent,\n getElementRects,\n getClientRects,\n getDimensions,\n getScale,\n isElement,\n isRTL\n};\n\nfunction rectsAreEqual(a, b) {\n return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;\n}\n\n// https://samthor.au/2021/observing-dom/\nfunction observeMove(element, onMove) {\n let io = null;\n let timeoutId;\n const root = getDocumentElement(element);\n function cleanup() {\n var _io;\n clearTimeout(timeoutId);\n (_io = io) == null || _io.disconnect();\n io = null;\n }\n function refresh(skip, threshold) {\n if (skip === void 0) {\n skip = false;\n }\n if (threshold === void 0) {\n threshold = 1;\n }\n cleanup();\n const elementRectForRootMargin = element.getBoundingClientRect();\n const {\n left,\n top,\n width,\n height\n } = elementRectForRootMargin;\n if (!skip) {\n onMove();\n }\n if (!width || !height) {\n return;\n }\n const insetTop = floor(top);\n const insetRight = floor(root.clientWidth - (left + width));\n const insetBottom = floor(root.clientHeight - (top + height));\n const insetLeft = floor(left);\n const rootMargin = -insetTop + \"px \" + -insetRight + \"px \" + -insetBottom + \"px \" + -insetLeft + \"px\";\n const options = {\n rootMargin,\n threshold: max(0, min(1, threshold)) || 1\n };\n let isFirstUpdate = true;\n function handleObserve(entries) {\n const ratio = entries[0].intersectionRatio;\n if (ratio !== threshold) {\n if (!isFirstUpdate) {\n return refresh();\n }\n if (!ratio) {\n // If the reference is clipped, the ratio is 0. Throttle the refresh\n // to prevent an infinite loop of updates.\n timeoutId = setTimeout(() => {\n refresh(false, 1e-7);\n }, 1000);\n } else {\n refresh(false, ratio);\n }\n }\n if (ratio === 1 && !rectsAreEqual(elementRectForRootMargin, element.getBoundingClientRect())) {\n // It's possible that even though the ratio is reported as 1, the\n // element is not actually fully within the IntersectionObserver's root\n // area anymore. This can happen under performance constraints. This may\n // be a bug in the browser's IntersectionObserver implementation. To\n // work around this, we compare the element's bounding rect now with\n // what it was at the time we created the IntersectionObserver. If they\n // are not equal then the element moved, so we refresh.\n refresh();\n }\n isFirstUpdate = false;\n }\n\n // Older browsers don't support a `document` as the root and will throw an\n // error.\n try {\n io = new IntersectionObserver(handleObserve, {\n ...options,\n // Handle