fix(sirsoft-basic, sirsoft-admin_basic): 모바일 드로어 선호설정 아코디언화 및 뷰포트 개선
모바일 헤더 우측 그룹이 420px 를 요구해 폰 뷰포트에서 통화 선택기가 잘렸다. 언어·통화·배송국가를 드로어로 옮겨 가로 칩으로 통일했으나, 이번엔 드로어가 세로로 길어져 정작 메뉴가 스크롤 밖으로 밀렸다. 언어와 통화·배송국가를 각각 독립 아코디언(기본 접힘)으로 바꾸고, 접힘 상태에서도 트리거에 현재 선택값을 요약 표기해 펼치지 않고 확인할 수 있게 했다. 언어는 템플릿이, 통화·배송국가는 이커머스 주입 조각이 각자 상태를 소유해 서로 간섭하지 않는다. 통화 패널은 새 플래그를 만들지 않고 데스크톱 드롭다운의 기존 토글을 재사용한다 — portable 오버라이드에서 if 를 생략하면 엔진이 base if 를 상속하므로 자동으로 기본 접힘이 된다. 이 과정에서 모바일로 펼칠 때 전체 화면 배경 오버레이가 드로어 클릭을 가로채는 결함을 함께 차단했다. 그 밖에 모달 좌우 여백, 주문 취소·일괄변경 팝업의 세로 스택 전환, 은행 관리 입력칸 축소, 쇼핑몰 환경설정·쿠폰·엑셀 다운로드 화면의 가로 넘침을 함께 해소했다.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": "すべて",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- 휴대폰·태블릿 화면에서 통화·배송국가 선택이 드롭다운 대신 항상 펼쳐진 가로 버튼으로 표시됩니다. 좁은 화면의 메뉴 안에서 드롭다운이 잘려 보이던 문제가 함께 해소됩니다. 데스크톱 화면의 드롭다운은 그대로입니다.
|
||||
- 휴대폰·태블릿 화면에서 통화·배송국가 선택이 메뉴 안의 접었다 펼치는 항목으로 바뀌었습니다. 접혀 있을 때도 현재 선택된 통화와 배송국가가 함께 표시되어 펼치지 않아도 확인할 수 있고, 펼치면 가로 버튼으로 나열됩니다. 좁은 화면의 메뉴 안에서 드롭다운이 잘려 보이던 문제가 함께 해소됩니다. 데스크톱 화면의 드롭다운은 그대로입니다. 관리자 화면도 동일하게 적용됩니다.
|
||||
- 휴대폰 화면에서 주문 취소·주문상태 일괄변경 팝업의 상품 목록이 표 대신 상품별 카드 형태로 표시됩니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 쇼핑몰 환경설정의 은행 관리에서 은행코드를 입력해도 입력한 값이 보이지 않던 문제를 수정했습니다 — 좁은 화면에서 입력칸이 글자가 들어갈 수 없을 만큼 줄어들고 있었습니다.
|
||||
- 휴대폰 화면에서 주문 취소·주문상태 일괄변경 팝업의 금액이 "249,000원" 대신 "249,000" / "원" 처럼 숫자 중간에서 잘려 보이던 문제를 수정했습니다. 금액은 앞으로도 숫자 중간에서 줄바꿈되지 않으며, "결제완료" 같은 상태 이름도 "결제완" / "료" 로 쪼개지지 않습니다.
|
||||
- 휴대폰 화면에서 쇼핑몰 환경설정 화면이 좌우로 넘쳐 가로 스크롤이 생기던 문제를 수정했습니다 (이메일 입력 줄, 쇼핑몰 이름·종목 입력칸, 탭 영역).
|
||||
- 휴대폰 화면에서 쿠폰 발급기간 입력칸과 엑셀 다운로드 기간 검색 입력칸이 화면 밖으로 넘치던 문제를 수정했습니다.
|
||||
- 은행 관리·도서산간 배송비 팝업의 표가 좁은 화면에서 가로로 스크롤되도록 명시해, 앞으로 표가 잘려 보이지 않도록 했습니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
|
||||
+125
-3
@@ -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": [
|
||||
{
|
||||
|
||||
+51
-6
@@ -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": [
|
||||
{
|
||||
|
||||
+134
@@ -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');
|
||||
});
|
||||
});
|
||||
+247
@@ -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');
|
||||
});
|
||||
});
|
||||
+35
-6
@@ -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)에서는 통화·배송국가를 세로 목록이 아니라 가로 나열 칩으로 그린다.
|
||||
* 세로 목록은 좁은 드로어에서 세로 공간을 과하게 먹는다.
|
||||
|
||||
+23
-22
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+83
-15
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+88
-16
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+26
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+4
-3
@@ -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": [
|
||||
|
||||
+24
@@ -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",
|
||||
|
||||
+2
-1
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file admin-auth-setlocale-target.test.tsx
|
||||
* @description 비인증 화면(로그인·비밀번호찾기·비밀번호재설정) 언어 선택기의 setLocale 액션 형태 회귀 테스트
|
||||
*
|
||||
* 배경: 관리자 템플릿은 자체 `setLocaleHandler` 를 등록해 엔진 빌트인 `setLocale`(ActionDispatcher)
|
||||
* 을 덮어쓰고 있었다. 그 사본에는 두 결함이 있었다.
|
||||
* ① `<button>` 의 `value` 는 항상 `''` 인데 `!== undefined && !== null` 로만 걸러 빈 문자열을
|
||||
* 로케일로 채택 → 이후 검증에서 조기 반환(드로어 언어 칩이 눌러도 무반응).
|
||||
* ② `G7Core.config` 는 존재하지 않고, `state.get('_global.appConfig.supportedLocales')` 는
|
||||
* `_global` 객체 전체를 반환해 `validLocales.includes` 가 TypeError.
|
||||
*
|
||||
* 조치: 사용자 템플릿(`sirsoft-basic`)과 동일하게 템플릿 사본을 제거하고 엔진 빌트인을 쓴다.
|
||||
* 엔진 빌트인은 **`action.target` 만** 읽으므로(`params.locale` 은 무시), 세 화면의 Select 액션도
|
||||
* `params` → `target` 으로 옮겨야 한다. `Select` 컴포넌트는 네이티브 `<select>` 가 아니라 커스텀
|
||||
* 위젯이지만 `{target:{value}}` 형태의 합성 이벤트를 발생시키므로 `{{$event.target.value}}` 가
|
||||
* 그대로 해석된다(브라우저 실측 확인).
|
||||
*
|
||||
* 이 테스트를 `params.locale` 로 되돌리면 세 화면의 언어 전환이 조용히 죽는다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const baseDir = path.resolve(__dirname, '../..');
|
||||
|
||||
function loadJson(relPath: string): any {
|
||||
return JSON.parse(fs.readFileSync(path.resolve(baseDir, relPath), 'utf8'));
|
||||
}
|
||||
|
||||
/** 레이아웃 전체에서 handler === 'setLocale' 인 액션을 모두 수집한다. */
|
||||
function collectSetLocaleActions(node: any, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
if (Array.isArray(node)) {
|
||||
for (const n of node) collectSetLocaleActions(n, acc);
|
||||
return acc;
|
||||
}
|
||||
for (const action of node.actions ?? []) {
|
||||
if (action?.handler === 'setLocale') acc.push(action);
|
||||
}
|
||||
for (const k of ['children', 'components']) {
|
||||
if (node[k]) collectSetLocaleActions(node[k], acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const layouts: Array<[string, string]> = [
|
||||
['admin_login', 'layouts/admin_login.json'],
|
||||
['admin_forgot_password', 'layouts/admin_forgot_password.json'],
|
||||
['admin_reset_password', 'layouts/admin_reset_password.json'],
|
||||
];
|
||||
|
||||
describe('비인증 화면 언어 선택기 — 엔진 빌트인 setLocale 규약', () => {
|
||||
it.each(layouts)('%s 의 setLocale 은 target 으로 로케일을 넘긴다', (_name, relPath) => {
|
||||
const layout = loadJson(relPath);
|
||||
const actions = collectSetLocaleActions(layout.components ?? layout);
|
||||
|
||||
expect(actions.length).toBe(1);
|
||||
const [action] = actions;
|
||||
|
||||
expect(action.type).toBe('change');
|
||||
expect(action.target).toBe('{{$event.target.value}}');
|
||||
// 엔진 빌트인은 params 를 읽지 않는다. 남아 있으면 무시되어 언어 전환이 죽는다.
|
||||
expect(action.params).toBeUndefined();
|
||||
});
|
||||
|
||||
it('템플릿은 setLocale 핸들러를 자체 등록하지 않는다 (엔진 빌트인 사용)', () => {
|
||||
const indexTs = fs.readFileSync(
|
||||
path.resolve(baseDir, 'src/handlers/index.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(indexTs).not.toContain('setLocaleHandler');
|
||||
expect(fs.existsSync(path.resolve(baseDir, 'src/handlers/setLocaleHandler.ts'))).toBe(false);
|
||||
});
|
||||
});
|
||||
+133
-25
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* @file admin-base-header-lang-currency.test.tsx
|
||||
* @description 관리자 헤더 언어·통화 선택기 패리티 회귀 테스트
|
||||
* @description 관리자 언어·통화·배송국가 선택기 배치 회귀 테스트
|
||||
*
|
||||
* 배경(검수 지적):
|
||||
* - 관리자 데스크톱 언어 버튼이 globe 아이콘만 표시하고 현재 선택 언어를 안 보여줌(유저는 코드 표시).
|
||||
* → LanguageSelector 에 showCode 도입, _admin_base 가 showCode:true 로 사용.
|
||||
* - 관리자 모바일 헤더에 언어·통화 버튼이 둘 다 없음(데스크톱에만 존재).
|
||||
* → mobile_header_right 에 language_selector_mobile + header_currency_slot_mobile 추가.
|
||||
* 배경 (구 정본): 4표면(유저·관리자 × 데스크톱·모바일) 모두 언어+통화가 헤더 같은 줄에 노출.
|
||||
*
|
||||
* 합의 정본: 4표면(유저·관리자 × 데스크톱·모바일) 모두 언어+통화가 같은 줄에 현재값과 함께 노출.
|
||||
* 반전 사유 (현 정본): 모바일 헤더 우측 그룹은 `flex-shrink-0` 이라 좁아져도 폭을 유지한 채
|
||||
* 화면 밖으로 밀려난다. theme(36) + noti(36) + lang(79) + 통화(112) + gap = **420px 를 요구**해
|
||||
* 390px 에서 30px, 320px 에서 100px 이 잘렸다(통화 셀렉터 단독이 클립 전량의 원인 — 통화만
|
||||
* 숨기면 clip 0). 따라서 언어·통화·배송국가를 모두 모바일 드로어(`admin_drawer_prefs`)로 옮겨
|
||||
* 사용자 템플릿(`_user_base` 의 `mobile_drawer_prefs`)과 동일한 가로 칩 목록으로 통일했다.
|
||||
*
|
||||
* → 모바일 헤더에는 theme·notification 만 남는다. 이 테스트를 "패리티 회복"이라는 이유로
|
||||
* 되돌리면 폰 뷰포트 클립이 재발한다. 데스크톱(≥1024px)은 기존 헤더 배치를 유지한다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
@@ -31,7 +34,22 @@ function find(node: any, id: string): any {
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('관리자 헤더 언어·통화 패리티', () => {
|
||||
/**
|
||||
* 술어를 만족하는 첫 노드를 재귀 탐색합니다 (위치 인덱스/직계 자식 가정 회피).
|
||||
*
|
||||
* @param node 탐색 시작 노드/배열
|
||||
* @param pred 노드 술어
|
||||
* @return 찾은 노드 (없으면 null)
|
||||
*/
|
||||
function walk(node: any, pred: (n: any) => boolean): any {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (Array.isArray(node)) { for (const n of node) { const r = walk(n, pred); if (r) return r; } return null; }
|
||||
if (pred(node)) return node;
|
||||
for (const k of ['children', 'components']) { if (node[k]) { const r = walk(node[k], pred); if (r) return r; } }
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('관리자 헤더 언어·통화 배치', () => {
|
||||
const adminBase = loadJson('layouts/_admin_base.json');
|
||||
|
||||
it('데스크톱 언어 선택기는 showCode:true 로 현재 로케일 코드를 표시한다', () => {
|
||||
@@ -41,25 +59,115 @@ describe('관리자 헤더 언어·통화 패리티', () => {
|
||||
expect(node.props?.showCode).toBe(true);
|
||||
});
|
||||
|
||||
it('모바일 헤더에 언어 선택기 + 통화 슬롯이 같은 줄에 노출된다', () => {
|
||||
const mobileRight = find(adminBase.components, 'mobile_header_right');
|
||||
expect(mobileRight).toBeTruthy();
|
||||
const ids = (mobileRight.children ?? []).map((c: any) => c.id);
|
||||
expect(ids).toContain('language_selector_mobile');
|
||||
expect(ids).toContain('header_currency_slot_mobile');
|
||||
});
|
||||
|
||||
it('모바일 언어 선택기도 showCode:true (데스크톱과 동일 표기)', () => {
|
||||
const node = find(adminBase.components, 'language_selector_mobile');
|
||||
expect(node).toBeTruthy();
|
||||
expect(node.name).toBe('LanguageSelector');
|
||||
expect(node.props?.showCode).toBe(true);
|
||||
});
|
||||
|
||||
it('모바일 통화 슬롯은 데스크톱과 동일한 header_currency 슬롯을 공유한다', () => {
|
||||
const node = find(adminBase.components, 'header_currency_slot_mobile');
|
||||
it('데스크톱 헤더는 통화 슬롯을 계속 보유한다 (데스크톱 회귀 방지)', () => {
|
||||
const node = find(adminBase.components, 'header_currency_slot_desktop');
|
||||
expect(node).toBeTruthy();
|
||||
expect(node.name).toBe('SlotContainer');
|
||||
expect(node.props?.slotId).toBe('header_currency');
|
||||
});
|
||||
|
||||
it('모바일 헤더 우측에는 언어·통화가 없다 (420px 요구 → 폰에서 클립되던 원인 제거)', () => {
|
||||
const mobileRight = find(adminBase.components, 'mobile_header_right');
|
||||
expect(mobileRight).toBeTruthy();
|
||||
const ids = (mobileRight.children ?? []).map((c: any) => c.id);
|
||||
expect(ids).not.toContain('language_selector_mobile');
|
||||
expect(ids).not.toContain('header_currency_slot_mobile');
|
||||
// 남아야 하는 것: 테마 토글 + 알림
|
||||
expect(ids).toContain('theme_toggle_mobile');
|
||||
expect(ids).toContain('notification_center_mobile');
|
||||
});
|
||||
|
||||
it('모바일 드로어 선호설정 블록은 portable 에서만 렌더된다', () => {
|
||||
const prefs = find(adminBase.components, 'admin_drawer_prefs');
|
||||
expect(prefs).toBeTruthy();
|
||||
// 데스크톱에서는 미렌더 (드로어에 통화가 중복 노출되면 회귀)
|
||||
expect(prefs.if).toBe('{{false}}');
|
||||
expect(prefs.responsive?.portable?.if).toBe('{{true}}');
|
||||
});
|
||||
|
||||
it('드로어 언어는 가로 칩이며 iteration 은 Button 자신에 걸린다', () => {
|
||||
const lang = find(adminBase.components, 'admin_drawer_language');
|
||||
expect(lang).toBeTruthy();
|
||||
expect(lang.if).toBe('{{$locales.length > 1}}');
|
||||
|
||||
// 정적 flex-wrap 래퍼 + iteration 을 가진 Button 자식.
|
||||
// 래퍼는 아코디언 펼침 영역 안에 있으므로 직계 자식이 아니라 재귀로 찾는다.
|
||||
const wrapper = walk(lang,
|
||||
(c: any) => typeof c.props?.className === 'string' && c.props.className.includes('flex-wrap'),
|
||||
);
|
||||
expect(wrapper).toBeTruthy();
|
||||
|
||||
const chip = (wrapper.children ?? [])[0];
|
||||
expect(chip?.name).toBe('Button');
|
||||
// 엔진은 iteration 을 가진 노드를 복제한다. flex 컨테이너에 걸면 컨테이너가 복제되어
|
||||
// 칩이 세로로 쌓이므로, 반드시 리프(Button)에 건다.
|
||||
expect(chip?.iteration?.item_var).toBe('loc');
|
||||
expect(wrapper.iteration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('드로어 언어 칩은 엔진 빌트인 setLocale 을 action.target 으로 호출한다', () => {
|
||||
const lang = find(adminBase.components, 'admin_drawer_language');
|
||||
const wrapper = walk(lang,
|
||||
(c: any) => typeof c.props?.className === 'string' && c.props.className.includes('flex-wrap'),
|
||||
);
|
||||
const chip = (wrapper.children ?? [])[0];
|
||||
const action = (chip.actions ?? [])[0];
|
||||
|
||||
expect(action.handler).toBe('setLocale');
|
||||
// 엔진 빌트인(ActionDispatcher)은 action.target 만 읽는다. params.locale 로 주면 무시된다.
|
||||
expect(action.target).toBe('{{loc}}');
|
||||
expect(action.params).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* 접기/펼치기 — 언어·통화·배송국가를 전부 펼쳐 두면 드로어가 세로로 길어져 정작 메뉴가
|
||||
* 스크롤 밖으로 밀린다. 언어와 통화/배송국가는 각각 독립 아코디언이며 기본 접힘이고,
|
||||
* 접힘 상태에서도 트리거에 현재 선택값을 요약 표기한다.
|
||||
*
|
||||
* 언어 = 템플릿 소유(`_global.mobileLanguageOpen`),
|
||||
* 통화/배송국가 = 이커머스 주입 조각 소유(`_local.showCurrencyDropdown`) → 서로 독립.
|
||||
*/
|
||||
it('드로어 언어 섹션은 기본 접힘 + 트리거에 현재 언어를 요약 표기한다', () => {
|
||||
const lang = find(adminBase.components, 'admin_drawer_language');
|
||||
const toggle = find(lang, 'admin_drawer_language_toggle');
|
||||
expect(toggle?.name).toBe('Button');
|
||||
expect(toggle.props['aria-expanded']).toBe('{{_global.mobileLanguageOpen ?? false}}');
|
||||
|
||||
const act = toggle.actions[0];
|
||||
expect(act.handler).toBe('setState');
|
||||
expect(act.params.target).toBe('global');
|
||||
expect(act.params.mobileLanguageOpen).toBe('{{!_global.mobileLanguageOpen}}');
|
||||
|
||||
// 접힘 상태 요약 = 현재 언어명 + chevron
|
||||
const summary = JSON.stringify(toggle.children);
|
||||
expect(summary).toContain('localeNames?.[$locale]');
|
||||
expect(summary).toContain('chevron-down');
|
||||
expect(summary).toContain("{{_global.mobileLanguageOpen ? 'rotate-180' : ''}}");
|
||||
|
||||
// 펼침 영역: falsy(기본) 분기가 닫힘(max-h-0)이어야 기본 접힘이 성립한다
|
||||
const body = walk(lang, (n: any) =>
|
||||
typeof n.props?.className === 'string' &&
|
||||
n.props.className.includes('overflow-hidden') &&
|
||||
n.props.className.includes('mobileLanguageOpen'),
|
||||
);
|
||||
expect(body).toBeTruthy();
|
||||
expect(body.props.className).toContain("'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'");
|
||||
});
|
||||
|
||||
it('언어 토글은 통화 플래그를 건드리지 않는다 (독립 개폐)', () => {
|
||||
const toggle = find(adminBase.components, 'admin_drawer_language_toggle');
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(JSON.stringify(toggle)).not.toContain('showCurrencyDropdown');
|
||||
});
|
||||
|
||||
it('드로어 통화 슬롯은 데스크톱과 동일한 header_currency 슬롯을 공유하고 고유 id 를 갖는다', () => {
|
||||
const node = find(adminBase.components, 'admin_drawer_currency_wrap');
|
||||
expect(node).toBeTruthy();
|
||||
expect(node.name).toBe('SlotContainer');
|
||||
expect(node.props?.slotId).toBe('header_currency');
|
||||
// SlotContainer 는 주입 root id 를 `{id}__{containerId}` 로 스코프하므로 id 가 필수다.
|
||||
expect(node.id).toBe('admin_drawer_currency_wrap');
|
||||
// 헤더의 `flex items-center` 를 복사하면 주입 루트가 콘텐츠 폭으로 줄어든다.
|
||||
expect(node.props?.className).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file admin-mobile-viewport-css.test.tsx
|
||||
* @description 관리자 모바일 뷰포트 넘침 회귀 테스트 (템플릿 소유분: CSS + 컴포넌트)
|
||||
*
|
||||
* 브라우저 실측(390px / 320px)으로 확인된 결함에 1:1 대응한다.
|
||||
*
|
||||
* jsdom 은 레이아웃을 계산하지 않으므로 폭 자체는 검증할 수 없다.
|
||||
* 여기서는 "규칙/클래스가 선언되어 있는가" 만 단언하고, 실제 폭은 Playwright E2E
|
||||
* (tests/Playwright/specs/admin/mobile-viewport-overflow.spec.ts) 가 검증한다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const baseDir = path.resolve(__dirname, '../..');
|
||||
const read = (rel: string) => fs.readFileSync(path.resolve(baseDir, rel), 'utf8');
|
||||
|
||||
describe('B1 — .modal-container 뷰포트 clamp', () => {
|
||||
const css = read('src/styles/main.css');
|
||||
|
||||
it('max-w-[calc(100vw-2rem)] 로 좌우 16px 여백을 확보한다', () => {
|
||||
const rule = css.match(/\.modal-container\s*\{[\s\S]*?\}/)?.[0] ?? '';
|
||||
expect(rule).toContain('max-w-[calc(100vw-2rem)]');
|
||||
});
|
||||
|
||||
it('선언 순서 트랩 경고가 주석으로 남아 있다', () => {
|
||||
// .max-w-full 은 명시도가 같고(0,1,0) 빌드 CSS 에서 뒤에 정의되어 이 clamp 를 이긴다.
|
||||
// 이 사실을 모르면 레이아웃에 max-w-full 을 붙여 clamp 를 무효화하기 쉽다.
|
||||
expect(css).toContain('max-w-full');
|
||||
expect(css).toMatch(/명시도|승리/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C4 — .pagination 줄바꿈', () => {
|
||||
const css = read('src/styles/main.css');
|
||||
|
||||
it('flex-wrap 이 있어 페이지 수가 많아도 넘치지 않는다', () => {
|
||||
const rule = css.match(/\.pagination\s*\{[\s\S]*?\}/)?.[0] ?? '';
|
||||
expect(rule).toContain('flex-wrap');
|
||||
// 공통 컴포넌트라 이 한 줄로 주문상세(27px)·상품수정(29px) 두 지점이 동시에 해소된다.
|
||||
expect(rule).toContain('justify-center');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C1 — 다국어 로케일 탭 줄바꿈', () => {
|
||||
it.each([
|
||||
['MultilingualInput.tsx', 'src/components/composite/MultilingualInput.tsx'],
|
||||
['HtmlEditor.tsx', 'src/components/composite/HtmlEditor.tsx'],
|
||||
])('%s 의 로케일 탭 행이 flex-wrap 을 갖는다', (_name, rel) => {
|
||||
const src = read(rel);
|
||||
// 로케일 3개 이상(ko/en/ja) + md 미만 뷰포트에서 탭 행이 컨테이너를 넘친다.
|
||||
// 이 두 컴포넌트는 40개 레이아웃에서 쓰이므로 여기서 고치면 전 화면이 해소된다.
|
||||
expect(src).toContain('flex flex-wrap items-center gap-2 mb-3');
|
||||
expect(src).not.toContain('"flex items-center gap-2 mb-3"');
|
||||
});
|
||||
});
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file editor-spec-sidebar-open-state.test.tsx
|
||||
* @description 편집기 캔버스에서 모바일 드로어를 펼쳐 편집할 수 있는지 (editor-spec states)
|
||||
*
|
||||
* 배경: 언어·통화·배송국가 칩은 모바일 드로어(`admin_drawer_prefs`) 안에 있고, 드로어는
|
||||
* `_global.sidebarOpen` 으로 열린다. 편집기 캔버스는 정적 시뮬레이션이라 햄버거 클릭이 없으므로,
|
||||
* `sidebarOpen` 을 페이지 상태로 주입하지 않으면 드로어 내부를 영영 편집할 수 없다.
|
||||
*
|
||||
* `_admin_base` 는 라우트가 아니라 베이스 레이아웃이므로 `scope.kind: 'base'` 로 매칭해야 한다
|
||||
* (`LayoutEditorChrome` 이 `__base__/{layoutName}` → `{kind:'base', match:layoutName}` 로 도출).
|
||||
* 사용자 템플릿 `_user_base` 의 `mobile_menu_open` 과 동형.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const baseDir = path.resolve(__dirname, '../..');
|
||||
const states = JSON.parse(
|
||||
fs.readFileSync(path.resolve(baseDir, 'editor-spec/states.json'), 'utf8'),
|
||||
);
|
||||
|
||||
/** `matchStateItems` 와 동일한 매칭 규칙 (kind + match 완전 일치). */
|
||||
function matchGroup(kind: string, match: string) {
|
||||
return (states.groups ?? []).filter(
|
||||
(g: any) => g.scope?.kind === kind && g.scope?.match === match,
|
||||
);
|
||||
}
|
||||
|
||||
describe('editor-spec states — _admin_base 드로어 상태', () => {
|
||||
it('_admin_base 에 base scope 상태 그룹이 있다', () => {
|
||||
const groups = matchGroup('base', '_admin_base');
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sidebar_open 상태가 _global.sidebarOpen 을 true 로 주입한다', () => {
|
||||
const [group] = matchGroup('base', '_admin_base');
|
||||
const item = (group.items ?? []).find((i: any) => i.id === 'sidebar_open');
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
// 드로어 게이트는 `_admin_base.json` 의 `left_sidebar_area_portable_open` → `_global.sidebarOpen`.
|
||||
expect(item.initialState?.global?.sidebarOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('기본 상태(default)가 존재해 드로어 닫힌 화면도 미리본다', () => {
|
||||
const [group] = matchGroup('base', '_admin_base');
|
||||
const def = (group.items ?? []).find((i: any) => i.default === true);
|
||||
expect(def?.id).toBe('default');
|
||||
// 기본 상태는 sidebarOpen 을 주입하지 않는다(닫힌 드로어).
|
||||
expect(def.initialState?.global?.sidebarOpen).toBeUndefined();
|
||||
});
|
||||
|
||||
it('모든 상태 라벨이 $t: 다국어 키이고 ko/en 에 정의되어 있다', () => {
|
||||
const [group] = matchGroup('base', '_admin_base');
|
||||
const ko = JSON.parse(fs.readFileSync(path.resolve(baseDir, 'lang/partial/ko/editor.json'), 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync(path.resolve(baseDir, 'lang/partial/en/editor.json'), 'utf8'));
|
||||
|
||||
for (const item of group.items ?? []) {
|
||||
expect(item.label).toMatch(/^\$t:editor\.state\./);
|
||||
const key = item.label.replace('$t:editor.state.', '');
|
||||
expect(ko.state?.[key], `ko editor.state.${key}`).toBeTruthy();
|
||||
expect(en.state?.[key], `en editor.state.${key}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,3 @@
|
||||
import { setLocaleHandler } from './setLocaleHandler';
|
||||
import { setThemeHandler, initThemeHandler } from './setThemeHandler';
|
||||
import { scrollToSectionHandler } from './scrollToSectionHandler';
|
||||
import { initMenuFromUrlHandler } from './initMenuFromUrlHandler';
|
||||
@@ -16,7 +15,6 @@ import { downloadAttachmentHandler } from './downloadAttachment';
|
||||
* 새로운 핸들러 추가 시 여기에만 등록하면 자동으로 ActionDispatcher에 등록됩니다.
|
||||
*/
|
||||
export declare const handlerMap: {
|
||||
readonly setLocale: typeof setLocaleHandler;
|
||||
readonly setTheme: typeof setThemeHandler;
|
||||
readonly initTheme: typeof initThemeHandler;
|
||||
readonly scrollToSection: typeof scrollToSectionHandler;
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* setLocale 핸들러
|
||||
*
|
||||
* 사용자가 선택한 언어로 변경합니다.
|
||||
* localStorage에 g7_locale 값을 저장하고 페이지를 새로고침합니다.
|
||||
*/
|
||||
/**
|
||||
* 언어 변경 핸들러
|
||||
*
|
||||
* ActionDispatcher는 handler(action, context) 형태로 호출합니다.
|
||||
* context 객체에는 { data, event, props, state, setState, navigate } 등이 포함됩니다.
|
||||
*
|
||||
* @param action 액션 정의
|
||||
* @param context 액션 컨텍스트 (ActionContext 타입)
|
||||
*/
|
||||
export declare function setLocaleHandler(action: any, context?: any): Promise<void>;
|
||||
@@ -1,6 +1,29 @@
|
||||
{
|
||||
"comment": "admin 목록/폼 상태 세트. with_data/empty_data/loading + edit_existing/validation_failed/saving.",
|
||||
"groups": [
|
||||
{
|
||||
"comment": "베이스 레이아웃 _admin_base. 모바일 드로어(left_sidebar_area)는 _global.sidebarOpen 으로 열리고, 그 안의 언어/통화/배송국가 칩(admin_drawer_prefs)은 responsive.portable 게이트라 디바이스=모바일 + 드로어 열림 상태에서만 캔버스에 보인다. 편집기 캔버스는 정적 시뮬레이션이라 햄버거 클릭이 없으므로 sidebarOpen 을 상태로 주입해야 드로어 내부를 편집할 수 있다.",
|
||||
"scope": {
|
||||
"kind": "base",
|
||||
"match": "_admin_base"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "default",
|
||||
"label": "$t:editor.state.admin_base_default",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"id": "sidebar_open",
|
||||
"label": "$t:editor.state.sidebar_open",
|
||||
"initialState": {
|
||||
"global": {
|
||||
"sidebarOpen": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
|
||||
@@ -1408,6 +1408,8 @@
|
||||
"param_field_b": "Second value path"
|
||||
},
|
||||
"state": {
|
||||
"admin_base_default": "Default",
|
||||
"sidebar_open": "Mobile menu open",
|
||||
"with_data": "Has data",
|
||||
"empty_data": "No data",
|
||||
"edit_existing": "Edit existing",
|
||||
|
||||
@@ -1408,6 +1408,8 @@
|
||||
"param_field_b": "두 번째 값 경로"
|
||||
},
|
||||
"state": {
|
||||
"admin_base_default": "기본",
|
||||
"sidebar_open": "모바일 메뉴 펼침",
|
||||
"with_data": "데이터 있음",
|
||||
"empty_data": "데이터 없음",
|
||||
"edit_existing": "기존 수정",
|
||||
|
||||
@@ -241,6 +241,135 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "언어/통화/배송국가 선택 — 모바일 전용. 헤더 우측 그룹에서 이동(모바일 헤더가 420px 를 요구해 폰 뷰포트에서 통화 선택기가 화면 밖으로 잘렸다). 사용자 템플릿 _user_base 의 mobile_drawer_prefs 와 동일하게 셋 다 가로 나열 칩으로 통일. 통화/배송국가는 이커머스 모듈이 'header_currency' 슬롯에 주입하므로 SlotContainer 만 배치(모듈 비활성 시 자동 미렌더). 스크롤 영역(admin_sidebar_menu) 바깥 최상단에 둔다.",
|
||||
"id": "admin_drawer_prefs",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{false}}",
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"if": "{{true}}"
|
||||
}
|
||||
},
|
||||
"props": {
|
||||
"className": "px-4 py-3 border-b border-gray-200 dark:border-gray-700 space-y-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "언어 선택 (여러 언어 지원 시) — 헤더 LanguageSelector 드롭다운에서 가로 칩으로 전환. 접기/펼치기: 접힘이 기본이고 트리거에 현재 언어를 요약 표기한다. 통화/배송국가(이커머스 주입 슬롯)와는 독립된 토글(_global.mobileLanguageOpen)이라 서로 영향 없음.",
|
||||
"id": "admin_drawer_language",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{$locales.length > 1}}",
|
||||
"children": [
|
||||
{
|
||||
"comment": "트리거 — 좌측 '언어' 라벨, 우측 현재 언어명 + chevron. 접힘 상태에서도 현재 값을 알 수 있어야 하므로 요약을 항상 노출한다.",
|
||||
"id": "admin_drawer_language_toggle",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "w-full flex items-center justify-between py-1 cursor-pointer",
|
||||
"aria-expanded": "{{_global.mobileLanguageOpen ?? false}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"mobileLanguageOpen": "{{!_global.mobileLanguageOpen}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
|
||||
},
|
||||
"text": "$t:admin.language"
|
||||
},
|
||||
{
|
||||
"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.appConfig?.localeNames?.[$locale] ?? $locale}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "chevron-down",
|
||||
"className": "{{_global.mobileLanguageOpen ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "펼침 영역 — max-h/opacity 트랜지션. overflow-hidden 이라 접힘 시 칩이 잘려 사라진다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "overflow-hidden transition-all duration-300 ease-in-out {{_global.mobileLanguageOpen ? 'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "가로 나열 칩. iteration 은 반드시 Button 자신에 건다 — 엔진은 iteration 을 가진 노드를 항목 수만큼 복제하므로, flex 컨테이너에 걸면 컨테이너가 복제되어 칩이 세로로 쌓인다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex flex-wrap gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"iteration": { "source": "{{$locales}}", "item_var": "loc" },
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "{{loc === $locale ? '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'}} inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap",
|
||||
"role": "option",
|
||||
"aria-selected": "{{loc === $locale}}"
|
||||
},
|
||||
"actions": [
|
||||
{ "type": "click", "handler": "setLocale", "target": "{{loc}}" }
|
||||
],
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "globe", "size": "sm" } },
|
||||
{ "type": "basic", "name": "Span", "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "통화/배송국가 선택 — 이커머스 모듈 주입 슬롯. id 필수: SlotContainer 는 주입 컴포넌트 root id 를 {id}__{containerId} 로 스코프한다. className 은 block(헤더의 flex items-center 를 쓰면 주입 루트가 콘텐츠 폭으로 줄어든다). 주입 루트가 portable 에서 스스로 w-full 을 갖는다.",
|
||||
"id": "admin_drawer_currency_wrap",
|
||||
"type": "composite",
|
||||
"name": "SlotContainer",
|
||||
"props": {
|
||||
"slotId": "header_currency",
|
||||
"className": "block"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin_sidebar_menu",
|
||||
"type": "basic",
|
||||
@@ -1117,27 +1246,6 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "언어 선택(모바일) — 데스크톱과 동일하게 헤더에 노출(유저 템플릿 모바일과 패리티). showCode 로 현재 로케일 코드 표시.",
|
||||
"id": "language_selector_mobile",
|
||||
"type": "composite",
|
||||
"name": "LanguageSelector",
|
||||
"props": {
|
||||
"availableLocales": "{{_global.appConfig.supportedLocales ?? $locales}}",
|
||||
"languageText": "$t:admin.language",
|
||||
"showCode": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "통화 선택(모바일) — 이커머스 모듈이 'header_currency' 슬롯에 주입. 데스크톱 SlotContainer 와 동일 슬롯 공유(언어와 같은 줄, 유저 모바일과 패리티). 모듈 비활성 시 빈 슬롯.",
|
||||
"id": "header_currency_slot_mobile",
|
||||
"type": "composite",
|
||||
"name": "SlotContainer",
|
||||
"props": {
|
||||
"slotId": "header_currency",
|
||||
"className": "flex items-center"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -336,9 +336,7 @@
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"locale": "{{$event.target.value}}"
|
||||
}
|
||||
"target": "{{$event.target.value}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -380,9 +380,7 @@
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"locale": "{{$event.target.value}}"
|
||||
}
|
||||
"target": "{{$event.target.value}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -478,9 +478,7 @@
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"locale": "{{$event.target.value}}"
|
||||
}
|
||||
"target": "{{$event.target.value}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sirsoft-admin_basic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sirsoft-admin_basic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sirsoft-admin_basic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Gnuboard7 Basic Admin Template Components",
|
||||
"type": "module",
|
||||
"main": "dist/components.js",
|
||||
|
||||
@@ -297,8 +297,9 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
|
||||
}, [localMultilingualContent]);
|
||||
|
||||
// 다국어 탭 렌더링
|
||||
// flex-wrap: 로케일이 3개 이상이면 md 미만 뷰포트에서 탭 행이 컨테이너를 넘친다.
|
||||
const renderLocaleTabs = () => (
|
||||
<Div className="flex items-center gap-2 mb-3">
|
||||
<Div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{supportedLocales.map(localeCode => {
|
||||
const isDefault = localeCode === defaultLocale;
|
||||
const isActive = currentLocale === localeCode;
|
||||
|
||||
+2
-1
@@ -485,7 +485,8 @@ export const MultilingualInput: React.FC<MultilingualInputProps> = ({
|
||||
const renderTabsLayout = () => (
|
||||
<Div className={className}>
|
||||
{/* 언어 탭들 + 추가 버튼 (Pill Badge 스타일) */}
|
||||
<Div className="flex items-center gap-2 mb-3">
|
||||
{/* flex-wrap: 로케일이 3개 이상이면 md 미만 뷰포트에서 탭 행이 컨테이너를 넘친다. */}
|
||||
<Div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{activeLocales.map(localeCode => (
|
||||
<Button
|
||||
key={localeCode}
|
||||
|
||||
-286
@@ -1,286 +0,0 @@
|
||||
/**
|
||||
* setLocaleHandler 테스트
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { setLocaleHandler } from '../setLocaleHandler';
|
||||
|
||||
describe('setLocaleHandler', () => {
|
||||
// localStorage mock
|
||||
let localStorageMock: { [key: string]: string } = {};
|
||||
let setItemSpy: ReturnType<typeof vi.spyOn>;
|
||||
let reloadSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// localStorage mock 설정
|
||||
localStorageMock = {};
|
||||
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => localStorageMock[key] || null);
|
||||
setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => {
|
||||
localStorageMock[key] = value;
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => {
|
||||
delete localStorageMock[key];
|
||||
});
|
||||
|
||||
// window.location.reload mock
|
||||
reloadSpy = vi.fn();
|
||||
delete (window as any).location;
|
||||
(window as any).location = { reload: reloadSpy };
|
||||
|
||||
// console 스파이 설정
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('정상 동작', () => {
|
||||
it('이벤트 데이터에서 locale 값을 추출하여 localStorage에 저장하고 페이지를 리로드해야 함 (ko)', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: 'ko' },
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
|
||||
expect(localStorageMock['g7_locale']).toBe('ko');
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('이벤트 데이터에서 locale 값을 추출하여 localStorage에 저장하고 페이지를 리로드해야 함 (en)', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: 'en' },
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'en');
|
||||
expect(localStorageMock['g7_locale']).toBe('en');
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('React Select 형태의 이벤트 데이터를 처리해야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
value: 'ko', // React Select는 value를 직접 전달
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
|
||||
expect(localStorageMock['g7_locale']).toBe('ko');
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('action.target에 직접 locale이 지정된 경우 처리해야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: 'ko', // 템플릿 문자열이 아닌 직접 값
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, undefined);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
|
||||
expect(localStorageMock['g7_locale']).toBe('ko');
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('예외 처리', () => {
|
||||
it('지원하지 않는 locale은 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: 'fr' }, // 지원하지 않는 언어
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Unsupported locale:', 'fr');
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locale 값이 없으면 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: undefined },
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', undefined);
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('템플릿 문자열이 그대로 전달되면 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}', // 템플릿 문자열 그대로
|
||||
};
|
||||
|
||||
// 이벤트 데이터 없음
|
||||
await setLocaleHandler(action, undefined);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', undefined);
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locale 값이 문자열이 아니면 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: 123 }, // 숫자
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', 123);
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('빈 문자열은 경고를 출력하고 localStorage에 저장하지 않아야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: '' },
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('[Handler:SetLocale]', 'Invalid locale:', '');
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('localStorage 저장 실패 시 오류를 출력하고 리로드하지 않아야 함', async () => {
|
||||
// localStorage.setItem이 실패하도록 모의
|
||||
const setItemError = new Error('Storage quota exceeded');
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw setItemError;
|
||||
});
|
||||
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: '{{event.target.value}}',
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: 'ko' },
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[Handler:SetLocale]',
|
||||
'Failed to save locale to localStorage:',
|
||||
setItemError
|
||||
);
|
||||
expect(window.location.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('다양한 이벤트 데이터 형식', () => {
|
||||
it('context.event.target.value가 우선순위가 가장 높아야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: 'en', // 직접 값 지정
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
target: { value: 'ko' }, // 이벤트 데이터 우선
|
||||
value: 'en', // React Select 형식도 있음
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
// context.event.target.value가 우선되어야 함
|
||||
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
|
||||
expect(localStorageMock['g7_locale']).toBe('ko');
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('context.event.value가 두 번째 우선순위를 가져야 함', async () => {
|
||||
const action = {
|
||||
type: 'change',
|
||||
handler: 'setLocale',
|
||||
target: 'en', // 직접 값 지정
|
||||
};
|
||||
|
||||
const context = {
|
||||
event: {
|
||||
value: 'ko', // React Select 형식
|
||||
},
|
||||
};
|
||||
|
||||
await setLocaleHandler(action, context);
|
||||
|
||||
// context.event.value가 action.target보다 우선되어야 함
|
||||
expect(setItemSpy).toHaveBeenCalledWith('g7_locale', 'ko');
|
||||
expect(localStorageMock['g7_locale']).toBe('ko');
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
* 템플릿에서 사용하는 모든 커스텀 핸들러를 정의합니다.
|
||||
*/
|
||||
|
||||
import { setLocaleHandler } from './setLocaleHandler';
|
||||
// 언어 관련 핸들러는 엔진 레벨(ActionDispatcher)에서 처리
|
||||
// setLocale 핸들러는 ActionDispatcher에 빌트인으로 등록되어 있음
|
||||
import { setThemeHandler, initThemeHandler } from './setThemeHandler';
|
||||
import { scrollToSectionHandler } from './scrollToSectionHandler';
|
||||
import { initMenuFromUrlHandler } from './initMenuFromUrlHandler';
|
||||
@@ -33,7 +34,7 @@ import { downloadAttachmentHandler } from './downloadAttachment';
|
||||
* 새로운 핸들러 추가 시 여기에만 등록하면 자동으로 ActionDispatcher에 등록됩니다.
|
||||
*/
|
||||
export const handlerMap = {
|
||||
setLocale: setLocaleHandler,
|
||||
// 언어: setLocale은 엔진 레벨(ActionDispatcher)에서 빌트인으로 처리
|
||||
setTheme: setThemeHandler,
|
||||
initTheme: initThemeHandler,
|
||||
scrollToSection: scrollToSectionHandler,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* setLocale 핸들러
|
||||
*
|
||||
* 사용자가 선택한 언어로 변경합니다.
|
||||
* localStorage에 g7_locale 값을 저장하고 페이지를 새로고침합니다.
|
||||
*/
|
||||
|
||||
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
|
||||
const logger = ((window as any).G7Core?.createLogger?.('Handler:SetLocale')) ?? {
|
||||
log: (...args: unknown[]) => console.log('[Handler:SetLocale]', ...args),
|
||||
warn: (...args: unknown[]) => console.warn('[Handler:SetLocale]', ...args),
|
||||
error: (...args: unknown[]) => console.error('[Handler:SetLocale]', ...args),
|
||||
};
|
||||
|
||||
/**
|
||||
* 언어 변경 핸들러
|
||||
*
|
||||
* ActionDispatcher는 handler(action, context) 형태로 호출합니다.
|
||||
* context 객체에는 { data, event, props, state, setState, navigate } 등이 포함됩니다.
|
||||
*
|
||||
* @param action 액션 정의
|
||||
* @param context 액션 컨텍스트 (ActionContext 타입)
|
||||
*/
|
||||
export async function setLocaleHandler(
|
||||
action: any,
|
||||
context?: any
|
||||
): Promise<void> {
|
||||
// 1. 이벤트 데이터에서 locale 값 추출
|
||||
let locale: string | undefined;
|
||||
|
||||
const eventData = context?.event;
|
||||
|
||||
if (eventData?.target?.value !== undefined && eventData.target.value !== null) {
|
||||
// HTML Select onChange 이벤트
|
||||
locale = eventData.target.value;
|
||||
} else if (eventData?.value !== undefined && eventData.value !== null) {
|
||||
// React Select 등 커스텀 이벤트 대응
|
||||
locale = eventData.value;
|
||||
} else if (action?.target && typeof action.target === 'string' && !action.target.includes('{{')) {
|
||||
// action.target이 템플릿 문자열이 아닌 경우에만 사용
|
||||
locale = action.target;
|
||||
}
|
||||
|
||||
// 2. 유효성 검증
|
||||
if (!locale || typeof locale !== 'string') {
|
||||
logger.warn('Invalid locale:', locale);
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효한 로케일 확인 — 활성 언어팩(supported_locales)을 우선, 미초기화 시 ko/en 폴백
|
||||
const G7Core = (window as any).G7Core;
|
||||
const validLocales: string[] = G7Core?.config?.('app.supported_locales')
|
||||
?? G7Core?.state?.get?.('_global.appConfig.supportedLocales')
|
||||
?? ['ko', 'en'];
|
||||
if (!validLocales.includes(locale)) {
|
||||
logger.warn('Unsupported locale:', locale);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. localStorage에 저장
|
||||
try {
|
||||
localStorage.setItem('g7_locale', locale);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save locale to localStorage:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 페이지 새로고침
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -363,6 +363,10 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1328,8 +1332,13 @@
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
/*
|
||||
* flex-wrap: 페이지 수가 많으면 한 줄에 들어가지 않아 좁은 뷰포트에서 콘텐츠 영역을 넘친다
|
||||
* (주문상세·상품수정의 활동로그 페이지네이션에서 재현). 공통 컴포넌트이므로 여기 한 줄로
|
||||
* 두 지점이 동시에 해소된다 — 개별 모듈 레이아웃을 고치지 말 것.
|
||||
*/
|
||||
.pagination {
|
||||
@apply flex items-center gap-2;
|
||||
@apply flex flex-wrap items-center justify-center gap-2;
|
||||
}
|
||||
.pagination-btn {
|
||||
@apply px-3 py-1 text-sm border rounded;
|
||||
@@ -1524,8 +1533,16 @@
|
||||
.modal-backdrop {
|
||||
@apply absolute inset-0 bg-black/50;
|
||||
}
|
||||
/*
|
||||
* max-w-[calc(100vw-2rem)]: 좁은 뷰포트에서 모달이 화면 좌우에 붙지 않도록 16px 여백을 남긴다.
|
||||
* 인라인 width(기본 500px)는 .modal-wrapper 의 flex 정렬이 이미 문서폭으로 clamp 하므로
|
||||
* 폭이 넘치지는 않지만, 여백이 0 이 되어 화면에 꽉 찬다.
|
||||
*
|
||||
* 주의: `.max-w-full`(max-width:100%)은 이 규칙과 명시도가 같고(0,1,0) 빌드 CSS 에서 더 뒤에
|
||||
* 정의되므로 승리한다. 레이아웃 className 에 `max-w-full` 을 함께 주면 이 clamp 가 무효화된다.
|
||||
*/
|
||||
.modal-container {
|
||||
@apply relative bg-white rounded-lg shadow-xl max-h-[90vh] overflow-hidden;
|
||||
@apply relative bg-white rounded-lg shadow-xl max-h-[90vh] overflow-hidden max-w-[calc(100vw-2rem)];
|
||||
@apply dark:bg-gray-800;
|
||||
}
|
||||
.modal-header-row {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "Admin Basic",
|
||||
"en": "Admin Basic"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 기본 관리자 템플릿",
|
||||
|
||||
+5
@@ -12,12 +12,17 @@ import { issueToken, authenticatePage } from '../../../../../../tests/Playwright
|
||||
type AdminTemplateAuthFixtures = {
|
||||
/** 레이아웃 편집/조회 권한 보유 토큰 */
|
||||
layoutEditToken: string;
|
||||
/** 관리자 대시보드 진입 권한 보유 토큰 (모바일 뷰포트 검증용) */
|
||||
adminDashboardToken: string;
|
||||
};
|
||||
|
||||
export const test = base.extend<AdminTemplateAuthFixtures>({
|
||||
layoutEditToken: async ({}, use) => {
|
||||
await use(issueToken('core.templates.layouts.edit', 'core.templates.read'));
|
||||
},
|
||||
adminDashboardToken: async ({}, use) => {
|
||||
await use(issueToken('core.dashboard.read'));
|
||||
},
|
||||
});
|
||||
|
||||
export { issueToken, authenticatePage };
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* sirsoft-admin_basic — 모바일 뷰포트 가로 오버플로 회귀
|
||||
*
|
||||
* 배경: 모바일 헤더 우측 그룹은 `flex-shrink-0` 이라 좁아져도 폭을 유지한 채 화면 밖으로
|
||||
* 밀려난다. theme(36) + noti(36) + lang(79) + 통화(112) + gap 합계가 **420px** 를 요구해
|
||||
* 390px 에서 30px, 320px 에서 100px 이 잘렸다. 언어/통화/배송국가를 모바일 드로어의
|
||||
* 가로 칩으로 옮겨 해소했다.
|
||||
*
|
||||
* 이후 드로어가 세로로 길어져 관리자 메뉴가 스크롤 밖으로 밀리는 문제가 생겨,
|
||||
* 언어 / 통화·배송국가를 각각 독립 아코디언(기본 접힘 + 트리거에 현재값 요약)으로 바꿨다.
|
||||
*
|
||||
* 측정 규율 (이 spec 을 고칠 사람에게):
|
||||
* - `document.scrollWidth - clientWidth`(docOver)는 **항상 0** 이다. `.admin_layout_root` 가
|
||||
* `overflow-hidden` 이라 초과분이 문서 스크롤폭에 기여하지 않기 때문. 이걸 단언하면
|
||||
* 회귀를 영원히 못 잡는다.
|
||||
* - 절대 폭 하한(`toBeGreaterThanOrEqual(80)`)도 회귀를 못 잡는다. **클립 0 을 직접 단언**한다.
|
||||
* - 대신 두 지표를 쓴다: ① 요소 우측 끝이 뷰포트를 넘는가(clip)
|
||||
* ② `#right_content_area` 의 `scrollWidth - clientWidth`(rcaExcess)
|
||||
*
|
||||
* @scenario admin_viewport=390|320|1023|1024|1280 x surface=header|drawer|content
|
||||
* @effects mobile_header_no_clip, drawer_prefs_chips_on_screen, content_area_no_overflow,
|
||||
* portable_desktop_boundary_flips_at_1024, desktop_header_currency_dropdown_preserved,
|
||||
* drawer_prefs_collapsed_by_default, drawer_prefs_accordions_independent
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/admin-template-auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** 세로 스크롤바(≈15px) 때문에 clientWidth 를 원하는 값으로 맞추려면 창을 그만큼 넓게 잡는다. */
|
||||
const SCROLLBAR = 15;
|
||||
const phone = { width: 390 + SCROLLBAR, height: 844 };
|
||||
const smallPhone = { width: 320 + SCROLLBAR, height: 568 };
|
||||
/**
|
||||
* `portable` 경계 양쪽. 엔진 `ResponsiveManager` 는 `portable = 0~1023px` 을
|
||||
* `window.innerWidth` 로 판정하므로 스크롤바 보정 없이 창 폭을 그대로 쓴다.
|
||||
* 1023 = portable 마지막 픽셀(드로어 렌더), 1024 = desktop 첫 픽셀(헤더 렌더).
|
||||
*/
|
||||
const portableEdge = { width: 1023, height: 800 };
|
||||
const desktopEdge = { width: 1024, height: 800 };
|
||||
const desktop = { width: 1280, height: 900 };
|
||||
|
||||
/** 요소의 우측 끝이 뷰포트를 넘는 픽셀 수 (넘지 않으면 0). */
|
||||
async function clipOf(page: Page, selector: string): Promise<number> {
|
||||
return page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return -1;
|
||||
const vw = document.documentElement.clientWidth;
|
||||
return Math.max(0, Math.round(el.getBoundingClientRect().right - vw));
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/** #right_content_area 의 가로 초과분. */
|
||||
async function rcaExcess(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const rca = document.getElementById('right_content_area');
|
||||
return rca ? rca.scrollWidth - rca.clientWidth : -1;
|
||||
});
|
||||
}
|
||||
|
||||
/** settle 곡선 확인 — 지연 로딩(활동로그 페이지네이션 등) 때문에 첫 측정은 위음성이 난다. */
|
||||
async function settledRcaExcess(page: Page, samples = 3): Promise<number> {
|
||||
let last = -1;
|
||||
for (let i = 0; i < samples; i += 1) {
|
||||
await page.waitForTimeout(1200);
|
||||
last = await rcaExcess(page);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async function openDrawer(page: Page): Promise<void> {
|
||||
await page.locator('#mobile_menu_toggle').click();
|
||||
// 드로어는 `transform: translateX(-256px) → 0` 을 0.3s 에 걸쳐 이동한다. 이 트랜지션이 끝나기
|
||||
// 전에 좌표를 읽으면 칩이 화면 밖(음수 x)에 있어 위양성이 난다.
|
||||
//
|
||||
// `getAnimations()` 로 기다리면 안 된다 — 클릭 직후에는 트랜지션이 아직 시작되지 않아
|
||||
// 빈 배열을 반환하고, waitForFunction 이 즉시 통과해 애니메이션 도중에 측정하게 된다.
|
||||
// 최종 기하(left === 0)가 안정될 때까지 기다린다.
|
||||
await page.waitForFunction(() => {
|
||||
const d = document.getElementById('left_sidebar_area');
|
||||
if (!d) return false;
|
||||
return Math.round(d.getBoundingClientRect().left) === 0;
|
||||
}, undefined, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
test.describe('@sirsoft-admin_basic 모바일 뷰포트 가로 오버플로', () => {
|
||||
test('390px: 모바일 헤더가 잘리지 않고 통화·언어는 헤더에 없다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(phone);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.locator('#mobile_header_right').waitFor({ timeout: 20_000 });
|
||||
|
||||
// 회귀 시 정확히 30 이 되어 실패한다.
|
||||
expect(await clipOf(page, '#mobile_header_right')).toBe(0);
|
||||
|
||||
// 헤더에는 테마·알림만 남는다.
|
||||
const headerIds = await page.evaluate(() =>
|
||||
[...(document.getElementById('mobile_header_right')?.children ?? [])].map((c) => c.id),
|
||||
);
|
||||
expect(headerIds).toContain('theme_toggle_mobile');
|
||||
expect(headerIds).toContain('notification_center_mobile');
|
||||
expect(headerIds).not.toContain('language_selector_mobile');
|
||||
expect(headerIds).not.toContain('header_currency_slot_mobile');
|
||||
});
|
||||
|
||||
test('320px: 헤더 클립 0 + 햄버거 탭 가능 (최소 폰)', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(smallPhone);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.locator('#mobile_header_right').waitFor({ timeout: 20_000 });
|
||||
|
||||
// 회귀 시 100 이 된다.
|
||||
expect(await clipOf(page, '#mobile_header_right')).toBe(0);
|
||||
|
||||
// 햄버거가 화면 안이고 실제 히트 타깃이어야 한다(다른 요소가 덮으면 실패).
|
||||
const burgerTappable = await page.evaluate(() => {
|
||||
const b = document.getElementById('mobile_menu_toggle');
|
||||
if (!b) return false;
|
||||
const r = b.getBoundingClientRect();
|
||||
if (r.left < 0 || r.right > document.documentElement.clientWidth) return false;
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return !!hit && (b.contains(hit) || hit.contains(b));
|
||||
});
|
||||
expect(burgerTappable).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* 드로어 선호설정은 언어 / 통화·배송국가 두 개의 독립 아코디언이며 기본은 접힘이다.
|
||||
* 접힘 상태에서는 트리거에 현재 선택값만 요약 표기되고, 펼쳐야 칩이 렌더된다.
|
||||
* (전부 펼쳐 두면 드로어가 세로로 길어져 정작 관리자 메뉴가 스크롤 밖으로 밀린다.)
|
||||
*/
|
||||
test('390px: 드로어 선호설정은 기본 접힘이고 트리거에 현재값을 요약 표기한다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(phone);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await openDrawer(page);
|
||||
|
||||
const prefs = page.locator('#admin_drawer_prefs');
|
||||
await expect(prefs).toBeVisible();
|
||||
|
||||
// 언어: 접힘 → aria-expanded=false.
|
||||
const langToggle = page.locator('#admin_drawer_language_toggle');
|
||||
await expect(langToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// 접힘 상태 요약 = 현재 언어명. E2E 세션 로케일이 ko/en 중 무엇이든 성립해야 하므로
|
||||
// 특정 문자열('한국어')을 박지 않고, aria-selected 인 칩의 이름과 일치하는지로 본다.
|
||||
const summaryMatchesSelectedChip = await page.evaluate(() => {
|
||||
const toggle = document.querySelector('#admin_drawer_language_toggle');
|
||||
const selected = document.querySelector('#admin_drawer_language [role="option"][aria-selected="true"]');
|
||||
if (!toggle || !selected) return null;
|
||||
const chipName = (selected.textContent ?? '').trim();
|
||||
// 트리거 텍스트 = "<라벨><현재 언어명>" (칩 목록은 별도 래퍼라 포함되지 않는다)
|
||||
return { toggleText: (toggle.textContent ?? '').trim(), chipName };
|
||||
});
|
||||
expect(summaryMatchesSelectedChip).not.toBeNull();
|
||||
expect(summaryMatchesSelectedChip!.chipName.length).toBeGreaterThan(0);
|
||||
expect(summaryMatchesSelectedChip!.toggleText).toContain(summaryMatchesSelectedChip!.chipName);
|
||||
|
||||
// 칩은 `max-h-0 overflow-hidden` 래퍼에 잘려 화면에 안 보인다.
|
||||
// 칩 자신은 레이아웃 박스를 갖고 있어(=Playwright 기준 visible) 칩에 not.toBeVisible()
|
||||
// 을 걸면 안 된다. 잘림을 만드는 **래퍼의 실제 높이 0** 을 단언한다.
|
||||
const langBodyHeight = await page.evaluate(() => {
|
||||
const body = document
|
||||
.querySelector('#admin_drawer_language_toggle')
|
||||
?.parentElement?.querySelector('.overflow-hidden');
|
||||
return body ? Math.round(body.getBoundingClientRect().height) : -1;
|
||||
});
|
||||
expect(langBodyHeight).toBe(0);
|
||||
|
||||
// 통화/배송국가(이커머스 주입): 접힘 → 패널 자체가 언마운트.
|
||||
// 모듈 비활성 환경에서는 슬롯이 비므로 존재할 때만 검증한다.
|
||||
const curTrigger = prefs.locator('button[aria-haspopup="listbox"]');
|
||||
if (await curTrigger.count()) {
|
||||
await expect(curTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(prefs.locator('[role="listbox"]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('390px: 아코디언을 펼치면 언어·통화·배송국가 칩이 모두 화면 안에 있다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(phone);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await openDrawer(page);
|
||||
|
||||
// 언어 펼치기 (엔진 빌트인 setLocale 을 action.target 으로 호출하는 칩 3개)
|
||||
await page.locator('#admin_drawer_language_toggle').click();
|
||||
await expect(page.locator('#admin_drawer_language_toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.locator('#admin_drawer_language [role="option"]')).toHaveCount(3);
|
||||
// 칩 자신은 접힘 상태에서도 레이아웃 박스를 가지므로, 잘림 래퍼가 실제로 열렸는지를 본다.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => {
|
||||
const body = document
|
||||
.querySelector('#admin_drawer_language_toggle')
|
||||
?.parentElement?.querySelector('.overflow-hidden');
|
||||
return body ? Math.round(body.getBoundingClientRect().height) : -1;
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// 통화/배송국가 펼치기 (모듈 비활성 환경에서는 트리거 자체가 없다)
|
||||
const prefs = page.locator('#admin_drawer_prefs');
|
||||
const curTrigger = prefs.locator('button[aria-haspopup="listbox"]');
|
||||
if (await curTrigger.count()) {
|
||||
await curTrigger.click();
|
||||
await expect(prefs.locator('[role="listbox"]')).toHaveCount(1);
|
||||
}
|
||||
|
||||
const chips = prefs.locator('[role="option"]');
|
||||
expect(await chips.count()).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// 칩이 한 개라도 뷰포트를 넘으면 실패.
|
||||
const anyChipClipped = await page.evaluate(() => {
|
||||
const vw = document.documentElement.clientWidth;
|
||||
return [...document.querySelectorAll('#admin_drawer_prefs [role="option"]')].some((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && (r.right > vw + 1 || r.left < -1);
|
||||
});
|
||||
});
|
||||
expect(anyChipClipped).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* 두 아코디언은 서로 다른 상태를 쓴다(언어=_global.mobileLanguageOpen,
|
||||
* 통화=_local.showCurrencyDropdown). 하나를 닫아도 다른 하나는 열린 채여야 한다.
|
||||
*/
|
||||
test('390px: 언어와 통화 아코디언은 독립적으로 개폐된다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(phone);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await openDrawer(page);
|
||||
|
||||
const prefs = page.locator('#admin_drawer_prefs');
|
||||
const langToggle = page.locator('#admin_drawer_language_toggle');
|
||||
const curTrigger = prefs.locator('button[aria-haspopup="listbox"]');
|
||||
test.skip(!(await curTrigger.count()), '이커머스 모듈 비활성 — 통화 슬롯 없음');
|
||||
|
||||
await langToggle.click();
|
||||
await curTrigger.click();
|
||||
await expect(langToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(curTrigger).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// 언어만 닫는다 → 통화는 열린 채 유지 (독립성)
|
||||
await langToggle.click();
|
||||
await expect(langToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(curTrigger).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(prefs.locator('[role="listbox"]')).toHaveCount(1);
|
||||
|
||||
// 드로어 안 인라인 아코디언이므로 배경 오버레이(fixed inset-0)는 렌더되지 않는다.
|
||||
// 렌더되면 드로어 전체 클릭을 가로챈다.
|
||||
const backdrops = await page.evaluate(
|
||||
() => document.querySelectorAll('#admin_drawer_prefs .fixed.inset-0').length,
|
||||
);
|
||||
expect(backdrops).toBe(0);
|
||||
});
|
||||
|
||||
test('390px: 이커머스 환경설정 콘텐츠 영역이 넘치지 않는다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(phone);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/ecommerce/settings');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
|
||||
|
||||
// 회귀 시 71 이 된다(이메일 행 nowrap + sticky-tab-nav 과다 bleed).
|
||||
expect(await settledRcaExcess(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('320px: 이커머스 환경설정 고정폭 입력이 넘치지 않는다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(smallPhone);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/ecommerce/settings');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
|
||||
|
||||
// 회귀 시 32 가 된다(w-80 고정폭 입력 2개).
|
||||
expect(await settledRcaExcess(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('1023px: portable 마지막 픽셀 — 드로어 선호설정이 렌더된다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(portableEdge);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
|
||||
|
||||
// 경계가 1px 밀리면(예: `<` → `<=` 오타) 여기서 잡힌다.
|
||||
await expect(page.locator('#admin_drawer_prefs')).toHaveCount(1);
|
||||
expect(await settledRcaExcess(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('1024px: desktop 첫 픽셀 — 드로어 선호설정이 사라지고 헤더 통화가 렌더된다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(desktopEdge);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
|
||||
|
||||
// 데스크톱에서 드로어 통화가 중복 노출되면 회귀다.
|
||||
await expect(page.locator('#admin_drawer_prefs')).toHaveCount(0);
|
||||
await expect(page.locator('#header_currency_slot_desktop')).toHaveCount(1);
|
||||
expect(await settledRcaExcess(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('1280px: 데스크톱 헤더/통화 드롭다운이 회귀하지 않는다', async ({ page, adminDashboardToken }) => {
|
||||
await page.setViewportSize(desktop);
|
||||
await authenticatePage(page, adminDashboardToken);
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.locator('#right_content_area').waitFor({ timeout: 20_000 });
|
||||
|
||||
// 드로어 선호설정 블록은 데스크톱에서 렌더되지 않아야 한다(중복 노출 = 회귀).
|
||||
await expect(page.locator('#admin_drawer_prefs')).toHaveCount(0);
|
||||
|
||||
// 통화는 데스크톱 헤더 슬롯에 남는다.
|
||||
const desktopSlot = page.locator('#header_currency_slot_desktop');
|
||||
await expect(desktopSlot).toHaveCount(1);
|
||||
|
||||
expect(await settledRcaExcess(page)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
- 휴대폰 화면에서 헤더가 가로로 넘쳐 사이트명이 잘리고 메뉴 버튼이 화면 밖으로 밀려나던 문제를 수정했습니다. 언어·통화 선택은 메뉴(햄버거) 안으로 옮겼습니다.
|
||||
- 좁은 화면(320px)에서 메뉴 버튼을 누를 수 없어 사이트 이동 자체가 불가능하던 문제를 수정했습니다.
|
||||
- 메뉴 안의 언어·통화·배송국가 선택이 세로로 길게 늘어지지 않고 가로로 나란히 배치됩니다.
|
||||
- 메뉴 안의 언어와 통화·배송국가가 각각 접었다 펼치는 항목으로 바뀌어, 메뉴를 열었을 때 곧바로 사이트 메뉴가 보입니다. 접혀 있을 때도 현재 선택된 언어와 통화·배송국가가 표시되므로 펼치지 않아도 확인할 수 있으며, 두 항목은 서로 독립적으로 여닫힙니다.
|
||||
- 언어 선택이 로그인하지 않은 방문자에게도 보입니다. 이전에는 메뉴 안에서 로그인한 사용자만 언어를 바꿀 수 있었습니다.
|
||||
- 게시판 이름이 길면 게시글 위쪽의 목록·이전글·다음글 버튼이 찌그러지거나 보이지 않던 문제를 수정했습니다. 이제 게시판 이름이 윗줄에 표시되고 버튼은 아랫줄에 원래 크기로 놓입니다.
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* - 언어·통화는 헤더 우측 그룹이 아니라 모바일 드로어 최상단(mobile_drawer_prefs)에 있다.
|
||||
* 데스크톱(Header.tsx)은 기존 헤더 배치를 유지한다. 사유는 아래 해당 it 블록 주석 참조.
|
||||
* - 드로어는 overflow-y-auto 라 absolute 드롭다운이 잘린다 → 주입 조각이 responsive.portable
|
||||
* 오버라이드로 모바일에서만 펼친 목록이 된다(트리거 숨김 + 패널 static).
|
||||
* 오버라이드로 모바일에서만 static 인라인 목록이 된다.
|
||||
* - 언어 / 통화·배송국가는 각각 독립 아코디언이며 기본 접힘이다. 접힘 상태에서도 트리거에
|
||||
* 현재 선택값(언어명 / 통화·배송국가)을 요약 표기한다.
|
||||
*
|
||||
* 회귀 차단:
|
||||
* - _user_base.json: defaultCurrency/preferredCurrency(_global)/availableCurrencies 주입 + X-Currency 헤더.
|
||||
@@ -67,6 +69,35 @@ function findNode(node: any, id: string): any {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 트리에서 술어를 만족하는 첫 노드를 찾습니다.
|
||||
*
|
||||
* 위치 인덱스(children[1] 등) 대신 구조로 찾기 위한 헬퍼 — 노드가 앞에 추가되어도
|
||||
* 테스트가 깨지지 않습니다.
|
||||
*
|
||||
* @param node 탐색 시작 노드/배열
|
||||
* @param pred 노드 술어
|
||||
* @return 찾은 노드 (없으면 null)
|
||||
*/
|
||||
function walkNode(node: any, pred: (n: any) => boolean): any {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (Array.isArray(node)) {
|
||||
for (const n of node) {
|
||||
const r = walkNode(n, pred);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (pred(node)) return node;
|
||||
for (const k of ['children', 'components']) {
|
||||
if (node[k]) {
|
||||
const r = walkNode(node[k], pred);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('A1 — _user_base.json 통화 주입', () => {
|
||||
const userBase = loadJson('layouts/_user_base.json');
|
||||
const initActions = userBase.init_actions ?? [];
|
||||
@@ -221,10 +252,15 @@ describe('헤더 통화 슬롯(이커머스 주입) + 언어 버튼(코어 내
|
||||
);
|
||||
expect(langSection).toBeTruthy();
|
||||
|
||||
// [0] = 라벨, [1] = 칩 컨테이너
|
||||
const chipRow = langSection.children[1];
|
||||
// 칩 컨테이너는 아코디언 펼침 영역 안에 있다. 위치 인덱스가 아니라 구조로 찾는다
|
||||
// (트리거 행이 앞에 추가되어도 깨지지 않도록).
|
||||
const chipRow = walkNode(langSection, (n: any) =>
|
||||
n.name === 'Div' &&
|
||||
typeof n.props?.className === 'string' &&
|
||||
n.props.className.includes('flex-wrap')
|
||||
);
|
||||
expect(chipRow).toBeTruthy();
|
||||
expect(chipRow.props.className).toContain('flex');
|
||||
expect(chipRow.props.className).toContain('flex-wrap');
|
||||
expect(chipRow.iteration).toBeUndefined();
|
||||
|
||||
const button = chipRow.children[0];
|
||||
@@ -235,6 +271,53 @@ describe('헤더 통화 슬롯(이커머스 주입) + 언어 버튼(코어 내
|
||||
expect(button.props.className).toContain('whitespace-nowrap');
|
||||
});
|
||||
|
||||
/**
|
||||
* 접기/펼치기 — 언어·통화·배송국가를 전부 펼쳐 두면 드로어가 세로로 길어져 정작 메뉴가
|
||||
* 스크롤 밖으로 밀린다. 언어와 통화/배송국가를 각각 독립 아코디언으로 접고, 접힘
|
||||
* 상태에서도 현재 선택값을 요약 표기한다(무엇이 선택돼 있는지 펼치지 않고 알 수 있어야 함).
|
||||
*
|
||||
* 언어 = 템플릿 소유(_global.mobileLanguageOpen),
|
||||
* 통화/배송국가 = 이커머스 주입 조각 소유(_local.showCurrencyDropdown) → 서로 독립.
|
||||
*/
|
||||
it('드로어 언어 섹션은 기본 접힘 + 트리거에 현재 언어를 요약 표기한다', () => {
|
||||
const userBase = loadJson('layouts/_user_base.json');
|
||||
const langSection = findNode(userBase.components, 'mobile_drawer_language');
|
||||
expect(langSection).toBeTruthy();
|
||||
|
||||
const toggle = findNode(langSection, 'mobile_drawer_language_toggle');
|
||||
expect(toggle?.name).toBe('Button');
|
||||
expect(toggle.props['aria-expanded']).toBe('{{_global.mobileLanguageOpen ?? false}}');
|
||||
|
||||
// 토글은 언어 전용 플래그를 뒤집는다
|
||||
const act = toggle.actions[0];
|
||||
expect(act.handler).toBe('setState');
|
||||
expect(act.params.target).toBe('global');
|
||||
expect(act.params.mobileLanguageOpen).toBe('{{!_global.mobileLanguageOpen}}');
|
||||
|
||||
// 접힘 상태 요약 = 현재 언어명 + chevron
|
||||
const summary = JSON.stringify(toggle.children);
|
||||
expect(summary).toContain('localeNames?.[$locale]');
|
||||
expect(summary).toContain('chevron-down');
|
||||
expect(summary).toContain("{{_global.mobileLanguageOpen ? 'rotate-180' : ''}}");
|
||||
|
||||
// 펼침 영역: falsy(기본) 분기가 닫힘(max-h-0)이어야 기본 접힘이 성립한다
|
||||
const body = walkNode(langSection, (n: any) =>
|
||||
typeof n.props?.className === 'string' &&
|
||||
n.props.className.includes('overflow-hidden') &&
|
||||
n.props.className.includes('mobileLanguageOpen')
|
||||
);
|
||||
expect(body).toBeTruthy();
|
||||
expect(body.props.className).toContain("'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'");
|
||||
});
|
||||
|
||||
it('언어 토글은 통화 플래그를 건드리지 않는다 (독립 개폐)', () => {
|
||||
const userBase = loadJson('layouts/_user_base.json');
|
||||
const toggle = findNode(userBase.components, 'mobile_drawer_language_toggle');
|
||||
expect(toggle).toBeTruthy();
|
||||
// 언어 = _global.mobileLanguageOpen / 통화 = _local.showCurrencyDropdown (모듈 조각 소유)
|
||||
expect(JSON.stringify(toggle)).not.toContain('showCurrencyDropdown');
|
||||
});
|
||||
|
||||
it('드로어 언어/통화 섹션은 비회원에게도 노출된다 (아코디언 currentUser 게이트 바깥)', () => {
|
||||
const userBase = loadJson('layouts/_user_base.json');
|
||||
const drawer = findNode(userBase.components, 'mobile_nav_drawer');
|
||||
@@ -313,15 +396,29 @@ describe('이커머스 헤더 통화 주입 확장 (slot=header_currency)', () =
|
||||
* 모바일 드로어는 overflow-y-auto + w-80 이라 absolute 드롭다운 패널이 스크롤 경계에서 잘린다.
|
||||
* SlotContainer 는 주입 자식에게 variant/context prop 을 전달하지 않으므로 컨테이너별로
|
||||
* 다르게 그릴 수 없다 → 주입 노드 자체에 responsive.portable(0~1023px) 오버라이드를 둔다.
|
||||
*
|
||||
* portable 에서는 팝오버가 아니라 드로어 안 인라인 아코디언이 되며, 개폐는 데스크톱과 같은
|
||||
* `_local.showCurrencyDropdown` 이 결정한다(기본 접힘).
|
||||
*/
|
||||
it('유저 확장 트리거 버튼은 portable(모바일/태블릿)에서 숨겨진다', () => {
|
||||
it('유저 확장 트리거는 portable 에서 아코디언 행 + 현재값 요약을 노출한다', () => {
|
||||
const node = userExt.injections[0].components[0];
|
||||
const trigger = (node.children ?? []).find((c: any) => c.name === 'Button');
|
||||
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');
|
||||
|
||||
// 접힘 상태에서도 현재 통화/배송국가를 알 수 있어야 한다
|
||||
const summary = JSON.stringify(portable.children);
|
||||
expect(summary).toContain('preferredCurrency');
|
||||
expect(summary).toContain('preferredShippingCountry');
|
||||
expect(summary).toContain('chevron-down');
|
||||
});
|
||||
|
||||
it('유저 확장 패널은 portable 에서 항상 펼쳐진 정적 목록이다 (absolute/z-50 해제)', () => {
|
||||
it('유저 확장 패널은 portable 에서도 _local.showCurrencyDropdown 으로 게이트된다 (기본 접힘)', () => {
|
||||
const node = userExt.injections[0].components[0];
|
||||
const panel = (node.children ?? []).find(
|
||||
(c: any) => c.name === 'Div' && c.props?.role === 'listbox'
|
||||
@@ -333,16 +430,25 @@ describe('이커머스 헤더 통화 주입 확장 (slot=header_currency)', () =
|
||||
expect(panel.props.className).toContain('absolute');
|
||||
expect(panel.props.className).toContain('z-50');
|
||||
|
||||
// portable 오버라이드: if 해제 + 정적 흐름
|
||||
// portable 오버라이드: if 를 생략해 base if 를 상속(접힘 기본) + 정적 흐름
|
||||
const portable = panel.responsive?.portable;
|
||||
expect(portable).toBeTruthy();
|
||||
expect(portable.if).toBe('{{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');
|
||||
expect(portable.props.role).toBe('listbox');
|
||||
});
|
||||
|
||||
it('유저 확장 백드롭은 portable 에서 꺼진다 (드로어 클릭 가로채기 방지)', () => {
|
||||
const node = userExt.injections[0].components[0];
|
||||
const backdrop = (node.children ?? []).find(
|
||||
(c: any) => c.name === 'Div' && (c.props?.className ?? '').includes('fixed inset-0')
|
||||
);
|
||||
expect(backdrop).toBeTruthy();
|
||||
expect(backdrop.responsive?.portable?.if).toBe('{{false}}');
|
||||
});
|
||||
|
||||
it('관리자 확장이 _admin_base 에 header_currency 슬롯 + init_actions(통화 복원)를 기여한다', () => {
|
||||
expect(adminExt.target_layout).toBe('_admin_base');
|
||||
const node = adminExt.injections[0].components[0];
|
||||
|
||||
@@ -812,19 +812,75 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "언어 선택 (여러 언어 지원 시)",
|
||||
"comment": "언어 선택 (여러 언어 지원 시). 접기/펼치기 — 접힘이 기본이고 트리거에 현재 언어를 요약 표기한다. 통화/배송국가(이커머스 주입 슬롯)와는 독립된 토글(_global.mobileLanguageOpen)이라 서로 영향 없음.",
|
||||
"id": "mobile_drawer_language",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{$locales.length > 1}}",
|
||||
"children": [
|
||||
{
|
||||
"comment": "트리거 — 좌측 '언어' 라벨, 우측 현재 언어명 + chevron. 접힘 상태에서 현재 값을 알 수 있어야 하므로 요약을 항상 노출한다.",
|
||||
"id": "mobile_drawer_language_toggle",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "w-full flex items-center justify-between py-1 cursor-pointer",
|
||||
"aria-expanded": "{{_global.mobileLanguageOpen ?? false}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"mobileLanguageOpen": "{{!_global.mobileLanguageOpen}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
|
||||
},
|
||||
"text": "$t:common.language"
|
||||
},
|
||||
{
|
||||
"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.appConfig?.localeNames?.[$locale] ?? $locale}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "chevron-down",
|
||||
"className": "{{_global.mobileLanguageOpen ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "펼침 영역 — 사용자 정보 아코디언과 동일한 max-h/opacity 트랜지션. overflow-hidden 이라 접힘 시 칩이 잘려 사라진다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"
|
||||
"className": "overflow-hidden transition-all duration-300 ease-in-out {{_global.mobileLanguageOpen ? 'max-h-screen opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'}}"
|
||||
},
|
||||
"text": "$t:common.language"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "가로 나열 칩. iteration 은 반드시 Button 자신에 건다 — 엔진은 iteration 을 가진 노드를 항목 수만큼 복제하므로, flex 컨테이너에 걸면 컨테이너가 복제되어 칩이 세로로 쌓인다.",
|
||||
"type": "basic",
|
||||
@@ -853,6 +909,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+84
-8
@@ -10,9 +10,14 @@
|
||||
*
|
||||
* 조치:
|
||||
* - 언어/통화를 모바일 드로어(mobile_drawer_prefs)로 이동, 가로 칩으로 나열
|
||||
* - 통화/배송국가 주입 조각은 responsive.portable 로 모바일에서만 펼친 칩 목록
|
||||
* - 통화/배송국가 주입 조각은 responsive.portable 로 모바일에서만 static 인라인 목록
|
||||
* - 데스크톱(≥1024px)은 기존 헤더 드롭다운 유지
|
||||
*
|
||||
* 후속 (드로어 세로 길이):
|
||||
* - 언어 / 통화·배송국가를 각각 독립 아코디언으로 접는다(기본 접힘). 접힘 상태에서도
|
||||
* 트리거에 현재값(언어명 / 통화·배송국가)을 요약 표기해 펼치지 않고 알 수 있게 한다.
|
||||
* - 언어=템플릿 소유(_global.mobileLanguageOpen), 통화=주입 조각 소유(_local.showCurrencyDropdown).
|
||||
*
|
||||
* 단위 테스트(Vitest)는 레이아웃 JSON 구조만 본다. 실제 브라우저 폭·줄 수·가시성은
|
||||
* 여기서만 검증된다 (위지윅 발행 회귀 #238 교훈).
|
||||
*/
|
||||
@@ -86,7 +91,11 @@ test.describe('모바일 헤더/드로어 (390px)', () => {
|
||||
await expect(page.locator('#mobile_header_right #mobile_currency_selector_wrap')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('언어/통화는 드로어 안에 가로 칩으로 나열되고 비회원도 볼 수 있다', async ({ page }) => {
|
||||
/**
|
||||
* 드로어 선호설정은 언어 / 통화·배송국가 두 개의 독립 아코디언이며 기본은 접힘이다.
|
||||
* 전부 펼쳐 두면 드로어가 세로로 길어져 정작 메뉴가 스크롤 밖으로 밀린다.
|
||||
*/
|
||||
test('드로어 선호설정은 기본 접힘이고 트리거에 현재값을 요약 표기한다 (비회원 포함)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.locator('#mobile_menu_toggle').click();
|
||||
@@ -94,16 +103,54 @@ test.describe('모바일 헤더/드로어 (390px)', () => {
|
||||
const prefs = page.locator('#mobile_drawer_prefs');
|
||||
await expect(prefs).toBeVisible();
|
||||
|
||||
// 언어 칩: 여러 로케일이 한 줄에 나열 (iteration 이 Button 에 걸려 있어야 함)
|
||||
// 언어: 접힘 (트리거 aria-expanded=false, 잘림 래퍼 높이 0)
|
||||
const langToggle = page.locator('#mobile_drawer_language_toggle');
|
||||
await expect(langToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
const langBodyHeight = await page.evaluate(() => {
|
||||
const body = document
|
||||
.querySelector('#mobile_drawer_language_toggle')
|
||||
?.parentElement?.querySelector('.overflow-hidden');
|
||||
return body ? Math.round(body.getBoundingClientRect().height) : -1;
|
||||
});
|
||||
expect(langBodyHeight).toBe(0);
|
||||
|
||||
// 접힘 상태 요약 = 현재 선택된 언어 칩의 이름 (세션 로케일 무관)
|
||||
const summary = await page.evaluate(() => {
|
||||
const toggle = document.querySelector('#mobile_drawer_language_toggle');
|
||||
const selected = document.querySelector('#mobile_drawer_language [role="option"][aria-selected="true"]');
|
||||
if (!toggle || !selected) return null;
|
||||
return { toggleText: (toggle.textContent ?? '').trim(), chipName: (selected.textContent ?? '').trim() };
|
||||
});
|
||||
expect(summary).not.toBeNull();
|
||||
expect(summary!.toggleText).toContain(summary!.chipName);
|
||||
|
||||
// 통화 슬롯: 이커머스 모듈 주입 + id 스코프. 접힘 → 트리거는 보이고 패널은 언마운트.
|
||||
const currencyRoot = page.locator('#mobile_drawer_currency_wrap [id^="ext_header_currency_selector"]');
|
||||
await expect(currencyRoot).toBeVisible();
|
||||
const trigger = currencyRoot.locator('button[aria-haspopup="listbox"]');
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(currencyRoot.locator('[role="listbox"]')).toHaveCount(0);
|
||||
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('아코디언을 펼치면 언어/통화 칩이 가로로 나열되고 뷰포트를 넘지 않는다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.locator('#mobile_menu_toggle').click();
|
||||
|
||||
const prefs = page.locator('#mobile_drawer_prefs');
|
||||
await expect(prefs).toBeVisible();
|
||||
|
||||
// 언어 펼치기 — iteration 이 Button 에 걸려 있어야 칩이 가로로 나열된다
|
||||
await page.locator('#mobile_drawer_language_toggle').click();
|
||||
const langChips = prefs.locator('button[role="option"]:not(#mobile_drawer_currency_wrap button)');
|
||||
expect(await langChips.count()).toBeGreaterThan(0);
|
||||
|
||||
// 통화 슬롯: 이커머스 모듈 주입 + id 스코프
|
||||
// 통화 펼치기 — portable 에서는 absolute 팝오버가 아니라 static 인라인 목록
|
||||
const currencyRoot = page.locator('#mobile_drawer_currency_wrap [id^="ext_header_currency_selector"]');
|
||||
await expect(currencyRoot).toBeVisible();
|
||||
|
||||
// portable 오버라이드 — 트리거는 숨고 패널은 펼쳐진 정적 목록
|
||||
await expect(currencyRoot.locator('button[aria-haspopup="listbox"]')).toBeHidden();
|
||||
await currencyRoot.locator('button[aria-haspopup="listbox"]').click();
|
||||
const panel = currencyRoot.locator('[role="listbox"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveClass(/\bstatic\b/);
|
||||
@@ -115,9 +162,38 @@ test.describe('모바일 헤더/드로어 (390px)', () => {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
await expect(chips.nth(i)).toHaveClass(/rounded-full/);
|
||||
}
|
||||
|
||||
// 드로어 안 인라인 아코디언이라 배경 오버레이(fixed inset-0)는 렌더되지 않는다.
|
||||
// 렌더되면 드로어 전체 클릭을 가로챈다.
|
||||
const backdrops = await page.evaluate(
|
||||
() => document.querySelectorAll('#mobile_drawer_prefs .fixed.inset-0').length,
|
||||
);
|
||||
expect(backdrops).toBe(0);
|
||||
|
||||
expect(await docOverflow(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('언어와 통화 아코디언은 독립적으로 개폐된다', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.locator('#mobile_menu_toggle').click();
|
||||
|
||||
const langToggle = page.locator('#mobile_drawer_language_toggle');
|
||||
const currencyRoot = page.locator('#mobile_drawer_currency_wrap [id^="ext_header_currency_selector"]');
|
||||
const trigger = currencyRoot.locator('button[aria-haspopup="listbox"]');
|
||||
|
||||
await langToggle.click();
|
||||
await trigger.click();
|
||||
await expect(langToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// 언어만 닫는다 → 통화는 열린 채 유지
|
||||
await langToggle.click();
|
||||
await expect(langToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(currencyRoot.locator('[role="listbox"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('320px 에서도 햄버거 버튼을 누를 수 있다 (내비게이션 접근성)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
Reference in New Issue
Block a user