From 0ff4b674ac601eb51ec089f818f692044baeede6 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Fri, 10 Jul 2026 10:18:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(sirsoft-basic,=20sirsoft-admin=5Fbasic):=20?= =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=93=9C=EB=A1=9C=EC=96=B4=20?= =?UTF-8?q?=EC=84=A0=ED=98=B8=EC=84=A4=EC=A0=95=20=EC=95=84=EC=BD=94?= =?UTF-8?q?=EB=94=94=EC=96=B8=ED=99=94=20=EB=B0=8F=20=EB=B7=B0=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모바일 헤더 우측 그룹이 420px 를 요구해 폰 뷰포트에서 통화 선택기가 잘렸다. 언어·통화·배송국가를 드로어로 옮겨 가로 칩으로 통일했으나, 이번엔 드로어가 세로로 길어져 정작 메뉴가 스크롤 밖으로 밀렸다. 언어와 통화·배송국가를 각각 독립 아코디언(기본 접힘)으로 바꾸고, 접힘 상태에서도 트리거에 현재 선택값을 요약 표기해 펼치지 않고 확인할 수 있게 했다. 언어는 템플릿이, 통화·배송국가는 이커머스 주입 조각이 각자 상태를 소유해 서로 간섭하지 않는다. 통화 패널은 새 플래그를 만들지 않고 데스크톱 드롭다운의 기존 토글을 재사용한다 — portable 오버라이드에서 if 를 생략하면 엔진이 base if 를 상속하므로 자동으로 기본 접힘이 된다. 이 과정에서 모바일로 펼칠 때 전체 화면 배경 오버레이가 드로어 클릭을 가로채는 결함을 함께 차단했다. 그 밖에 모달 좌우 여백, 주문 취소·일괄변경 팝업의 세로 스택 전환, 은행 관리 입력칸 축소, 쇼핑몰 환경설정·쿠폰·엑셀 다운로드 화면의 가로 넘침을 함께 해소했다. --- .../CHANGELOG.md | 6 + .../frontend/partial/editor.json | 4 +- .../language-pack.json | 2 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 11 +- .../header-currency-selector-admin.json | 128 ++++++- .../header-currency-selector-user.json | 57 +++- ...minHeaderCurrencySelectorPortable.test.tsx | 134 ++++++++ .../adminMobileViewportOverflow.test.tsx | 247 ++++++++++++++ .../layouts/shippingCountryLayouts.test.tsx | 41 ++- .../admin_ecommerce_excel_download_index.json | 45 +-- .../admin/admin_ecommerce_settings.json | 5 +- .../_modal_batch_change_confirm.json | 98 +++++- .../_modal_cancel_order.json | 104 +++++- .../_partial_issue_settings.json | 27 +- .../_bank_management_modal.json | 7 +- .../_tab_basic_info.json | 24 ++ .../_modal_extra_fee_template.json | 3 +- .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 19 ++ .../admin-auth-setlocale-target.test.tsx | 77 +++++ .../admin-base-header-lang-currency.test.tsx | 158 +++++++-- .../admin-mobile-viewport-css.test.tsx | 59 ++++ .../editor-spec-sidebar-open-state.test.tsx | 67 ++++ .../dist/css/components.css | 2 +- .../dist/js/components.iife.js | 116 +++---- .../dist/js/components.iife.js.map | 2 +- .../dist/src/handlers/index.d.ts | 2 - .../dist/src/handlers/setLocaleHandler.d.ts | 16 - .../editor-spec/states.json | 23 ++ .../lang/partial/en/editor.json | 2 + .../lang/partial/ko/editor.json | 2 + .../layouts/_admin_base.json | 150 ++++++-- .../layouts/admin_forgot_password.json | 4 +- .../layouts/admin_login.json | 4 +- .../layouts/admin_reset_password.json | 4 +- .../sirsoft-admin_basic/package-lock.json | 4 +- .../_bundled/sirsoft-admin_basic/package.json | 2 +- .../src/components/composite/HtmlEditor.tsx | 3 +- .../composite/MultilingualInput.tsx | 3 +- .../__tests__/setLocaleHandler.test.ts | 286 ---------------- .../sirsoft-admin_basic/src/handlers/index.ts | 5 +- .../src/handlers/setLocaleHandler.ts | 70 ---- .../sirsoft-admin_basic/src/styles/main.css | 21 +- .../sirsoft-admin_basic/template.json | 2 +- .../fixtures/admin-template-auth.ts | 5 + .../admin/mobile-viewport-overflow.spec.ts | 323 ++++++++++++++++++ templates/_bundled/sirsoft-basic/CHANGELOG.md | 1 + .../layouts/currencySelector.test.tsx | 124 ++++++- .../sirsoft-basic/layouts/_user_base.json | 66 +++- .../specs/mobile-viewport-overflow.spec.ts | 92 ++++- 49 files changed, 2048 insertions(+), 609 deletions(-) create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminHeaderCurrencySelectorPortable.test.tsx create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminMobileViewportOverflow.test.tsx create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-auth-setlocale-target.test.tsx create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-mobile-viewport-css.test.tsx create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/editor-spec-sidebar-open-state.test.tsx delete mode 100644 templates/_bundled/sirsoft-admin_basic/dist/src/handlers/setLocaleHandler.d.ts delete mode 100644 templates/_bundled/sirsoft-admin_basic/src/handlers/__tests__/setLocaleHandler.test.ts delete mode 100644 templates/_bundled/sirsoft-admin_basic/src/handlers/setLocaleHandler.ts create mode 100644 templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/admin/mobile-viewport-overflow.spec.ts 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