feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)
SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).
- Build reproducibility: dist text normalized to LF, dist/build-info.json
(commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
(eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
the logo in the existing document-button look, reusing onDocumentOpen and
documentOpenLabel, no duplicate Open in the dropdown), leading slot,
hiddenControls sub-items documentNew / documentOpen / layerFreeze /
layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
SWHeaderPropertyPanel gains children, title, autoFocus and optional
selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
(useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
overview 52/54 with the two pre-existing historical assertions).
Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5faa1f1ffe
commit
55179b4577
138 files changed
+17194
-162
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Package files are LF on every checkout so dist, ai/ and the tarball are byte-identical (core.autocrlf=true included).
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.svg text eol=lf
|
||||
@@ -1,5 +1,7 @@
|
||||
# @hm-design/sw
|
||||
|
||||
> **0.2.0 통합 개선(2026-10-19):** 패키지에 포함된 AI 안내 [ai/AI_USAGE.md](./ai/AI_USAGE.md)·[카탈로그](./ai/COMPONENT_CATALOG.md)가 공개 API의 기준입니다. 추가: `SWPopup` / `SWPopupSection` / `SWPopupRow`(속성창 외형 + `children` 자유 콘텐츠, 여러 Popup Escape 스택, Popup 안 Dropdown 고정 Overlay), `SWHeaderPropertyPanel`의 `children`·`title`·`autoFocus`와 선택형 `selection`/`properties`/`groups`, `SWToolButton` / `SWToolGroup`(기존 글자 토글·pill·파일명 면 재사용), `SWHeader`의 `documentOpenPlacement`(로고 오른쪽 Open, 실측 기반 More 이동)·`leading`, `hiddenControls`의 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`, `showNew`/`showOpen`/`showFreeze`/`showLock`. 모두 선택 Props이며 기존 화면은 그대로입니다. 빌드는 줄바꿈 정규화로 재현 가능하고 `dist/build-info.json`에 출처 Commit·도구 버전을 남깁니다. 검증 기록: 저장소 `docs/sw/sw-integrated-improvement-2026-10-19.md`.
|
||||
|
||||
> **현재 기준 색인(2026-10-19 1차 정리):** 아래 날짜별 안내는 작성 당시 기록입니다. 현재 기준은 [사용 가이드](../../docs/sw/sw-header-usage.md), [디자인 명세](../../docs/sw/sw-header-design-spec.md) 상단 색인, 속성 필드 13개 [검증 기록](../../docs/sw/sw-property-fields-verification-2026-10-19.md), 게이지·속성Bar [후속 4건 기록](../../docs/sw/sw-header-followup-four-design-fixes-verification-2026-10-19.md)·[Firefox 게이지 기록](../../docs/sw/sw-header-firefox-range-close-verification-2026-10-19.md)입니다. Storybook은 `SW / Header` 33개 Story이며 전체 목록과 2차 검토 대상은 [1차 정리 보고](../../docs/sw/sw-first-pass-cleanup-review-2026-10-19.md)에 있습니다.
|
||||
|
||||
> **3차 구현(2026-10-16):** `hiddenControls`(로고 외 모든 구성요소 개별 제거, 로고 필수), `menuOrder`(레이어·색상·선/문자/치수스타일 순서), 속성창 `shapeContent` Slot, 속성창 4상태 개별·비교 Story를 추가했습니다. 미지정 시 기존 디자인과 같습니다. [사용법](../../docs/sw/sw-header-usage.md#구성요소-표시메뉴-순서속성창-4상태형상-slot-2026-10-16-3차), [3차 검증 기록](../../docs/sw/sw-header-composition-shape-verification-2026-10-16.md).
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트·Props·Callback·크기·CSS 의존성 |
|
||||
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형 |
|
||||
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header 조합 방법 |
|
||||
| [examples/](./examples) | 공개 API만 쓰는 예제 A~D(독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
|
||||
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 |
|
||||
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 |
|
||||
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 파일 열기 + 도구 막대 |
|
||||
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
|
||||
|
||||
## 1. 작업 순서 (필수)
|
||||
|
||||
1. **먼저 검색한다.** 요청 기능을 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md)의 공개 컴포넌트에서 찾는다.
|
||||
2. **있으면 재사용한다(1순위).** 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다.
|
||||
2. **있으면 재사용한다(1순위).** 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, `documentOpenPlacement`, `leading`, `tools`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다. 팝업·설정창은 `SWPopup`, 버튼·탭·토글은 `SWToolButton` / `SWToolGroup`이다.
|
||||
- Popup 요청은 이렇게 판단한다: ① `SWPopup`이 프레임(외형·닫기·Escape·스크롤)을 제공한다 → ② 내용은 `children`으로 자유 구성(구조 제한 없음) → ③ 행 모양은 `SWPopupSection` / `SWPopupRow` + `SWHeaderInput` → ④ SW에 없는 컨트롤은 `@hm-design/ui`를 children에 → ⑤ 프레임을 다시 만들거나 없는 Props(`content`, `renderContent` 등)를 추측하지 않는다.
|
||||
3. **없으면 조합한다(2순위).** 개별 공개 컴포넌트(`SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerMenu` …)를 `<SWTheme>` 안에 배치한다. 배치용 CSS만 소비 프로젝트에 작성한다([COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md)).
|
||||
4. **조합으로도 안 될 때만 새로 만든다(3순위).** 새 컴포넌트는 **소비 프로젝트**에 작성하고, 공개 컴포넌트(예: `SWHeaderInput`)를 내부에 재사용하며, 외형은 공개 토큰 `--hm-sw-header-*`만 읽는다([DESIGN_RULES.md](./DESIGN_RULES.md), 예제 D).
|
||||
5. **새 디자인 값을 만들지 않는다.** 크기·색·간격은 토큰과 DESIGN_RULES의 확인값만 쓴다. 값이 없으면 추측하지 말고 사용자에게 확인한다.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# @hm-design/sw 컴포넌트 카탈로그
|
||||
|
||||
기준: `@hm-design/sw` 0.1.0, `dist/index.d.ts` 공개 Export(2026-10-19). 모든 Import는 `from "@hm-design/sw"`이며 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 이 표에 없는 이름은 공개 API가 아닙니다.
|
||||
기준: `@hm-design/sw` 0.2.0, `dist/index.d.ts` 공개 Export(2026-10-19 통합 개선 반영). 출처 Commit·빌드 도구 버전은 `dist/build-info.json`에 있습니다. 모든 Import는 `from "@hm-design/sw"`이며 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 이 표에 없는 이름은 공개 API가 아닙니다.
|
||||
|
||||
- **단독 사용:** `SWHeader` 밖에서는 `<SWTheme>` 안에 둡니다(토큰·글꼴·버튼 초기화).
|
||||
- **상태 소유:** 값은 Props, 사용자 행동은 Callback입니다. 컴포넌트는 CAD 데이터를 바꾸지 않습니다. 호스트가 Callback을 받아 Props를 갱신합니다.
|
||||
- **열림 상태:** Dropdown은 `open` / `onOpenChange`(Controlled)를 주지 않으면 내부 상태로 동작합니다.
|
||||
- **공통 키보드:** Click·Enter·Space 열기/닫기(Hover로는 열리지 않음), ArrowDown 열고 첫(현재) 항목 Focus, Arrow 이동, Escape·바깥 클릭·Tab 이탈 닫기(Escape는 Trigger로 Focus 복귀).
|
||||
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 이번 소비 검증 범위 밖(기존 Storybook 검증 기록만 있음).
|
||||
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 소비 검증 범위 밖(Storybook 검증 기록만 있음).
|
||||
- **무엇을 쓸까:** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). Slider·Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
|
||||
|
||||
## 1. 완성형 템플릿
|
||||
|
||||
@@ -16,7 +17,8 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
|
||||
| 구분 | Props / Callback |
|
||||
|---|---|
|
||||
| 필수 | `documentTitle: string` |
|
||||
| 구성 | `hiddenControls?: SWHeaderHideableControlId[]`(로고 외 부품 제거, `home` 제거 시 정적 로고), `menuOrder?: SWHeaderMenuId[]`(5개 메뉴끼리 순서), `controls?: SWHeaderControlConfig[]`(객체 영역 전체 allowlist·순서·label) |
|
||||
| 구성 | `hiddenControls?: SWHeaderHideableControlId[]`(로고 외 부품 제거, `home` 제거 시 정적 로고. 하위 항목 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`도 제거 — 레이아웃 변화 없음), `menuOrder?: SWHeaderMenuId[]`(5개 메뉴끼리 순서), `controls?: SWHeaderControlConfig[]`(객체 영역 전체 allowlist·순서·label) |
|
||||
| 로고 오른쪽 | `documentOpenPlacement?: "menu" \| "header"`(기본 `menu` = 기존 Dropdown 하단. `header` = 로고 바로 오른쪽에 파일명과 같은 면의 Open 버튼, Dropdown의 Open은 숨김. 둘 다 `onDocumentOpen`·`documentOpenLabel`), `leading?: ReactNode`(Open 뒤·파일명 앞 추가 부품, 예: `SWToolButton appearance="document"`) |
|
||||
| 로고 | `logo?: ReactNode`, `logoSrc?: string`, `logoAlt?: string`, `onHomeClick` |
|
||||
| 파일 | `documents`, `activeDocumentId`, `documentMenuOpen`, `onDocumentMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onDocumentNew`, `onDocumentOpen`, `onDocumentClick`, `documentIcon`, `documentNewLabel`, `documentOpenLabel` |
|
||||
| 레이어 | `layer`, `layers`, `activeLayerId`, `layerMenuOpen`, `onLayerMenuOpenChange`, `onLayerSelect`, `onLayerVisibilityChange`, `onLayerFreezeChange`, `onLayerLockChange`, `onLayerDetail`, `onLayerOpen` |
|
||||
@@ -28,7 +30,8 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
|
||||
| 기타 | `activeMenu`, `disabled`(창 버튼 제외), `drawingModeLabel`, `tools?: ReactNode`(객체 영역 메뉴 뒤), `<header>` HTML 속성·`ref` |
|
||||
|
||||
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
|
||||
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`).
|
||||
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F).
|
||||
- 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다.
|
||||
|
||||
```tsx
|
||||
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
|
||||
@@ -40,36 +43,64 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
|
||||
|---|---|---|---|---|---|
|
||||
| `SWTheme` | 단독 사용 범위(토큰·글꼴·초기화). 배치는 정하지 않음 | `div` HTML 속성, `className`, `style`(토큰 override) | — | — | ✅ |
|
||||
| `SWHeaderHome` | 로고 슬롯 + 홈 버튼 | `logo`, `logoSrc`, `logoAlt`, `onClick`, `interactive`(false = 정적 로고) | 80 × 48, 로고 슬롯 32 × 17, 오른쪽 −17px로 다음 파일 면과 겹침 | Hover 배경 교체, Focus | ✅ |
|
||||
| `SWHeaderDocumentTitle` | 파일명 + 문서 Dropdown(+ New / Open) | `title`(필수), `documents`, `activeDocumentId`, `menuOpen`, `onMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onNew`, `onOpen`, `onClick`, `documentIcon`, `newLabel`, `openLabel`, `label` | 높이 48, 폭 190–310(반응형 clamp), 메뉴 310 | 텍스트만 Hover #FFC600, 열림 화살표 회전 | ✅ |
|
||||
| `SWHeaderLayerMenu` | 레이어 필드 + Dropdown | `value`(필수), `layers`(필수), `activeLayerId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLayerSelect`, `onVisibilityChange`, `onFreezeChange`, `onLockChange`, `onDetail`, `label` | 필드 234 × 38, 메뉴 234 | Hover 링, Selected, Expanded, 행 Hover | ✅ |
|
||||
| `SWHeaderDocumentTitle` | 파일명 + 문서 Dropdown(+ New / Open) | `title`(필수), `documents`, `activeDocumentId`, `menuOpen`, `onMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onNew`, `onOpen`, `onClick`, `documentIcon`, `newLabel`, `openLabel`, `label`, `showNew` / `showOpen`(기본 true, 둘 다 false면 하단 없음) | 높이 48, 폭 190–310(반응형 clamp), 메뉴 310 | 텍스트만 Hover #FFC600, 열림 화살표 회전 | ✅ |
|
||||
| `SWHeaderLayerMenu` | 레이어 필드 + Dropdown | `value`(필수), `layers`(필수), `activeLayerId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLayerSelect`, `onVisibilityChange`, `onFreezeChange`, `onLockChange`, `onDetail`, `label`, `showFreeze` / `showLock`(기본 true. false = 그 상태를 그리지 않고 20px 자리만 유지) | 필드 234 × 38, 메뉴 234 | Hover 링, Selected, Expanded, 행 Hover | ✅ |
|
||||
| `SWHeaderColorMenu` | 색상 필드 + Dropdown(ByLayer/ByBlock, 팔레트, 사용자 색, 추출, RGB 입력) | `value`, `colors`(필수), `userColors`, `mode`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onColorSelect`, `onModeChange`, `onColorInput`, `onPick`, `onDetail`, `label` | 필드 192 × 38 | 칩 선택 시 열림 유지 | ✅ |
|
||||
| `SWHeaderLineStyleMenu` | 선스타일 필드 + 3열 Dropdown | `value`, `lineTypes`, `lineWeights`, `activeLineTypeId`, `activeLineWeightId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLineTypeSelect`, `onLineWeightSelect`, `onLineWeightValueChange`, `onScaleChange`, `onDetail`, `label` | 필드 284 × 38, 열 182·142·142(필드 중앙 정렬) | 열별 선택, 열림 유지 | ✅ |
|
||||
| `SWHeaderTextStyleMenu` | 문자스타일 필드 + 목록 | `value`, `textStyles`(필수), `activeTextStyleId`, `onTextStyleSelect`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onDetail`, `label` | 필드 164 × 38 | 선택 시 닫힘 | ✅ |
|
||||
| `SWHeaderDimensionStyleMenu` | 치수스타일 필드 + 목록 | `value`, `dimensionStyles`(필수), `activeDimensionStyleId`, `onDimensionStyleSelect`, 열림·`onDetail`·`label` 동일 | 필드 164 × 38 | 선택 시 닫힘 | ✅ |
|
||||
| `SWHeaderLayerControl` · `SWHeaderColorControl` · `SWHeaderLineStyle` · `SWHeaderTextStyle` · `SWHeaderDimensionStyle` | Dropdown 없는 필드 버튼(호스트가 자체 팝업을 열 때) | `value`(필수), `label`, `selected`, `disabled`, `onClick`, `expanded`, `controls`, `buttonRef` / `SWHeaderLayerControl`은 `state`(표시·동결·잠금) | 위 필드 크기와 같음 | Hover 링, Selected | ◻ |
|
||||
| `SWHeaderLayerControl` · `SWHeaderColorControl` · `SWHeaderLineStyle` · `SWHeaderTextStyle` · `SWHeaderDimensionStyle` | Dropdown 없는 필드 버튼(호스트가 자체 팝업을 열 때) | `value`(필수), `label`, `selected`, `disabled`, `onClick`, `expanded`, `controls`, `buttonRef` / `SWHeaderLayerControl`은 `state`(표시·동결·잠금), `showFreeze` / `showLock` | 위 필드 크기와 같음 | Hover 링, Selected | ◻ |
|
||||
| `SWHeaderActions` | 인쇄·K 버튼 pill | `onPrint`, `onPopupGuide`, `disabled`, `showPrint`, `showPopupGuide`(하나만 남기면 pill이 축소) | pill 85 × 42(버튼 40 × 38) | 아이콘 Hover, Hover 링, K 가이드 미리보기(반복 Motion, Reduced Motion 정적) | ✅ |
|
||||
| `SWHeaderWindowControls` | 창 이동·최소화·종료 | `onWindowMoveStart`, `onMinimize`, `onClose`, `showWindowMove`, `showMinimize`, `showClose` | 이동 88 × 48, 버튼 32 × 48 | 창 이동 Hover Motion 1.4s, 종료 Hover 자산 | ◻ |
|
||||
| `SWHeaderProperties` | 상세보기 / 속성Bar · 속성창 토글 | `barOpen`, `panelOpen`, `panelId`, `disabled`, `onBarToggle`, `onPanelToggle`, `showCaption`, `showBar`, `showPanel` | 157 × 38 | 텍스트 Hover #FFC600 | ◻ |
|
||||
| `SWHeaderPropertyBar` | 28px 속성 편집 띠 | `selection`, `properties`, `onPropertyChange`, `readOnly`, `disabled`, `div` 속성 | 높이 28, 콘텐츠 폭 | Input 상태 | ◻ |
|
||||
| `SWHeaderPropertyPanel` | 속성창(13개 필드 타입, 그룹, 형상 Slot) | `selection`, `properties`, `groups`, `onPropertyChange`, `menus`, `expandedGroupIds`/`defaultExpandedGroupIds`/`onExpandedGroupsChange`, `toolActive`/`onToolActiveChange`, `toolLabel`, `onOverviewClick`, `shapeContent`, `onClose`, `id`, `anchorRef`, `style` | 고정 위치 Overlay(Portal·Dim 없음) | 4가지 선택 상태 | ◻ |
|
||||
| `SWHeaderPropertyPanel` | 데이터 기반 속성창(필드 타입 6종 `text`·`number`·`select`·`color`·`slider`·`computed` + picker, 그룹 레이아웃 `rows`·`circle`·`position`, 형상 Slot) | `selection`·`properties`·`groups`(0.2.0부터 선택), `title`, `children`(그룹 뒤 자유 콘텐츠), `autoFocus`, `onPropertyChange`, `menus`(`showLayerFreeze`/`showLayerLock` 포함), `expandedGroupIds`/`defaultExpandedGroupIds`/`onExpandedGroupsChange`, `toolActive`/`onToolActiveChange`, `toolLabel`, `onOverviewClick`, `shapeContent`, `onClose`, `id`, `anchorRef`, `style` | 314 폭, 높이는 콘텐츠(최대 화면, 본문 스크롤). `anchorRef`가 있으면 고정 위치 Overlay, 없으면 문서 흐름(위치는 `style`) | 4가지 선택 상태 | ✅ |
|
||||
| `SWHeaderInput` | 수정 가능한 SW Input | `value`(필수), `onValueChange`, `onCommit`(Enter·Blur, 유효 + 변경 시), `onCancel`(Escape 복원), `valueType`(`text`/`number`), `parse`, `readOnly`, `disabled`, `commitOnUnmount`, `input` 속성 | 58 × 20 기준, Hover/편집 24px | Default/Hover/편집/ReadOnly/Disabled, IME 조합 중 Enter 무시 | ✅ |
|
||||
| `parseSWHeaderColor` · `parseSWHeaderNumber` | Input 검증 도우미 | `r,g,b` / `#rrggbb`, 숫자(`min`, `max`) | — | — | ◻ |
|
||||
| `SWHeaderLinePreview` · `swHeaderLinePatterns` | 70 × 2 선 미리보기, CENTER/HIDDEN 패턴 | `pattern?: number[]` | 70 × 2 | — | ◻(패턴 상수만 예제 데이터에서 사용) |
|
||||
|
||||
## 3. 공개 타입
|
||||
## 3. Popup — 외형 고정, 콘텐츠 자유
|
||||
|
||||
`SWHeaderProps`, 각 컴포넌트 `*Props`, `SWThemeProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`.
|
||||
속성창(Figma 26:11822)의 외형과 공통 동작을 그대로 쓰는 Popup입니다. Header 없이 `<SWTheme>` 안에서 씁니다. 내용은 `children`으로 자유롭게 구성합니다(SW 컨트롤, `@hm-design/ui` 컴포넌트, 소비 프로젝트 요소 모두 가능, 구조 제한 없음).
|
||||
|
||||
## 4. 공개하지 않은 것 (사용 금지)
|
||||
| 컴포넌트 | 용도 | 주요 Props | 크기·동작 | 검증 |
|
||||
|---|---|---|---|---|
|
||||
| `SWPopup` | 설정·정보·사용자 정의 Popup 프레임 | `title`(필수), `children`, `onClose`(닫기 버튼·Escape), `closeLabel`(기본 `닫기`), `autoFocus`(열릴 때 Focus, 안에서 닫히면 원래 위치로 복귀), `anchorRef`(기준 요소 아래 고정 Overlay), `style`·`className`·`div` 속성 | 폭 314, 제목줄 30, 높이는 콘텐츠(최대 화면 − 8px, `style.maxHeight`로 더 줄일 수 있음) → 본문 스크롤. Non-modal(Dim·Portal·Focus trap 없음) | ✅ |
|
||||
| `SWPopupSection` | 속성창 그룹 면(제목 + 내용) | `title`, `children`, `section` 속성 | 그룹 제목 700·70% 검정, 내용 면 `--hm-sw-property-content` | ✅ |
|
||||
| `SWPopupRow` | 속성창 한 줄(라벨 + 값) | `label`(필수), `htmlFor`, `children`(값 컨트롤, SW 필드는 `SWHeaderInput`), `unit`, `field`(`box` 기본 · `compact` · `none`), `numeric`(숫자 편집 On), `readOnly`, `disabled` | 행 최소 32, 라벨 72, 값 상자 28(Default·Hover 테두리, 숫자 편집 시 #0F0D0A). `compact` = 41px hug 숫자 필드 | ✅ |
|
||||
| `SWHeaderPropertyPanel` | 데이터 기반 속성창(2절). 같은 프레임이며 `children`도 받음 | 2절 | 같은 DOM·class | ✅ |
|
||||
|
||||
`SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다.
|
||||
- **Escape·Focus:** Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한(Focus·클릭) Popup 하나만 닫습니다. Dropdown·편집 중인 Input처럼 Escape를 이미 처리한 요소(`preventDefault` / 전파 중단)가 있으면 Popup은 닫히지 않습니다.
|
||||
- **Popup 안 SW Dropdown:** 레이어·색상·선·문자·치수 Dropdown은 Popup 안에서 고정 위치 Overlay로 열려 스크롤 본문에 잘리지 않습니다(아래에 자리가 없으면 위로 열림). 단, Header용 어두운 필드 모양 그대로이므로 밝은 Popup 면과 대비가 다릅니다. 속성창 행 모양의 선택 필드가 필요하면 `SWHeaderPropertyPanel`의 `picker` 속성(`properties`)을 씁니다.
|
||||
- **Popup 하단 버튼(적용·취소)의 SW 전용 디자인은 아직 없습니다.** 필요하면 소비 프로젝트 버튼이나 `@hm-design/ui`의 `Button`을 `children`에 둡니다. 어두운 `SWToolButton`을 밝은 Popup에 강제로 쓰지 않습니다.
|
||||
|
||||
## 5. 알려진 단독 사용 제약
|
||||
## 4. 도구 막대 — 기존 SW 버튼 3종
|
||||
|
||||
새 디자인 값 없이 기존 SW 버튼 모양을 공개 버튼으로 씁니다(뷰어의 Model/Layout, Fit, 2.5D, 파일 열기 등). `SWHeader`의 `tools`·`leading` 안이나 `<SWTheme>` 안에 둡니다.
|
||||
|
||||
| 컴포넌트 | 용도 | 주요 Props | 모양(기존 기준) | 검증 |
|
||||
|---|---|---|---|---|
|
||||
| `SWToolButton` | 버튼·토글 | `appearance`(`text` 기본 / `pill` / `document`), `pressed`(토글·선택), `children`(글자. `pill`은 접근성 이름으로만 사용), `icon`·`hoverIcon`(`pill`), `button` 속성 | `text` = 속성Bar·속성창 글자 토글(700, Hover·선택 #FFC600, 행 19px). `pill` = 인쇄·K 40 × 38(노란 Hover 링, 아이콘 24 × 23 영역). `document` = 파일명 면(48px, 글자만 Hover #FFC600). Home 바로 뒤에 두면 로고 사선 접점을 그대로 공유 | ✅ |
|
||||
| `SWToolGroup` | 버튼 묶음 | `appearance`(`text` 기본 = 상세보기 묶음, `pill` = 인쇄·K pill), `label`(`text`의 12px 캡션·접근성 이름), `selection`(`single` = radiogroup, 화살표·Home·End로 이동·선택), `div` 속성 | 버튼 사이 1px 구분선(`text`), 내용 폭 pill | ✅ |
|
||||
|
||||
- 탭(Model / Layout)은 별도 컴포넌트 없이 `SWToolGroup selection="single"` + `SWToolButton pressed`입니다.
|
||||
- 상태 표시(로드 상태 등) 전용 SW 디자인은 아직 없습니다(상태 표시 컴포넌트는 디자인 확정 후 검토).
|
||||
|
||||
## 5. 공개 타입
|
||||
|
||||
`SWHeaderProps`, 각 컴포넌트 `*Props`, `SWThemeProps`, `SWPopupProps`, `SWPopupSectionProps`, `SWPopupRowProps`, `SWToolButtonProps`, `SWToolButtonAppearance`, `SWToolGroupProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderSubControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`.
|
||||
|
||||
## 6. 공개하지 않은 것 (사용 금지)
|
||||
|
||||
`SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, `SWPopupFrame`, `useSWFixedDropdownPosition`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다.
|
||||
|
||||
## 7. 알려진 단독 사용 제약
|
||||
|
||||
| 항목 | 내용 | 대응 |
|
||||
|---|---|---|
|
||||
| 반응형 More | `SWHeader` 전용. 조합 Header에서는 More·필드 이동이 없음 | 조합 Header의 폭은 소비 앱이 정함(필요 없는 컨트롤을 빼거나 가로 공간 확보) |
|
||||
| 반응형 More | `SWHeader` 전용. `SWTheme`로 직접 배치한 조합 Header에는 More·필드 이동이 없음 | 반응형이 필요하면 `SWHeader`의 `controls`·`documentOpenPlacement`·`leading`·`tools`로 구성(예제 F). 직접 배치하면 폭은 소비 앱이 정함 |
|
||||
| `SWHeaderDocumentTitle` 왼쪽 테두리 | 앞의 로고와 겹치는 전제로 왼쪽 테두리가 없음 | 로고 없이 첫 칸이면 소비 레이아웃 wrapper에 1px 테두리(예제 C) |
|
||||
| `SWHeaderDocumentTitle` 폭 | `clamp(190px, 100cqw − 1610px, 310px)`; Header 밖에서는 cqw가 뷰포트 기준 | flex 부모 안에 둔다. 1920 뷰포트에서 310px |
|
||||
| 창 영역 배경 | 배경·테두리는 `SWHeader`의 창 영역에 있음 | 소비 레이아웃이 토큰으로 표면 작성(예제 B·C) |
|
||||
| Dropdown 위치 | `SWHeader` 안이면 헤더, 밖이면 뷰포트 안으로 좌우 보정 | 부모에 `overflow: hidden`을 두지 않는다 |
|
||||
| Dropdown 위치 | `SWHeader` 안이면 헤더, 밖이면 뷰포트 안으로 좌우 보정. `SWPopup`·속성창 안이면 고정 위치 Overlay | Popup 밖에서는 부모에 `overflow: hidden`을 두지 않는다 |
|
||||
| 파일 열기 뒤 파일명 여백 | `SWToolButton appearance="document"` 바로 뒤의 파일명은 왼쪽 여백 12px(로고 아래가 아니므로) | 패키지 CSS가 처리. 소비 코드에서 조정하지 않음 |
|
||||
@@ -10,6 +10,8 @@
|
||||
| 기존 컨트롤 일부만, 다른 배치·순서 | 2. 조합 Header |
|
||||
| 기존 Dropdown을 툴바·패널 등 다른 곳에 | 3. 개별 컨트롤 |
|
||||
| 패키지에 없는 기능 | 4. 확장(소비 프로젝트 신규 컴포넌트) |
|
||||
| SW Popup 디자인의 설정창·정보창·사용자 정의 폼 | 5. Popup(외형 고정, 콘텐츠 자유) |
|
||||
| 로고 옆 파일 열기, 뷰어 도구 막대(Model/Layout, Fit, 2.5D) | 6. 파일 열기 + 도구 막대 |
|
||||
|
||||
## 1. 완성형 SW Header — [ExampleA-DefaultHeader.tsx](./examples/ExampleA-DefaultHeader.tsx)
|
||||
|
||||
@@ -73,7 +75,48 @@
|
||||
|
||||
새 컨트롤은 패키지 공식 컴포넌트로 자동 등록하지 않습니다. 여러 프로젝트에서 반복되면 SW 패키지 승격을 별도로 검토합니다.
|
||||
|
||||
## 5. 하지 말 것
|
||||
## 5. Popup — 외형 고정, 콘텐츠 자유 — [ExampleE-CustomPopup.tsx](./examples/ExampleE-CustomPopup.tsx)
|
||||
|
||||
요청 예: "HMDesign SW Popup 디자인을 사용해서 내부에 새로운 설정 폼을 만들어 줘."
|
||||
|
||||
1. 카탈로그 3절에서 `SWPopup`을 찾는다(프레임 = 속성창 외형·닫기·Escape·스크롤).
|
||||
2. 내용은 `children`으로 구성한다. 행 모양은 `SWPopupSection` + `SWPopupRow`, 값 입력은 `SWHeaderInput`.
|
||||
3. SW에 없는 컨트롤(Switch, Slider, Select, Button 등)은 `@hm-design/ui`를 `children` 안에 그대로 둔다. 구조 제한은 없다.
|
||||
4. 프레임(폭, 제목줄, 닫기, 표면색)을 다시 만들지 않고, `hm-sw-property*` class를 쓰지 않는다.
|
||||
|
||||
```tsx
|
||||
<SWTheme>
|
||||
<SWPopup title="도면 설정" onClose={close}>
|
||||
<SWPopupSection title="도면">
|
||||
<SWPopupRow label="이름" htmlFor={id}><SWHeaderInput id={id} value={name} onCommit={setName} /></SWPopupRow>
|
||||
<SWPopupRow label="축척" unit="%" numeric><SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={setScale} /></SWPopupRow>
|
||||
</SWPopupSection>
|
||||
<MyProjectFields />
|
||||
</SWPopup>
|
||||
</SWTheme>
|
||||
```
|
||||
|
||||
- 데이터 기반 속성창이 필요하면 `SWHeaderPropertyPanel`(`properties` / `groups`)을 쓰고, 추가 내용은 같은 컴포넌트의 `children`으로 붙인다.
|
||||
- 위치는 호스트가 정한다: 문서 흐름(`style`) 또는 `anchorRef`(기준 요소 아래 고정 Overlay). 열고 닫는 상태도 호스트가 가진다(조건부 렌더링).
|
||||
- 여러 Popup을 동시에 띄워도 Escape는 하나씩 닫힌다(카탈로그 3절).
|
||||
|
||||
## 6. 파일 열기 + 도구 막대 — [ExampleF-ViewerToolbar.tsx](./examples/ExampleF-ViewerToolbar.tsx)
|
||||
|
||||
```tsx
|
||||
<SWHeader documentTitle={name} documents={documents} documentOpenPlacement="header" documentOpenLabel="파일 열기" onDocumentOpen={open}
|
||||
controls={[{ id: "document" }, { id: "layer" }, { id: "color" }, { id: "popupGuide" }]}
|
||||
hiddenControls={["documentNew", "layerFreeze", "layerLock"]}
|
||||
tools={<SWToolGroup label="보기" selection="single">
|
||||
<SWToolButton pressed={view === "model"} onClick={showModel}>Model</SWToolButton>
|
||||
<SWToolButton pressed={view === "layout"} onClick={showLayout}>Layout</SWToolButton>
|
||||
</SWToolGroup>} />
|
||||
```
|
||||
|
||||
- 파일 열기는 `tools`에 직접 만들지 않는다. `documentOpenPlacement="header"`가 로고 바로 오른쪽에 파일명과 같은 면으로 그리고 Dropdown의 Open은 숨긴다(중복 없음). 공간이 부족하면 More로 이동한다.
|
||||
- 지원하지 않는 기능은 `hiddenControls`로 뺀다(`documentNew`, `documentOpen`, `layerFreeze`, `layerLock`). 가짜 버튼을 두지 않는다.
|
||||
- `SWHeader` 없이 같은 순서가 필요하면: `SWHeaderHome` → `SWToolButton appearance="document"` → `SWHeaderDocumentTitle showOpen={false}`(사선 접점·12px 여백은 패키지가 처리). 이 경우 More는 없다.
|
||||
|
||||
## 7. 하지 말 것
|
||||
|
||||
- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기
|
||||
- `dist` 구현·`styles.css` 블록 복사로 같은 컴포넌트 재작성
|
||||
|
||||
@@ -84,7 +84,33 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
|
||||
|
||||
조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다.
|
||||
|
||||
## 8. 새 컴포넌트 체크리스트
|
||||
## 8. Popup (속성창 프레임, `SWPopup` / `SWHeaderPropertyPanel`)
|
||||
|
||||
프로젝트마다 내용이 달라도 아래 외형은 그대로 유지됩니다(프레임은 패키지가 그림). 새 Popup 화면은 `SWPopup`을 쓰고 프레임을 다시 만들지 않습니다.
|
||||
|
||||
| 항목 | 값 | 토큰 |
|
||||
|---|---|---|
|
||||
| 폭 / 높이 | 314px / 콘텐츠 높이(최대 화면 − 8px), 넘치면 본문 세로 스크롤 | — |
|
||||
| 표면 / 테두리 / radius | `#E4DBC9` / 1px `#A67D03` / 4px, 안쪽 1px 검정 그림자 | `--hm-sw-property-surface`, `--hm-sw-property-border` |
|
||||
| 제목줄 | 30px, Figma 제목 아트, 제목 14px 700 `#FFC600` + 검정 외곽선, 닫기 44 × 30(Hover 자산) | `--hm-sw-header-selected-color` |
|
||||
| 본문 | padding 7px, 그룹 사이 8px | — |
|
||||
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px | `--hm-sw-property-content` |
|
||||
| 행(`SWPopupRow`) | 최소 32px, 라벨 72px 700 검정, 값 글자 `#E60403` 500, 단위 12px | `--hm-sw-property-label`, `--hm-sw-header-input-color` |
|
||||
| 값 상자 | 28px, Hover 1px `rgba(33,29,19,.4)`, 숫자 편집 On `#0F0D0A`(단위 흰색), Dropdown On `#211D13` | `--hm-sw-property-field-*` |
|
||||
| 동작 | Non-modal(Dim·Portal·Focus trap 없음). Escape는 Popup 안이면 그 Popup, 밖이면 최근 사용 Popup 하나. `autoFocus`면 열릴 때 Focus, 안에서 닫히면 원래 위치로 | — |
|
||||
|
||||
- 밝은 Popup 면에는 SW Header의 어두운 컨트롤(필드·`SWToolButton`)이 디자인상 맞지 않습니다. 쓸 수는 있지만(Dropdown은 고정 Overlay로 잘리지 않음) 행 모양이 필요하면 `SWPopupRow` + `SWHeaderInput` 또는 속성창 `picker`를 씁니다.
|
||||
- Popup 하단 버튼의 SW 디자인은 없습니다. 임의 색·크기로 만들지 말고 `@hm-design/ui` `Button` 등 기존 컴포넌트를 쓰거나 사용자에게 디자인을 확인합니다.
|
||||
|
||||
## 9. 도구 버튼 (`SWToolButton` / `SWToolGroup`)
|
||||
|
||||
| appearance | 기존 기준 | 상태 |
|
||||
|---|---|---|
|
||||
| `text` | 속성Bar·속성창 토글: 19px 행, 14px 700, 자간 0.04em, 묶음 위 12px 캡션, 버튼 사이 1px 구분선 | Hover·`pressed` 글자 `#FFC600`, 테두리·배경 변화 없음 |
|
||||
| `pill` | 인쇄·K: pill(`rgba(0,0,0,.8)`, 1px `#092920`, radius 4px) 안 40 × 38 버튼, 아이콘 영역 24 × 23 | Hover 노란 gradient 링 + `hoverIcon`. 아이콘 색은 기존 인쇄 아이콘 `#B6D0C9` → Hover `#FFC600`(`--hm-sw-header-selected-color`) |
|
||||
| `document` | 파일명 면: 48px, gradient 표면, 위·오른쪽·아래 1px `#0E4C3A`, 글자 `#F0F7F5` 700 · 자간 0.06em | 글자만 Hover `#FFC600`. Home 바로 뒤면 로고 사선 접점 공유, 그 뒤 파일명은 왼쪽 여백 12px |
|
||||
|
||||
## 10. 새 컴포넌트 체크리스트
|
||||
|
||||
1. `<SWTheme>`(또는 `SWHeader`의 `tools`) 안에서 렌더링한다.
|
||||
2. 표면·크기·색은 위 토큰만 쓴다. 필드형이면 38px·속성 필드 면·12px 라벨 + 14px 700 값.
|
||||
@@ -92,3 +118,5 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
|
||||
4. 입력이 필요하면 `SWHeaderInput`을 재사용한다.
|
||||
5. 값은 Props, 동작은 Callback. CAD 데이터를 내부에서 바꾸지 않는다.
|
||||
6. `hm-sw-*` class를 새로 쓰거나 덮어쓰지 않는다(자기 접두사 사용, 예: `app-`).
|
||||
7. 버튼·탭·토글이면 먼저 `SWToolButton`의 세 모양(§9)으로 해결되는지 본다. 팝업·설정창이면 `SWPopup` + `SWPopupSection` / `SWPopupRow`(§8).
|
||||
8. 팝업 안의 새 콘텐츠는 §8의 Popup 토큰(`--hm-sw-property-*`)만 읽는다. Header용 어두운 토큰을 밝은 Popup 면에 섞지 않는다.
|
||||
@@ -0,0 +1,40 @@
|
||||
// Example E — the SW popup frame with project content (no Header). SWPopup keeps the property window design;
|
||||
// children are free: SWPopupSection / SWPopupRow rows with SWHeaderInput, and an existing SW dropdown.
|
||||
import { useId, useState } from "react"
|
||||
import { SWHeaderLayerMenu, SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
|
||||
import { layers } from "./sample-data"
|
||||
|
||||
export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const nameId = useId()
|
||||
const [name, setName] = useState("평면도-01")
|
||||
const [scale, setScale] = useState("100")
|
||||
const [x, setX] = useState("1200.5")
|
||||
const [activeLayerId, setActiveLayerId] = useState("0")
|
||||
if (!open) return <button type="button" onClick={() => setOpen(true)}>설정 열기</button>
|
||||
return (
|
||||
<SWTheme>
|
||||
<SWPopup title="도면 설정" onClose={() => { setOpen(false); onEvent?.("popup:close") }}>
|
||||
<SWPopupSection title="도면">
|
||||
<SWPopupRow label="이름" htmlFor={nameId}>
|
||||
<SWHeaderInput id={nameId} value={name} onCommit={next => { setName(next); onEvent?.(`name:${next}`) }} />
|
||||
</SWPopupRow>
|
||||
<SWPopupRow label="축척" unit="%" numeric>
|
||||
<SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={next => { setScale(next); onEvent?.(`scale:${next}`) }} />
|
||||
</SWPopupRow>
|
||||
</SWPopupSection>
|
||||
<SWPopupSection title="객체 정보">
|
||||
<SWPopupRow label="타입" readOnly><SWHeaderInput aria-label="타입" value="Line" readOnly /></SWPopupRow>
|
||||
<SWPopupRow label="X" unit="mm" numeric>
|
||||
<SWHeaderInput aria-label="X" valueType="number" value={x} onCommit={next => { setX(next); onEvent?.(`x:${next}`) }} />
|
||||
</SWPopupRow>
|
||||
</SWPopupSection>
|
||||
<SWPopupSection title="레이어">
|
||||
{/* Inside a popup the dropdown opens as a fixed overlay, so the scrolling body never clips it. */}
|
||||
<SWHeaderLayerMenu value={{ name: "0" }} layers={layers} activeLayerId={activeLayerId}
|
||||
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }} />
|
||||
</SWPopupSection>
|
||||
</SWPopup>
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Example F — a viewer header: logo → Open → file → layer → color → K, plus a tool bar (Model / Layout, 2.5D,
|
||||
// Fit) after the menus. Everything is SWHeader props, so the existing responsive More serves the whole layout
|
||||
// (Open moves into More when the measured header has no room). No freeze / lock, no "+ New" in this product.
|
||||
import { useState } from "react"
|
||||
import { SWHeader, SWToolButton, SWToolGroup } from "@hm-design/sw"
|
||||
import { colors, documents, layers } from "./sample-data"
|
||||
|
||||
// The program supplies its icons (24 x 23 box of a pill button). Colors: the existing print icon fill #B6D0C9,
|
||||
// hover --hm-sw-header-selected-color (#FFC600), as in DESIGN_RULES §2.
|
||||
const FitIcon = ({ color }: { color: string }) => (
|
||||
<svg viewBox="0 0 24 23" width="24" height="23" aria-hidden="true">
|
||||
<path d="M2 8V2h6M16 2h6v6M22 15v6h-6M8 21H2v-6" fill="none" stroke={color} strokeWidth="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function ExampleFViewerToolbar({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||
const [activeLayerId, setActiveLayerId] = useState("0")
|
||||
const [view, setView] = useState<"model" | "layout">("model")
|
||||
const [depth, setDepth] = useState(false)
|
||||
return (
|
||||
<SWHeader
|
||||
documentTitle={documents[0].name}
|
||||
documents={documents}
|
||||
activeDocumentId="doc-1"
|
||||
documentOpenPlacement="header"
|
||||
documentOpenLabel="파일 열기"
|
||||
onDocumentOpen={() => onEvent?.("document:open")}
|
||||
onDocumentSelect={document => onEvent?.(`document:${document.id}`)}
|
||||
controls={[{ id: "document" }, { id: "layer" }, { id: "color" }, { id: "popupGuide" }]}
|
||||
hiddenControls={["documentNew", "layerFreeze", "layerLock"]}
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
|
||||
colors={colors}
|
||||
onColorSelect={option => onEvent?.(`color:${option.id}`)}
|
||||
tools={<>
|
||||
<SWToolGroup label="보기" selection="single">
|
||||
<SWToolButton pressed={view === "model"} onClick={() => { setView("model"); onEvent?.("view:model") }}>Model</SWToolButton>
|
||||
<SWToolButton pressed={view === "layout"} onClick={() => { setView("layout"); onEvent?.("view:layout") }}>Layout</SWToolButton>
|
||||
</SWToolGroup>
|
||||
<SWToolGroup label="표시">
|
||||
<SWToolButton pressed={depth} onClick={() => { setDepth(!depth); onEvent?.(`2.5d:${!depth}`) }}>2.5D</SWToolButton>
|
||||
</SWToolGroup>
|
||||
<SWToolGroup appearance="pill" aria-label="화면">
|
||||
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />} onClick={() => onEvent?.("fit")}>화면 맞춤</SWToolButton>
|
||||
</SWToolGroup>
|
||||
</>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -55,3 +55,33 @@ async function finishDeclarations(directory) {
|
||||
}
|
||||
}
|
||||
await finishDeclarations(output)
|
||||
|
||||
// Reproducibility: the same source and the same locked tools give byte-identical dist on any path or
|
||||
// checkout. Sources are copied as written (styles.css keeps sw.css bytes), so CRLF checkouts
|
||||
// (core.autocrlf=true) would change the output; every emitted text file is normalized to LF.
|
||||
async function normalizeLineEndings(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) await normalizeLineEndings(file)
|
||||
else if (/\.(js|css|d\.ts|json)$/.test(entry.name)) {
|
||||
const text = await readFile(file, "utf8")
|
||||
if (text.includes("\r")) await writeFile(file, text.replace(/\r\n?/g, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
await normalizeLineEndings(output)
|
||||
|
||||
// Provenance without timestamps: source commit (SW_BUILD_COMMIT overrides; "unknown" outside git),
|
||||
// whether packages/sw had uncommitted changes, and the locked build tool versions.
|
||||
const git = args => { try { return execFileSync("git", args, { cwd: packageRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() } catch { return null } }
|
||||
const toolVersion = name => { try { return JSON.parse(execFileSync(process.execPath, ["-p", `JSON.stringify(require("${name}/package.json").version)`], { cwd: packageRoot, encoding: "utf8" })) } catch { return null } }
|
||||
const commit = process.env.SW_BUILD_COMMIT ?? git(["rev-parse", "HEAD"]) ?? "unknown"
|
||||
// With SW_BUILD_COMMIT the caller vouches for the source; dirty is then unknown (null), never a guess.
|
||||
const status = process.env.SW_BUILD_COMMIT ? null : git(["status", "--porcelain", "--", "."])
|
||||
await writeFile(path.join(output, "build-info.json"), JSON.stringify({
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
commit,
|
||||
dirty: status === null ? null : status.length > 0,
|
||||
tools: { vite: toolVersion("vite"), rolldown: toolVersion("rolldown"), typescript: toolVersion("typescript") },
|
||||
}, null, 2) + "\n")
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hm-design/sw",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": false,
|
||||
"description": "HM Design System SW (EasyBeam-style desktop software) components. Independent of @hm-design/ui.",
|
||||
"type": "module",
|
||||
|
||||
@@ -19,7 +19,8 @@ import printHover from "../../assets/print-hover.svg"
|
||||
import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
|
||||
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
|
||||
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
|
||||
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps } from "./types"
|
||||
import { SWToolButton } from "../sw-tool/SWTool"
|
||||
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
|
||||
|
||||
const defaults = {
|
||||
layer: { name: "0" },
|
||||
@@ -37,6 +38,7 @@ const menuSlot: Record<SWHeaderMenuId, string> = {
|
||||
const defaultMenuOrder = Object.keys(menuSlot) as SWHeaderMenuId[]
|
||||
const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ...defaultMenuOrder, "details", "propertyBar", "propertyPanel", "print", "popupGuide"] as const).map(id => ({ id }))
|
||||
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
|
||||
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
|
||||
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
|
||||
|
||||
/**
|
||||
@@ -73,6 +75,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
documentIcon,
|
||||
documentNewLabel,
|
||||
documentOpenLabel,
|
||||
documentOpenPlacement = "menu",
|
||||
leading,
|
||||
drawingModeLabel = "그리기모드",
|
||||
layer = defaults.layer,
|
||||
layers,
|
||||
@@ -161,7 +165,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
|
||||
// Default design with removed object parts: same structure and breakpoints, but the script decides what
|
||||
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
|
||||
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId))
|
||||
// Sub-items live inside a part and never change the layout mode.
|
||||
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
|
||||
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
|
||||
const presentRef = useRef(new Set(configuration.map(item => item.id)))
|
||||
presentRef.current = new Set(configuration.map(item => item.id))
|
||||
const label = (id: SWHeaderControlId, fallback: string) => configuration.find(item => item.id === id)?.label ?? fallback
|
||||
@@ -204,6 +210,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
|
||||
const [headerWidth, setHeaderWidth] = useState(1920)
|
||||
const [barOffset, setBarOffset] = useState(0)
|
||||
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
|
||||
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
|
||||
const leadingRef = useRef<HTMLDivElement>(null)
|
||||
const [openCollapsed, setOpenCollapsed] = useState(false)
|
||||
const collapseRef = useRef<{ at: number; width: number } | null>(null)
|
||||
const openShown = openInHeader && !openCollapsed
|
||||
const hasLeading = openShown || (leading !== undefined && leading !== null && leading !== false)
|
||||
useLayoutEffect(() => {
|
||||
const header = fieldsRef.current?.closest(".hm-sw-header")
|
||||
if (!header) return
|
||||
@@ -218,6 +231,35 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
if (panelAnchorRef.current) observer.observe(panelAnchorRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
|
||||
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
|
||||
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
|
||||
useLayoutEffect(() => {
|
||||
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
|
||||
if (!header) return
|
||||
const leadingWidth = leadingRef.current ? leadingRef.current.getBoundingClientRect().width : 0
|
||||
const value = hasLeading ? `${leadingWidth + 2}px` : ""
|
||||
if (header.style.getPropertyValue("--hm-sw-header-leading-width") !== value) {
|
||||
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
|
||||
else header.style.removeProperty("--hm-sw-header-leading-width")
|
||||
}
|
||||
if (!openInHeader) {
|
||||
collapseRef.current = null
|
||||
if (openCollapsed) setOpenCollapsed(false)
|
||||
return
|
||||
}
|
||||
if (!openCollapsed) {
|
||||
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
|
||||
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
|
||||
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
|
||||
if (overflow && open) {
|
||||
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
|
||||
setOpenCollapsed(true)
|
||||
}
|
||||
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
|
||||
setOpenCollapsed(false)
|
||||
}
|
||||
})
|
||||
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
|
||||
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
|
||||
const validPromoted = promoted && enabled(promoted) ? promoted : null
|
||||
@@ -302,7 +344,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
if (!item) return []
|
||||
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
|
||||
return [{ ...item, label: config.label ?? item.label, visible: scripted ? hidden : undefined }]
|
||||
})
|
||||
}).concat(openInHeader ? [{ id: "documentOpen", label: documentOpenLabel ?? "Open", tier: "action" as const, disabled, visible: openCollapsed, onSelect: onDocumentOpen }] : [])
|
||||
const fieldNodes: Record<SWHeaderMenuId, ReactNode> = {
|
||||
layer: (layers ? (
|
||||
<SWHeaderLayerMenu label={label("layer", "레이어")}
|
||||
@@ -318,9 +360,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
onFreezeChange={onLayerFreezeChange}
|
||||
onLockChange={onLayerLockChange}
|
||||
onDetail={onLayerDetail}
|
||||
{...layerStates}
|
||||
/>
|
||||
) : (
|
||||
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} />
|
||||
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} {...layerStates} />
|
||||
)),
|
||||
color: (colors ? (
|
||||
<SWHeaderColorMenu label={label("color", "색상")}
|
||||
@@ -404,6 +447,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
onDocumentClose={onDocumentClose}
|
||||
onNew={onDocumentNew}
|
||||
onOpen={onDocumentOpen}
|
||||
showNew={shown("documentNew")}
|
||||
showOpen={shown("documentOpen") && !openInHeader}
|
||||
documentIcon={documentIcon}
|
||||
newLabel={documentNewLabel}
|
||||
openLabel={documentOpenLabel}
|
||||
@@ -424,16 +469,21 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
const windowReduced = removed.size > 0 && (frameControls.some(id => removed.has(id)) || (controls === undefined && !(enabled("print") && enabled("popupGuide"))))
|
||||
// CSS-only default layout: a field holding the first slot instead of the removed layer is styled as promoted.
|
||||
const promotedSlot = validPromoted ?? (!scripted && baseMenu && baseMenu !== "layer" ? baseMenu : null)
|
||||
const barData = propertyBar ?? (propertyPanel && {
|
||||
// The bar shares the panel data only when the panel has a selection (a content-only panel has no bar rows).
|
||||
const barData = propertyBar ?? (propertyPanel?.selection && {
|
||||
selection: propertyPanel.selection,
|
||||
properties: propertyPanel.properties,
|
||||
properties: propertyPanel.properties ?? [],
|
||||
onPropertyChange: propertyPanel.onPropertyChange,
|
||||
})
|
||||
|
||||
return (
|
||||
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} {...rest}>
|
||||
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
|
||||
<div className="hm-sw-header__bar">
|
||||
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
|
||||
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
|
||||
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
|
||||
{leading}
|
||||
</div>}
|
||||
{controls === undefined && enabled("document") && documentNode}
|
||||
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
|
||||
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
|
||||
@@ -446,7 +496,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
|
||||
{tools}
|
||||
</div>
|
||||
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} />}
|
||||
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
|
||||
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
|
||||
</div>
|
||||
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
|
||||
@@ -455,7 +505,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
||||
</div>}
|
||||
</div>
|
||||
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
|
||||
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
|
||||
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
|
||||
</header>
|
||||
)
|
||||
})
|
||||
@@ -33,6 +33,12 @@ export interface SWHeaderDocumentTitleProps {
|
||||
documentIcon?: ReactNode
|
||||
newLabel?: string
|
||||
openLabel?: string
|
||||
/**
|
||||
* Footer actions for products without that function (default true). Hiding both removes the footer;
|
||||
* hiding Open avoids a duplicate when the Header shows its own Open button next to the logo.
|
||||
*/
|
||||
showNew?: boolean
|
||||
showOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +61,11 @@ export function SWHeaderDocumentTitle({
|
||||
documentIcon: iconOverride,
|
||||
newLabel = "+ New",
|
||||
openLabel = "Open",
|
||||
showNew = true,
|
||||
showOpen = true,
|
||||
}: SWHeaderDocumentTitleProps) {
|
||||
const hasMenu = documents !== undefined
|
||||
// Nothing to list and no footer action: the file name stays a plain button (no empty dropdown).
|
||||
const hasMenu = documents !== undefined && (documents.length > 0 || showNew || showOpen)
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = hasMenu && (menuOpen ?? internalOpen)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
@@ -173,10 +182,10 @@ export function SWHeaderDocumentTitle({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="hm-sw-header__document-footer">
|
||||
<button type="button" className="hm-sw-header__document-action" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>
|
||||
<button type="button" className="hm-sw-header__document-action" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>
|
||||
</div>
|
||||
{(showNew || showOpen) && <div className="hm-sw-header__document-footer">
|
||||
{showNew && <button type="button" className="hm-sw-header__document-action" data-action="new" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>}
|
||||
{showOpen && <button type="button" className="hm-sw-header__document-action" data-action="open" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>}
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type RefObject } from "react"
|
||||
import detailIcon from "../../../assets/layer-detail.svg"
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react"
|
||||
|
||||
@@ -9,6 +9,45 @@ export interface SWHeaderDropdownOptions {
|
||||
disabled?: boolean
|
||||
/** `start`: left edge under the field (color, text, dimension). `center`: centered under it (line style). */
|
||||
align?: "start" | "center"
|
||||
/** Inside an SW popup (scrolling, clipping body) the list becomes a fixed overlay. Default true. */
|
||||
escapePopup?: boolean
|
||||
}
|
||||
|
||||
export interface SWFixedDropdownPosition { left: number; top: number; flipped: boolean; offset: number }
|
||||
|
||||
/**
|
||||
* Fixed overlay position of a field dropdown, kept on screen: below the field (or above it when only there is
|
||||
* room), aligned to the field start / end / center and shifted sideways only at the screen edge. Used by the
|
||||
* property window pickers and by every field dropdown placed inside an SW popup, whose body scrolls and clips.
|
||||
* `offset` is the panel left relative to the field.
|
||||
*/
|
||||
export function useSWFixedDropdownPosition(open: boolean, rootRef: RefObject<HTMLElement | null>, panelRef: RefObject<HTMLElement | null>, align: "start" | "end" | "center", enabled = true) {
|
||||
const [position, setPosition] = useState<SWFixedDropdownPosition | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !enabled) { setPosition(null); return }
|
||||
const update = () => {
|
||||
const field = rootRef.current?.getBoundingClientRect()
|
||||
const panel = panelRef.current
|
||||
if (!field || !panel) return
|
||||
const width = panel.offsetWidth, height = panel.offsetHeight
|
||||
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
|
||||
const start = align === "end" ? field.right - width : align === "center" ? field.left + Math.round((field.width - width) / 2) : field.left
|
||||
const left = Math.max(4, Math.min(start, viewportWidth - width - 4))
|
||||
const below = field.bottom
|
||||
const flipped = below + height > viewportHeight - 4 && field.top - height >= 4
|
||||
const top = flipped ? field.top - height : below
|
||||
const offset = Math.round(left - field.left)
|
||||
setPosition(current => (current && current.left === left && current.top === top && current.flipped === flipped ? current : { left, top, flipped, offset }))
|
||||
}
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
window.addEventListener("scroll", update, true)
|
||||
return () => {
|
||||
window.removeEventListener("resize", update)
|
||||
window.removeEventListener("scroll", update, true)
|
||||
}
|
||||
}, [open, enabled, align, rootRef, panelRef])
|
||||
return position
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,7 +57,7 @@ export interface SWHeaderDropdownOptions {
|
||||
* a click outside / Tab out close. The list overlays below the field and is shifted horizontally only
|
||||
* when it would leave the header (or the screen).
|
||||
*/
|
||||
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start" }: SWHeaderDropdownOptions) {
|
||||
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start", escapePopup = true }: SWHeaderDropdownOptions) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = !disabled && (openProp ?? internalOpen)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
@@ -70,6 +109,9 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
|
||||
return () => window.removeEventListener("resize", update)
|
||||
}) // Reposition after a host-driven control reorder as well as open/resize.
|
||||
|
||||
const inPopup = open && escapePopup && !!rootRef.current?.closest(".hm-sw-property")
|
||||
const fixed = useSWFixedDropdownPosition(open, rootRef, panelRef, align, inPopup)
|
||||
|
||||
/** Focusable entries of the panel in DOM order (rows, inputs, footer). */
|
||||
const entries = () => Array.from(panelRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)") ?? [])
|
||||
|
||||
@@ -120,9 +162,11 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
|
||||
triggerRef,
|
||||
menuId,
|
||||
/** Panel `left` relative to the field (centered and / or shifted to stay on screen). */
|
||||
panelStyle: position.left !== 0 ? { left: position.left } : undefined,
|
||||
panelStyle: fixed ? { position: "fixed" as const, left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" as const } : position.left !== 0 ? { left: position.left } : undefined,
|
||||
/** Panel left relative to the field (also in the fixed popup overlay). */
|
||||
panelOffset: fixed ? fixed.offset : position.left,
|
||||
/** The panel no longer sits under the field (the outline then rings the panel only). */
|
||||
shifted: position.left !== position.base,
|
||||
shifted: fixed ? fixed.flipped || fixed.offset !== position.base : position.left !== position.base,
|
||||
rootProps: { ref: rootRef, onKeyDown, onBlur, "data-open": open ? "true" : undefined },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,18 @@ export interface SWHeaderLayerControlProps extends FieldBase {
|
||||
value: SWHeaderLayerValue
|
||||
/** State of the current layer for the three field icons (gray when off). Defaults to all colored. */
|
||||
state?: Pick<SWHeaderLayer, "visible" | "frozen" | "locked">
|
||||
/** Products without freeze / lock: the icon is not drawn, its 20px place stays (default true). */
|
||||
showFreeze?: boolean
|
||||
showLock?: boolean
|
||||
}
|
||||
|
||||
export function SWHeaderLayerControl({ value, state, label = "레이어", ...rest }: SWHeaderLayerControlProps) {
|
||||
export function SWHeaderLayerControl({ value, state, label = "레이어", showFreeze = true, showLock = true, ...rest }: SWHeaderLayerControlProps) {
|
||||
return (
|
||||
<SWHeaderField label={label} variant="layer" {...rest}>
|
||||
<span className="hm-sw-header__layer-icons" aria-hidden="true">
|
||||
<SWHeaderLayerIconArt kind="visibility" on={layerIconOn("visibility", state)} />
|
||||
<SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} />
|
||||
<SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} />
|
||||
{showFreeze ? <SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
|
||||
{showLock ? <SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
|
||||
</span>
|
||||
<SWHeaderFieldText label={label} value={value.name} />
|
||||
</SWHeaderField>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
|
||||
import { SWHeaderMenuDetail } from "./SWHeaderDropdown"
|
||||
import { SWHeaderMenuDetail, useSWFixedDropdownPosition } from "./SWHeaderDropdown"
|
||||
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
|
||||
import { SWHeaderLayerControl } from "./SWHeaderFields"
|
||||
import { SWHeaderLayerToggle } from "./SWHeaderLayerIcons"
|
||||
@@ -26,6 +26,12 @@ export interface SWHeaderLayerMenuProps {
|
||||
onLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
|
||||
/** "레이어 상세". Optional host action; omitting it does not disable hover or simulate CAD work. */
|
||||
onDetail?: () => void
|
||||
/**
|
||||
* Products without freeze / lock (default true): the snowflake / lock is not drawn in the field or in
|
||||
* any row and is no button; its 20px place stays so names and the other icons keep their positions.
|
||||
*/
|
||||
showFreeze?: boolean
|
||||
showLock?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +55,8 @@ export function SWHeaderLayerMenu({
|
||||
onFreezeChange,
|
||||
onLockChange,
|
||||
onDetail,
|
||||
showFreeze = true,
|
||||
showLock = true,
|
||||
}: SWHeaderLayerMenuProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = !disabled && (openProp ?? internalOpen)
|
||||
@@ -150,6 +158,9 @@ export function SWHeaderLayerMenu({
|
||||
}
|
||||
|
||||
const current = layers.find(item => item.id === activeLayerId)
|
||||
// Inside an SW popup the scrolling body would clip the list: the shared fixed overlay takes over.
|
||||
const inPopup = open && !!rootRef.current?.closest(".hm-sw-property")
|
||||
const fixed = useSWFixedDropdownPosition(open, rootRef, menuRef, "start", inPopup)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -167,6 +178,8 @@ export function SWHeaderLayerMenu({
|
||||
label={label}
|
||||
value={current ? { name: current.name } : value}
|
||||
state={current}
|
||||
showFreeze={showFreeze}
|
||||
showLock={showLock}
|
||||
selected={selected || open}
|
||||
expanded={open}
|
||||
controls={open ? menuId : undefined}
|
||||
@@ -183,8 +196,8 @@ export function SWHeaderLayerMenu({
|
||||
className="hm-sw-header__layer-menu"
|
||||
role="group"
|
||||
aria-label="레이어 목록"
|
||||
data-shifted={offset !== 0 ? "true" : undefined}
|
||||
style={offset !== 0 ? { left: offset } : undefined}
|
||||
data-shifted={(fixed ? fixed.flipped || fixed.offset !== 0 : offset !== 0) ? "true" : undefined}
|
||||
style={fixed ? { position: "fixed", left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" } : offset !== 0 ? { left: offset } : undefined}
|
||||
>
|
||||
<div className="hm-sw-header__layer-panel">
|
||||
<SWHeaderLayerRows
|
||||
@@ -198,6 +211,8 @@ export function SWHeaderLayerMenu({
|
||||
onVisibilityChange={onVisibilityChange}
|
||||
onFreezeChange={onFreezeChange}
|
||||
onLockChange={onLockChange}
|
||||
showFreeze={showFreeze}
|
||||
showLock={showLock}
|
||||
onDetail={onDetail && (() => { onDetail(); close(true) })}
|
||||
/>
|
||||
</div>
|
||||
@@ -207,7 +222,7 @@ export function SWHeaderLayerMenu({
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange"> {
|
||||
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange" | "showFreeze" | "showLock"> {
|
||||
layers: SWHeaderLayer[]
|
||||
/** Row shown as current (#FFC600 name, hover emphasis). None when the selection mixes layers. */
|
||||
current?: SWHeaderLayer
|
||||
@@ -222,7 +237,7 @@ export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "on
|
||||
* Shared by the header layer field and the property window layer field: same rows, toggles and callbacks.
|
||||
* The bulb, snowflake and lock are siblings of the row selection, so they never select the row.
|
||||
*/
|
||||
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false }: SWHeaderLayerRowsProps) {
|
||||
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false, showFreeze = true, showLock = true }: SWHeaderLayerRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{layers.length > 0 && (
|
||||
@@ -236,8 +251,8 @@ export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChang
|
||||
>
|
||||
<span className="hm-sw-header__layer-toggles">
|
||||
<SWHeaderLayerToggle kind="visibility" layer={layer} onChange={onVisibilityChange} />
|
||||
<SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} />
|
||||
<SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} />
|
||||
{showFreeze ? <SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
|
||||
{showLock ? <SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -60,7 +60,7 @@ export function SWHeaderLineStyleMenu({
|
||||
value, label, lineTypes = [], lineWeights = [], activeLineTypeId, activeLineWeightId, open: openProp, onOpenChange, onClick,
|
||||
selected = false, disabled = false, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onScaleChange, onDetail,
|
||||
}: SWHeaderLineStyleMenuProps) {
|
||||
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
|
||||
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, panelOffset, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
|
||||
const currentType = lineTypes.find(item => (activeLineTypeId === undefined ? item.name === value.name : item.id === activeLineTypeId))
|
||||
const currentWeight = lineWeights.find(item => (activeLineWeightId === undefined ? item.name === value.weight : item.id === activeLineWeightId))
|
||||
|
||||
@@ -94,7 +94,7 @@ export function SWHeaderLineStyleMenu({
|
||||
/>
|
||||
{open && (
|
||||
<div ref={panelRef} id={menuId} className="hm-sw-header__menu-panel hm-sw-header__line-menu" role="group" aria-label="선스타일"
|
||||
style={{ ...panelStyle, "--hm-sw-line-seam-left": `${-(panelStyle?.left ?? 0)}px` } as CSSProperties}>
|
||||
style={{ ...panelStyle, ...({ "--hm-sw-line-seam-left": `${-panelOffset}px` } as CSSProperties) }}>
|
||||
<SWHeaderLineStyleColumns
|
||||
lineTypes={lineTypes}
|
||||
lineWeights={lineWeights}
|
||||
|
||||
@@ -25,10 +25,12 @@ export interface SWHeaderMoreItem {
|
||||
|
||||
export interface SWHeaderMoreProps {
|
||||
items: SWHeaderMoreItem[]
|
||||
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
|
||||
forceVisible?: boolean
|
||||
}
|
||||
|
||||
/** Overflow menu that keeps every moved control reachable. */
|
||||
export function SWHeaderMore({ items }: SWHeaderMoreProps) {
|
||||
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -64,7 +66,7 @@ export function SWHeaderMore({ items }: SWHeaderMoreProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="hm-sw-header__more" onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useState, type CSSProperties, type ReactNode, type Ref } from "react"
|
||||
import { type CSSProperties, type ReactNode, type Ref } from "react"
|
||||
import type { SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType } from "../types"
|
||||
import { SWHeaderColorPanelBody, useSWHeaderColorMode } from "./SWHeaderColorMenu"
|
||||
import { useSWHeaderDropdown } from "./SWHeaderDropdown"
|
||||
import { useSWFixedDropdownPosition, useSWHeaderDropdown } from "./SWHeaderDropdown"
|
||||
import { SWHeaderInput, parseSWHeaderColor } from "./SWHeaderInput"
|
||||
import { SWHeaderLayerRows } from "./SWHeaderLayerMenu"
|
||||
import { SWHeaderLinePreview, SWHeaderLineStyleColumns } from "./SWHeaderLineStyleMenu"
|
||||
@@ -38,30 +38,9 @@ const byValue = <T extends { id: string; name: string }>(items: readonly T[], va
|
||||
* the list flips above the field and shifts sideways only when the screen has no room.
|
||||
*/
|
||||
function useFieldDropdown(align: "start" | "end", disabled: boolean) {
|
||||
const dropdown = useSWHeaderDropdown({ disabled })
|
||||
const { open, rootRef, panelRef } = dropdown
|
||||
const [position, setPosition] = useState<CSSProperties>({ visibility: "hidden" })
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
const update = () => {
|
||||
const field = rootRef.current?.getBoundingClientRect()
|
||||
const panel = panelRef.current
|
||||
if (!field || !panel) return
|
||||
const width = panel.offsetWidth, height = panel.offsetHeight
|
||||
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
|
||||
const left = Math.max(4, Math.min(align === "end" ? field.right - width : field.left, viewportWidth - width - 4))
|
||||
const below = field.bottom
|
||||
const top = below + height > viewportHeight - 4 && field.top - height >= 4 ? field.top - height : below
|
||||
setPosition(current => (current.left === left && current.top === top ? current : { left, top }))
|
||||
}
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
window.addEventListener("scroll", update, true)
|
||||
return () => {
|
||||
window.removeEventListener("resize", update)
|
||||
window.removeEventListener("scroll", update, true)
|
||||
}
|
||||
}, [open, align, rootRef, panelRef])
|
||||
const dropdown = useSWHeaderDropdown({ disabled, escapePopup: false })
|
||||
const fixed = useSWFixedDropdownPosition(dropdown.open, dropdown.rootRef, dropdown.panelRef, align)
|
||||
const position: CSSProperties = fixed ? { left: fixed.left, top: fixed.top } : { visibility: "hidden" }
|
||||
return { ...dropdown, position }
|
||||
}
|
||||
|
||||
@@ -125,6 +104,8 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
|
||||
onVisibilityChange={menus?.onLayerVisibilityChange}
|
||||
onFreezeChange={menus?.onLayerFreezeChange}
|
||||
onLockChange={menus?.onLayerLockChange}
|
||||
showFreeze={menus?.showLayerFreeze}
|
||||
showLock={menus?.showLayerLock}
|
||||
onDetail={menus?.onLayerDetail && (() => { menus.onLayerDetail?.(); close(true) })}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode, type RefObject } from "react"
|
||||
import circleGraphic from "../../../assets/property-circle.svg"
|
||||
import titleGraphic from "../../../assets/property-title.svg"
|
||||
import singleTitleGraphic from "../../../assets/property-title-single.svg"
|
||||
@@ -59,6 +59,9 @@ export interface SWHeaderPropertyMenus {
|
||||
onLayerFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
|
||||
onLayerLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
|
||||
onLayerDetail?: () => void
|
||||
/** Products without freeze / lock: the state is not drawn in the layer list (its place stays). Default true. */
|
||||
showLayerFreeze?: boolean
|
||||
showLayerLock?: boolean
|
||||
lineTypes?: SWHeaderLineType[]
|
||||
/** Weight column of the line style dropdown; omitted => no column. */
|
||||
lineWeights?: SWHeaderLineWeight[]
|
||||
@@ -108,9 +111,20 @@ export interface SWHeaderPropertyShapeContext {
|
||||
*/
|
||||
export type SWHeaderPropertyShapeContent = ReactNode | ((context: SWHeaderPropertyShapeContext) => ReactNode)
|
||||
export interface SWHeaderPropertyPanelProps {
|
||||
selection: SWHeaderPropertySelection
|
||||
properties: readonly SWHeaderProperty[]
|
||||
groups: readonly SWHeaderPropertyGroup[]
|
||||
/** Optional (2026-10-19): without it the panel is a plain popup (no selection tool) titled by `title`. */
|
||||
selection?: SWHeaderPropertySelection
|
||||
/** Optional (default []): data rows; combine with or replace by `children`. */
|
||||
properties?: readonly SWHeaderProperty[]
|
||||
groups?: readonly SWHeaderPropertyGroup[]
|
||||
/** Title text; overrides `selection.title`. */
|
||||
title?: string
|
||||
/**
|
||||
* Free content of the scrolling body, after the property groups (before the selection counts). Any React
|
||||
* content: SW controls, @hm-design/ui components, the project's own fields. The frame never changes.
|
||||
*/
|
||||
children?: ReactNode
|
||||
/** Focus the window when it opens. Default: true with `anchorRef` (SWHeader), false standalone. */
|
||||
autoFocus?: boolean
|
||||
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
|
||||
/** Lists and callbacks of the layer / line style / color field dropdowns. */
|
||||
menus?: SWHeaderPropertyMenus
|
||||
@@ -247,81 +261,159 @@ function PropertyEditor({ property, onChange, menus }: { property: SWHeaderPrope
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape among several open popups: every mounted popup is on a stack ordered by its last activation (open,
|
||||
* focus or pointer inside). Escape inside a popup closes that popup; Escape elsewhere closes only the top one.
|
||||
* An Escape already handled inside (preventDefault / stopPropagation, e.g. a dropdown or an edited input)
|
||||
* never closes the popup.
|
||||
*/
|
||||
const popupStack: { close: () => void }[] = []
|
||||
function onDocumentEscape(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
|
||||
popupStack.at(-1)?.close()
|
||||
}
|
||||
function useSWPopupStack(onClose?: () => void) {
|
||||
const entry = useRef({ close: () => {} })
|
||||
entry.current.close = () => onClose?.()
|
||||
useEffect(() => {
|
||||
const item = entry.current
|
||||
popupStack.push(item)
|
||||
if (popupStack.length === 1) document.addEventListener("keydown", onDocumentEscape)
|
||||
return () => {
|
||||
popupStack.splice(popupStack.indexOf(item), 1)
|
||||
if (popupStack.length === 0) document.removeEventListener("keydown", onDocumentEscape)
|
||||
}
|
||||
}, [])
|
||||
/** Moves this popup to the top of the stack. */
|
||||
return () => {
|
||||
const index = popupStack.indexOf(entry.current)
|
||||
if (index >= 0 && index !== popupStack.length - 1) popupStack.push(...popupStack.splice(index, 1))
|
||||
}
|
||||
}
|
||||
|
||||
export interface SWPopupFrameProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title: string
|
||||
/** "single": title art without the selection tool (default). "tool": art with the tool slot. */
|
||||
titleArt?: "single" | "tool"
|
||||
/** Buttons of the tool slot (property window selection tool). */
|
||||
titleActions?: ReactNode
|
||||
/** Accessible name of the close button. */
|
||||
closeLabel?: string
|
||||
onClose?: () => void
|
||||
anchorRef?: RefObject<HTMLElement | null>
|
||||
autoFocus?: boolean
|
||||
/** Remounts the body when it changes (property window selection). */
|
||||
bodyKey?: string
|
||||
/** Rendered after the scrolling body (property window footer). */
|
||||
after?: ReactNode
|
||||
/** Value of data-selection-kind (frame CSS variants). */
|
||||
kind?: SWHeaderPropertySelection["kind"]
|
||||
}
|
||||
|
||||
/**
|
||||
* The SW popup frame (Figma 26:11822): 314px, #E4DBC9 surface, #A67D03 border, 4px radius, 30px title bar with
|
||||
* the Figma title art and close button, scrolling body (viewport-bound). Non-modal: no dim, no portal, no focus
|
||||
* trap. Shared by SWPopup and SWHeaderPropertyPanel, so both render the same DOM and classes.
|
||||
*/
|
||||
export function SWPopupFrame({
|
||||
title, titleArt = "single", titleActions, closeLabel = "속성창 닫기", onClose, anchorRef, autoFocus, bodyKey, after, kind = "single",
|
||||
id, style: customStyle, className, children, onKeyDown, onFocusCapture, onPointerDownCapture, ...rest
|
||||
}: SWPopupFrameProps) {
|
||||
const titleId = useId()
|
||||
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
|
||||
const raise = useSWPopupStack(onClose)
|
||||
const focus = autoFocus ?? !!anchorRef
|
||||
useLayoutEffect(() => {
|
||||
if (!focus) return
|
||||
const panel = panelRef.current
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
panel?.focus()
|
||||
return () => {
|
||||
// Closing with focus still inside (e.g. Escape on a field) returns it to where it came from.
|
||||
const active = document.activeElement
|
||||
if (panel && (active === document.body || active === null || panel.contains(active)) && previous?.isConnected) previous.focus()
|
||||
}
|
||||
}, [focus, panelRef])
|
||||
return (
|
||||
<div ref={panelRef} id={id} tabIndex={-1} className={`hm-sw-property${className ? ` ${className}` : ""}`} data-selection-kind={kind} role="dialog" aria-modal="false" aria-labelledby={titleId}
|
||||
style={{ ...style, ...customStyle }} {...rest}
|
||||
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
|
||||
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
|
||||
onKeyDown={event => {
|
||||
onKeyDown?.(event)
|
||||
if (event.key === "Escape" && !event.defaultPrevented && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
|
||||
}}>
|
||||
<div className="hm-sw-property__titlebar">
|
||||
<img className="hm-sw-property__title-art" src={titleArt === "single" ? singleTitleGraphic : titleGraphic} alt="" />
|
||||
<span id={titleId} className="hm-sw-property__title" title={title}>{title}</span>
|
||||
{titleActions}
|
||||
<button type="button" className="hm-sw-property__close" aria-label={closeLabel} onClick={onClose}>
|
||||
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
|
||||
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hm-sw-property__body" key={bodyKey}>{children}</div>
|
||||
{after}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single data-driven, non-modal property tool window (Figma 26:11822). */
|
||||
export function SWHeaderPropertyPanel({
|
||||
selection, properties, groups, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
|
||||
selection: selectionProp, properties = [], groups = [], title, children, autoFocus, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
|
||||
onExpandedGroupsChange, toolActive: controlledTool, onToolActiveChange, toolLabel = "객체 선택 도구",
|
||||
onOverviewClick, shapeContent, onClose, id, anchorRef, style: customStyle,
|
||||
onOverviewClick, shapeContent, onClose, id, anchorRef, style,
|
||||
}: SWHeaderPropertyPanelProps) {
|
||||
const titleId = useId()
|
||||
const selection: SWHeaderPropertySelection = selectionProp ?? { id: "", title: title ?? "", kind: "single" }
|
||||
const [expanded, setExpanded] = useState<string[]>([...defaultExpandedGroupIds])
|
||||
const [internalTool, setInternalTool] = useState(false)
|
||||
const active = controlledTool ?? internalTool
|
||||
const expandedIds = expandedGroupIds ?? expanded
|
||||
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
|
||||
useLayoutEffect(() => { if (anchorRef) panelRef.current?.focus() }, [anchorRef, panelRef])
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented && !event.isComposing) onClose?.()
|
||||
}
|
||||
document.addEventListener("keydown", onKey)
|
||||
return () => document.removeEventListener("keydown", onKey)
|
||||
}, [onClose])
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const next = expandedIds.includes(groupId) ? expandedIds.filter(id => id !== groupId) : [...expandedIds, groupId]
|
||||
if (expandedGroupIds === undefined) setExpanded(next)
|
||||
onExpandedGroupsChange?.(next)
|
||||
}
|
||||
return (
|
||||
<div ref={panelRef} id={id} tabIndex={-1} className="hm-sw-property" data-selection-kind={selection.kind} role="dialog" aria-modal="false" aria-labelledby={titleId} style={{ ...style, ...customStyle }} onKeyDown={event => {
|
||||
if (event.key === "Escape" && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
|
||||
}}>
|
||||
<div className="hm-sw-property__titlebar">
|
||||
<img className="hm-sw-property__title-art" src={selection.kind === "single" ? singleTitleGraphic : titleGraphic} alt="" />
|
||||
<span id={titleId} className="hm-sw-property__title" title={selection.title}>{selection.title}</span>
|
||||
{selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
|
||||
if (controlledTool === undefined) setInternalTool(!active)
|
||||
onToolActiveChange?.(!active)
|
||||
}}>
|
||||
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
|
||||
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
|
||||
</button>}
|
||||
<button type="button" className="hm-sw-property__close" aria-label="속성창 닫기" onClick={onClose}>
|
||||
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
|
||||
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hm-sw-property__body" key={selection.id}>
|
||||
{ordered(groups).map(group => {
|
||||
const items = ordered(properties.filter(property => property.groupId === group.id))
|
||||
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
|
||||
const isExpanded = expandedIds.includes(group.id)
|
||||
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
|
||||
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
|
||||
return (
|
||||
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
|
||||
{group.name && <h3>{group.name}</h3>}
|
||||
<div className="hm-sw-property__group-content">
|
||||
{group.layout === "circle" && (() => {
|
||||
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
|
||||
const fallback = shapeContent === undefined
|
||||
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
|
||||
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
|
||||
</div>
|
||||
})()}
|
||||
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
|
||||
<div className="hm-sw-property__editors">
|
||||
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
|
||||
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
|
||||
<SWPopupFrame id={id} title={title ?? selection.title} kind={selection.kind} titleArt={selection.kind === "single" ? "single" : "tool"}
|
||||
anchorRef={anchorRef} autoFocus={autoFocus} style={style} onClose={onClose} bodyKey={selection.id}
|
||||
titleActions={selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
|
||||
if (controlledTool === undefined) setInternalTool(!active)
|
||||
onToolActiveChange?.(!active)
|
||||
}}>
|
||||
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
|
||||
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
|
||||
</button>}
|
||||
after={selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}>
|
||||
{ordered(groups).map(group => {
|
||||
const items = ordered(properties.filter(property => property.groupId === group.id))
|
||||
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
|
||||
const isExpanded = expandedIds.includes(group.id)
|
||||
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
|
||||
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
|
||||
return (
|
||||
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
|
||||
{group.name && <h3>{group.name}</h3>}
|
||||
<div className="hm-sw-property__group-content">
|
||||
{group.layout === "circle" && (() => {
|
||||
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
|
||||
const fallback = shapeContent === undefined
|
||||
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
|
||||
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
|
||||
</div>
|
||||
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
|
||||
})()}
|
||||
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
|
||||
<div className="hm-sw-property__editors">
|
||||
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
|
||||
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
|
||||
</div>
|
||||
{selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}
|
||||
</div>
|
||||
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
|
||||
</SWPopupFrame>
|
||||
)
|
||||
}
|
||||
@@ -30,4 +30,4 @@ export type {
|
||||
} from "./types"
|
||||
|
||||
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
|
||||
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId } from "./types"
|
||||
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderSubControlId, SWHeaderHideableControlId } from "./types"
|
||||
@@ -138,8 +138,14 @@ export interface SWHeaderDimensionStyleOption {
|
||||
export type SWHeaderControlId = "document" | SWHeaderMenuId | "drawingMode" | "details" | "propertyBar" | "propertyPanel" | "print" | "popupGuide"
|
||||
/** Common frame parts outside the object area. Their position is fixed; each can be removed. */
|
||||
export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close"
|
||||
/**
|
||||
* Items inside a part, for products without that function: the document dropdown's `+ New` / `Open`
|
||||
* and the layer freeze / lock state. Removing one never changes the layout or the breakpoints; a layer
|
||||
* state keeps its 20px place (empty), so names and the other icons stay aligned.
|
||||
*/
|
||||
export type SWHeaderSubControlId = "documentNew" | "documentOpen" | "layerFreeze" | "layerLock"
|
||||
/** Every Header part except the logo, which is always shown. */
|
||||
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId
|
||||
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId | SWHeaderSubControlId
|
||||
export interface SWHeaderControlConfig {
|
||||
id: SWHeaderControlId
|
||||
visible?: boolean
|
||||
@@ -154,6 +160,9 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
|
||||
* Parts to remove, in either layout (default design or `controls`). Works without passing a full
|
||||
* `controls` list, so the default design and order of the remaining parts are kept. Removing
|
||||
* "home" keeps the logo as a static image. Unknown ids are ignored. Default: none.
|
||||
* Sub-items (`SWHeaderSubControlId`): "documentNew" / "documentOpen" remove the document dropdown
|
||||
* actions (both: no footer), "layerFreeze" / "layerLock" remove that state from the layer field,
|
||||
* the layer dropdown and the property window layer list.
|
||||
*/
|
||||
hiddenControls?: readonly SWHeaderHideableControlId[]
|
||||
/**
|
||||
@@ -187,6 +196,19 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
|
||||
onDocumentOpen?: () => void
|
||||
/** Default icon for documents without `icon`. Defaults to the Figma document icon. */
|
||||
documentIcon?: ReactNode
|
||||
/**
|
||||
* Where "Open" is offered. `menu` (default): the document dropdown footer (unchanged). `header`: an Open
|
||||
* button right of the logo in the file-name look (`SWToolButton appearance="document"`), and no Open in the
|
||||
* dropdown, so it never appears twice. Both call `onDocumentOpen` with the `documentOpenLabel` text.
|
||||
* When the header lacks room (measured), the button moves into More. `hiddenControls: ["documentOpen"]`
|
||||
* removes Open everywhere.
|
||||
*/
|
||||
documentOpenPlacement?: "menu" | "header"
|
||||
/**
|
||||
* Extra controls right of the logo (after the header Open button, before the file name), e.g.
|
||||
* `SWToolButton appearance="document"`. They keep their place at every width; the file name gives way.
|
||||
*/
|
||||
leading?: ReactNode
|
||||
/** Footer labels. Default `+ New` / `Open` (Figma). */
|
||||
documentNewLabel?: string
|
||||
documentOpenLabel?: string
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cloneElement, isValidElement, type HTMLAttributes, type MouseEvent, type ReactNode, type RefObject } from "react"
|
||||
import { SWPopupFrame } from "../sw-header/components/SWHeaderPropertyPanel"
|
||||
|
||||
export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
/** Title text (700, #FFC600, ellipsis). */
|
||||
title: string
|
||||
/** Close button and Escape (inside the popup, or anywhere while it is the top popup). */
|
||||
onClose?: () => void
|
||||
/** Accessible name of the close button. Default `닫기`. */
|
||||
closeLabel?: string
|
||||
/**
|
||||
* Free content of the scrolling body: SWPopupSection / SWPopupRow, SW controls, @hm-design/ui components or
|
||||
* the project's own elements. Nothing is restricted; the frame keeps the SW design whatever is inside.
|
||||
*/
|
||||
children?: ReactNode
|
||||
/** Focus the popup when it opens (and return focus when it closes from inside). Default false. */
|
||||
autoFocus?: boolean
|
||||
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
|
||||
anchorRef?: RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* HMDesign SW popup (the property window frame, Figma 26:11822) with free content. 314px wide, height follows the
|
||||
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
|
||||
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
|
||||
*/
|
||||
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
|
||||
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
|
||||
}
|
||||
|
||||
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
/** Group heading (700, 70% black). Also the section's accessible name. */
|
||||
title?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** A content group of the popup, the property window group (`rows` layout). Holds SWPopupRow or any content. */
|
||||
export function SWPopupSection({ title, children, className, ...rest }: SWPopupSectionProps) {
|
||||
return (
|
||||
<section className={`hm-sw-property__group hm-sw-property__group--rows${className ? ` ${className}` : ""}`} aria-label={rest["aria-label"] ?? title} {...rest}>
|
||||
{title && <h3>{title}</h3>}
|
||||
<div className="hm-sw-property__group-content"><div className="hm-sw-property__editors">{children}</div></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SWPopupRowProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Row label (72px, 700, ellipsis). */
|
||||
label: string
|
||||
/** Id of the control in `children`, for the label. */
|
||||
htmlFor?: string
|
||||
/** Unit text after the value (12px). */
|
||||
unit?: string
|
||||
/**
|
||||
* Value well: `box` (default) the 200px property field (Default / Hover border), `compact` the 41px hug field
|
||||
* of a bare number (children must be one SWHeaderInput), `none` no well (switches, buttons, text).
|
||||
*/
|
||||
field?: "box" | "compact" | "none"
|
||||
/** Number editing look (Figma On #0F0D0A while focused); text values keep Default / Hover only. */
|
||||
numeric?: boolean
|
||||
/** Read-only value: no hover / editing look. */
|
||||
readOnly?: boolean
|
||||
/** 40% row. */
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* One label + value row of the property window. The value is `children` (SWHeaderInput for SW fields, or any
|
||||
* control). A click on the empty part of a `box` focuses its first input, like the property window.
|
||||
*/
|
||||
export function SWPopupRow({ label, htmlFor, unit, field = "box", numeric, readOnly, disabled, children, className, ...rest }: SWPopupRowProps) {
|
||||
const focusInput = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (field !== "box" || disabled || readOnly) return
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest("input, select, textarea, button, a, [tabindex]")) return
|
||||
const input = event.currentTarget.querySelector<HTMLElement>("input, select, textarea")
|
||||
if (!input) return
|
||||
event.preventDefault()
|
||||
input.focus()
|
||||
}
|
||||
return (
|
||||
<div className={`hm-sw-property__editor${className ? ` ${className}` : ""}`} {...rest}>
|
||||
<div className="hm-sw-property__row" data-disabled={disabled || undefined}>
|
||||
<label htmlFor={htmlFor} title={label}>{label}</label>
|
||||
<div className="hm-sw-property__value" data-field={field === "none" ? undefined : field === "box" ? "box" : undefined}
|
||||
data-on={field === "box" && numeric && !readOnly ? "edit" : undefined} data-readonly={readOnly ? "true" : undefined}
|
||||
onMouseDown={focusInput}>
|
||||
{field === "compact" && isValidElement<{ className?: string }>(children)
|
||||
? cloneElement(children, { className: `${children.props.className ? `${children.props.className} ` : ""}hm-sw-property__compact` })
|
||||
: children}
|
||||
{unit && <span className="hm-sw-property__unit">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createContext, forwardRef, useContext, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* Existing SW button looks, reused as one public button (no new design values):
|
||||
* - `text`: the 속성Bar / 속성창 text toggle (700, text-only hover #FFC600, selected #FFC600).
|
||||
* - `pill`: the 40 x 38 print / K button of the dark pill (gradient hover ring, optional hover icon).
|
||||
* - `document`: the file-name surface (`hm-sw-header__document-button`, text-only hover #FFC600). Placed right
|
||||
* after `SWHeaderHome` it shares the slanted logo junction exactly like the file name.
|
||||
*/
|
||||
export type SWToolButtonAppearance = "text" | "pill" | "document"
|
||||
|
||||
export interface SWToolButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/** Default `text`. */
|
||||
appearance?: SWToolButtonAppearance
|
||||
/** Toggle state (aria-pressed, #FFC600 text). Inside `SWToolGroup selection="single"` it is the checked radio. */
|
||||
pressed?: boolean
|
||||
/** Button text. For `pill` it is the accessible name only (visually hidden: the pill shows its icon). */
|
||||
children?: ReactNode
|
||||
/** `pill` only: icon centered in the 40 x 38 button. */
|
||||
icon?: ReactNode
|
||||
/** `pill` only: icon shown instead of `icon` on hover (like print-hover.svg). */
|
||||
hoverIcon?: ReactNode
|
||||
}
|
||||
|
||||
interface GroupContext { selection: "none" | "single" }
|
||||
const SWToolGroupContext = createContext<GroupContext | null>(null)
|
||||
|
||||
export const SWToolButton = forwardRef<HTMLButtonElement, SWToolButtonProps>(function SWToolButton(
|
||||
{ appearance = "text", pressed, children, icon, hoverIcon, className, type = "button", ...rest }, ref,
|
||||
) {
|
||||
const group = useContext(SWToolGroupContext)
|
||||
const radio = group?.selection === "single"
|
||||
const state = pressed === undefined ? {} : radio
|
||||
? { role: "radio", "aria-checked": pressed, tabIndex: rest.tabIndex ?? (pressed ? 0 : -1) }
|
||||
: { "aria-pressed": pressed }
|
||||
const classes = (base: string) => `${base}${className ? ` ${className}` : ""}`
|
||||
if (appearance === "document") {
|
||||
// The wrapper is the Header flex item; [data-tool] keeps it at its content width (not the file-name clamp).
|
||||
return (
|
||||
<div className="hm-sw-header__document" data-tool="">
|
||||
<button ref={ref} type={type} className={classes("hm-sw-header__document-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
|
||||
<span className="hm-sw-header__document-name">{children}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (appearance === "pill") {
|
||||
return (
|
||||
<button ref={ref} type={type} className={classes("hm-sw-header__action hm-sw-header__action--print hm-sw-tool__pill-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
|
||||
{icon !== undefined && <span className="hm-sw-header__icon hm-sw-tool__icon" aria-hidden="true">{icon}</span>}
|
||||
{hoverIcon !== undefined && <span className="hm-sw-header__icon-hover hm-sw-tool__icon" aria-hidden="true">{hoverIcon}</span>}
|
||||
{children !== undefined && <span className="hm-sw-tool__label">{children}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button ref={ref} type={type} className={classes("hm-sw-header__toggle hm-sw-tool__text-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Existing SW group surfaces:
|
||||
* - `text`: the 상세보기 group (optional 12px caption above a 19px row; 1px dividers between buttons).
|
||||
* - `pill`: the dark rounded print / K pill (shrinks to its buttons).
|
||||
* `selection="single"` makes it a radio group (e.g. Model / Layout): give each button `pressed`, arrows move.
|
||||
*/
|
||||
export interface SWToolGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Default `text`. */
|
||||
appearance?: "text" | "pill"
|
||||
/** `text` only: the 12px caption above the row, e.g. `상세보기`. Also the group's accessible name. */
|
||||
label?: string
|
||||
/** `single`: one pressed button (radio group, arrow keys move and select by focus only). Default `none`. */
|
||||
selection?: "none" | "single"
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]
|
||||
if (!keys.includes(event.key)) return
|
||||
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)'))
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
if (index < 0 || !items.length) return
|
||||
event.preventDefault()
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
|
||||
: (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
|
||||
items[next].focus()
|
||||
items[next].click()
|
||||
}
|
||||
|
||||
export const SWToolGroup = forwardRef<HTMLDivElement, SWToolGroupProps>(function SWToolGroup(
|
||||
{ appearance = "text", label, selection = "none", children, className, onKeyDown, ...rest }, ref,
|
||||
) {
|
||||
const role = selection === "single" ? "radiogroup" : "group"
|
||||
const keys = (event: KeyboardEvent<HTMLDivElement>) => { onKeyDown?.(event); if (!event.defaultPrevented && selection === "single") moveFocus(event) }
|
||||
const items = Array.isArray(children) ? children.filter(child => child !== null && child !== undefined && child !== false) : [children]
|
||||
const content = (
|
||||
<SWToolGroupContext.Provider value={{ selection }}>
|
||||
{appearance === "text" ? items.map((child, index) => (
|
||||
<span key={index} className="hm-sw-tool__item">
|
||||
{index > 0 && <span className="hm-sw-header__properties-divider" aria-hidden="true" />}
|
||||
{child}
|
||||
</span>
|
||||
)) : children}
|
||||
</SWToolGroupContext.Provider>
|
||||
)
|
||||
if (appearance === "pill") {
|
||||
return <div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__actions hm-sw-tool__pill${className ? ` ${className}` : ""}`} data-partial="" onKeyDown={keys} {...rest}>{content}</div>
|
||||
}
|
||||
return (
|
||||
<div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__properties hm-sw-tool__group${className ? ` ${className}` : ""}`} data-partial="" data-caption={label ? "" : undefined} onKeyDown={keys} {...rest}>
|
||||
{label && <span className="hm-sw-header__properties-caption" aria-hidden="true">{label}</span>}
|
||||
<div className="hm-sw-header__properties-row">{content}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -2,3 +2,11 @@ import "./theme/sw.css"
|
||||
|
||||
export * from "./components/sw-header"
|
||||
export { SWTheme, type SWThemeProps } from "./components/sw-theme/SWTheme"
|
||||
export {
|
||||
SWToolButton, SWToolGroup,
|
||||
type SWToolButtonProps, type SWToolButtonAppearance, type SWToolGroupProps,
|
||||
} from "./components/sw-tool/SWTool"
|
||||
export {
|
||||
SWPopup, SWPopupSection, SWPopupRow,
|
||||
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
|
||||
} from "./components/sw-popup/SWPopup"
|
||||
@@ -377,7 +377,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(var(--hm-sw-header-document-menu-width) + 2 * var(--hm-sw-header-document-menu-border-width));
|
||||
max-width: calc(100cqw - 71px);
|
||||
max-width: calc(100cqw - 71px - var(--hm-sw-header-leading-width, 0px));
|
||||
border: var(--hm-sw-header-document-menu-border-width) solid var(--hm-sw-header-document-menu-border-color);
|
||||
border-radius: 0 0 4px 4px;
|
||||
overflow: hidden;
|
||||
@@ -475,6 +475,8 @@
|
||||
border-top: 1px solid var(--hm-sw-header-document-menu-divider-color);
|
||||
box-shadow: var(--hm-sw-header-document-menu-inset-shadow);
|
||||
}
|
||||
/* A single remaining action keeps its Figma side: Open stays right, + New stays left. */
|
||||
.hm-sw-header__document-footer > [data-action="open"]:only-child { margin-left: auto; }
|
||||
.hm-sw-header__document-action {
|
||||
height: 24px;
|
||||
padding: 5px 6px;
|
||||
@@ -1724,7 +1726,7 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
The file name always takes the remaining width (ellipsis). cqw = header width.
|
||||
Hiding targets the flex item of each field ([data-menu] / [data-tier] on the field button, or on
|
||||
the wrapper of a field with a dropdown), so a dropdown field hides like a plain one. */
|
||||
.hm-sw-header__document { flex: 0 0 clamp(190px, calc(100cqw - 1610px), 310px); min-width: 0; }
|
||||
.hm-sw-header__document { flex: 0 0 clamp(190px, calc(100cqw - 1610px - var(--hm-sw-header-leading-width, 0px)), 310px); min-width: 0; }
|
||||
.hm-sw-header__field--layer { flex-shrink: 1; }
|
||||
.hm-sw-header__field:not(.hm-sw-header__field--layer) { flex-shrink: 0; }
|
||||
|
||||
@@ -1732,12 +1734,12 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-header__fields > [data-tier="aux"] { display: none; }
|
||||
.hm-sw-header__more { display: block; }
|
||||
.hm-sw-header__more-item[data-tier="aux"] { display: flex; }
|
||||
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 1030px), 310px); }
|
||||
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 1030px - var(--hm-sw-header-leading-width, 0px)), 310px); }
|
||||
}
|
||||
@container hm-sw-header (max-width: 1179.98px) {
|
||||
.hm-sw-header__mode { display: none; }
|
||||
.hm-sw-header__fields { padding-left: 4px; }
|
||||
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 944px), 310px); }
|
||||
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 944px - var(--hm-sw-header-leading-width, 0px)), 310px); }
|
||||
}
|
||||
@container hm-sw-header (max-width: 1079.98px) {
|
||||
.hm-sw-header__properties { display: none; }
|
||||
@@ -1752,19 +1754,19 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-header__motion-tooltip { left: calc(100% + 1px); top: 11px; }
|
||||
.hm-sw-header__motion:hover > .hm-sw-header__motion-tooltip { animation: none; opacity: 1; }
|
||||
.hm-sw-header__window { flex-basis: 176px; width: 176px; }
|
||||
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 725px), 310px); }
|
||||
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 725px - var(--hm-sw-header-leading-width, 0px)), 310px); }
|
||||
}
|
||||
@container hm-sw-header (max-width: 899.98px) {
|
||||
.hm-sw-header__fields > [data-menu="color"] { display: none; }
|
||||
.hm-sw-header__more-item[data-tier="narrow"] { display: flex; }
|
||||
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 529px), 310px); }
|
||||
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 529px - var(--hm-sw-header-leading-width, 0px)), 310px); }
|
||||
}
|
||||
@container hm-sw-header (max-width: 639.98px) {
|
||||
.hm-sw-header__actions,
|
||||
.hm-sw-header__motion { display: none; }
|
||||
.hm-sw-header__window { flex-basis: 64px; width: 64px; }
|
||||
.hm-sw-header__more-item[data-tier="action"] { display: flex; }
|
||||
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 417px), 310px); }
|
||||
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 417px - var(--hm-sw-header-leading-width, 0px)), 310px); }
|
||||
.hm-sw-header__document-button { padding-right: 8px; }
|
||||
}
|
||||
@container hm-sw-header (max-width: 519.98px) {
|
||||
@@ -1840,3 +1842,37 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
@container hm-sw-header (max-width: 1079.98px) {
|
||||
.hm-sw-header__window[data-reduced]:not(:has(.hm-sw-header__window-buttons:not([data-partial]))) .hm-sw-header__motion-tooltip { left: auto; right: calc(100% + 1px); }
|
||||
}
|
||||
|
||||
/* SW Tool (SWToolButton / SWToolGroup, 2026-10-19): the existing toggle, pill and file-name looks reused as
|
||||
public buttons. Only placement rules live here; every color, size and state comes from the rules above. */
|
||||
.hm-sw-tool__group { margin-inline: 0; }
|
||||
.hm-sw-tool__item { display: contents; }
|
||||
.hm-sw-tool__group .hm-sw-header__toggle { padding-inline: 8px; }
|
||||
.hm-sw-tool__icon { position: absolute; inset: 20.1% 20% 19.62%; display: grid; place-items: center; pointer-events: none; }
|
||||
.hm-sw-tool__icon > :is(img, svg) { display: block; max-width: 100%; max-height: 100%; }
|
||||
.hm-sw-tool__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
/* File-name look as a button (e.g. Open next to the logo): content width, never the file-name clamp. */
|
||||
.hm-sw-header__document[data-tool] { flex: 0 0 auto; }
|
||||
.hm-sw-header__document[data-tool] > .hm-sw-header__document-button { width: auto; }
|
||||
|
||||
/* Leading area right of the logo (SWHeader documentOpenPlacement="header" / leading, 2026-10-19). It is the
|
||||
flex item the logo overlaps (like the file name), so the first file-name-look button keeps the Figma junction.
|
||||
Its measured width is --hm-sw-header-leading-width, subtracted from the file-name clamp above. */
|
||||
.hm-sw-header__leading { position: relative; z-index: 1; display: flex; flex: 0 0 auto; gap: 2px; height: var(--hm-sw-header-bar-height); }
|
||||
/* The file name after an Open button no longer sits under the logo: 12px start padding (D-1), not 5px. */
|
||||
:is(.hm-sw-header__leading, .hm-sw-header__document[data-tool]) + .hm-sw-header__document > .hm-sw-header__document-button { padding-left: 12px; }
|
||||
/* While the header Open is shown, the default fields keep their width: a lack of room becomes a measured
|
||||
overflow that moves Open into More instead of narrowing the layer field. Custom leading content alone keeps
|
||||
the existing behavior (file name first, then the layer field below 520px). */
|
||||
.hm-sw-header[data-leading="open"] .hm-sw-header__fields:not([data-configured]) > :is(.hm-sw-header__layer, .hm-sw-header__field--layer, .hm-sw-header__control[data-control="layer"]) { flex-shrink: 0; }
|
||||
.hm-sw-header__more[data-forced] { display: block; }
|
||||
/* Tool groups reuse the 상세보기 / print-K surfaces but are host tools: the Header breakpoints that move those
|
||||
built-in parts into More never hide them (they have no More entry). */
|
||||
.hm-sw-header__properties.hm-sw-tool__group { display: flex; }
|
||||
.hm-sw-header__actions.hm-sw-tool__pill { display: flex; }
|
||||
/* Configured layouts too: while the header Open is shown, controls other than the file name keep their width,
|
||||
so a lack of room is a measured overflow that moves Open into More (the file name still gives way first). */
|
||||
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]),
|
||||
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]) > * { flex-shrink: 0; }
|
||||
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
|
||||
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
|
||||
@@ -0,0 +1,98 @@
|
||||
// Build reproducibility of @hm-design/sw (2026-10-19).
|
||||
// Copies the current packages/sw source (+ root tsconfig.app.json it extends) into three directories
|
||||
// outside the repository — two LF at different depths and one CRLF copy (as made outside git) — links
|
||||
// the repository node_modules (same locked tools), builds each twice and packs it.
|
||||
// dist must be byte-identical everywhere; the tarball must be identical for every LF checkout.
|
||||
// Usage: node packages/sw/tests/build-reproducibility.mjs <empty-dir-outside-the-repo> <evidence-dir>
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
||||
const repoRoot = path.resolve(packageRoot, "../..")
|
||||
const outside = dir => { const relative = path.relative(repoRoot, dir); return relative.startsWith("..") || path.isAbsolute(relative) }
|
||||
const work = path.resolve(process.argv[2] ?? "")
|
||||
const evidence = path.resolve(process.argv[3] ?? "")
|
||||
if (!process.argv[2] || !process.argv[3] || !outside(work)) throw new Error("Pass <empty dir outside the repo> <evidence dir>")
|
||||
await mkdir(work, { recursive: true })
|
||||
if ((await readdir(work)).length) throw new Error(`${work} is not empty`)
|
||||
await mkdir(evidence, { recursive: true })
|
||||
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim()
|
||||
const env = { ...process.env, SW_BUILD_COMMIT: `${commit}+reproducibility-check` }
|
||||
const sha = data => createHash("sha256").update(data).digest("hex")
|
||||
// SVG assets are excluded: they are inlined as base64, so their bytes are fixed at checkout by
|
||||
// packages/sw/.gitattributes (eol=lf) and checked separately with `git ls-files --eol`.
|
||||
const textFile = name => /\.(tsx?|css|mjs|json|md)$/.test(name)
|
||||
|
||||
async function copySource(target, crlf) {
|
||||
const sw = path.join(target, "packages/sw")
|
||||
for (const entry of ["src", "ai", "README.md", "build.mjs", "package.json", "tsconfig.json", "tsconfig.build.json"]) {
|
||||
await cp(path.join(packageRoot, entry), path.join(sw, entry), { recursive: true })
|
||||
}
|
||||
await cp(path.join(repoRoot, "tsconfig.app.json"), path.join(target, "tsconfig.app.json"))
|
||||
if (crlf) {
|
||||
const convert = async directory => {
|
||||
for (const item of await readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, item.name)
|
||||
if (item.isDirectory()) await convert(file)
|
||||
else if (textFile(item.name)) await writeFile(file, (await readFile(file, "utf8")).replace(/\r?\n/g, "\r\n"))
|
||||
}
|
||||
}
|
||||
await convert(sw)
|
||||
}
|
||||
// Same locked tools: a directory junction to the repository node_modules (removed with rmdir semantics below).
|
||||
await symlink(path.join(repoRoot, "node_modules"), path.join(target, "node_modules"), "junction")
|
||||
return sw
|
||||
}
|
||||
|
||||
async function hashes(sw) {
|
||||
const out = {}
|
||||
const walk = async directory => {
|
||||
for (const item of await readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, item.name)
|
||||
if (item.isDirectory()) await walk(file)
|
||||
else out[path.relative(sw, file).replaceAll("\\", "/")] = sha(await readFile(file))
|
||||
}
|
||||
}
|
||||
await walk(path.join(sw, "dist"))
|
||||
return out
|
||||
}
|
||||
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const runs = []
|
||||
const layouts = [["lf", false], ["deeper/path/lf", false], ["crlf-copy", true]]
|
||||
for (const [name, crlf] of layouts) {
|
||||
const target = path.join(work, name)
|
||||
const sw = await copySource(target, crlf)
|
||||
for (const attempt of [1, 2]) {
|
||||
execFileSync(process.execPath, ["build.mjs"], { cwd: sw, env, stdio: "pipe" })
|
||||
const files = await hashes(sw)
|
||||
const packDir = path.join(target, `pack-${attempt}`)
|
||||
await mkdir(packDir)
|
||||
execFileSync(npm, ["pack", "--ignore-scripts", "--pack-destination", packDir], { cwd: sw, env, stdio: "pipe", shell: process.platform === "win32" })
|
||||
const tarball = (await readdir(packDir)).find(file => file.endsWith(".tgz"))
|
||||
runs.push({ checkout: crlf ? "CRLF" : "LF", path: target, attempt, tarball, tarballSha256: sha(await readFile(path.join(packDir, tarball))), files })
|
||||
}
|
||||
}
|
||||
// Leave the junctions behind as junctions only (never recurse into the repository node_modules).
|
||||
for (const [name] of layouts) await rm(path.join(work, name, "node_modules"), { force: true })
|
||||
|
||||
const reference = runs[0]
|
||||
const differences = runs.slice(1).map(run => ({
|
||||
run: `${run.checkout} #${run.attempt}`,
|
||||
tarballSame: run.tarballSha256 === reference.tarballSha256,
|
||||
differentFiles: Object.keys({ ...reference.files, ...run.files }).filter(file => reference.files[file] !== run.files[file]),
|
||||
}))
|
||||
// Index and working tree of every package file must be LF (or binary) so a fresh checkout matches.
|
||||
const eol = execFileSync("git", ["ls-files", "--eol", "--", "."], { cwd: packageRoot, encoding: "utf8" }).trim().split("\n")
|
||||
const notLf = eol.filter(line => !/^i\/(lf|-text)\s+w\/(lf|-text)\s/.test(line))
|
||||
// dist must match everywhere; the tarball also ships ai/ and README.md verbatim, so it must match for LF
|
||||
// checkouts (guaranteed by .gitattributes). A CRLF copy made outside git only has to keep dist identical.
|
||||
const pass = differences.every(item => item.differentFiles.length === 0 && (item.run.startsWith("CRLF") || item.tarballSame)) && notLf.length === 0
|
||||
const report = { status: pass ? "PASS" : "FAIL", commit, notLf, buildCommit: env.SW_BUILD_COMMIT, runs: runs.map(({ files, ...run }) => ({ ...run, fileCount: Object.keys(files).length })), referenceFiles: reference.files, differences }
|
||||
await writeFile(path.join(evidence, "build-reproducibility-result.json"), JSON.stringify(report, null, 2) + "\n")
|
||||
console.log(report.status, JSON.stringify(differences))
|
||||
if (!pass) process.exitCode = 1
|
||||
@@ -0,0 +1,145 @@
|
||||
// Browser check of the consumer built by integrated-consumer.mjs (2026-10-19): both stylesheet orders, examples
|
||||
// E (custom popup) and F (Open + tool bar), HM UI inside an SW popup, Escape stack, computed-style identity.
|
||||
// Usage: node packages/sw/tests/integrated-consumer.browser.mjs <consumer-dir> <evidence-dir>
|
||||
import { createRequire } from "node:module"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const dist = path.resolve(process.argv[2], "dist")
|
||||
const out = path.resolve(process.argv[3])
|
||||
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? "C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright")
|
||||
await mkdir(out, { recursive: true })
|
||||
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".ttf": "font/ttf" }
|
||||
const server = createServer(async (request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url, "http://x").pathname)
|
||||
const file = path.join(dist, pathname.endsWith("/") ? `${pathname}index.html` : pathname)
|
||||
let body
|
||||
try { body = await readFile(file) } catch { response.writeHead(404); response.end(); return }
|
||||
response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" })
|
||||
response.end(body)
|
||||
}).listen(0, "127.0.0.1")
|
||||
await new Promise(resolve => server.once("listening", resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}/`
|
||||
|
||||
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" })
|
||||
const report = { browser: browser.version(), checks: [], errors: [], styles: {} }
|
||||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? "PASS" : "FAIL", data }); console.log(ok ? "PASS" : "FAIL", name, ok ? "" : JSON.stringify(data ?? "")) }
|
||||
|
||||
// Computed styles of representative SW and UI elements; must not depend on the stylesheet order.
|
||||
const snapshot = page => page.evaluate(() => {
|
||||
const pick = (selector, props) => { const e = document.querySelector(selector); if (!e) return null; const s = getComputedStyle(e), r = e.getBoundingClientRect(); return { w: Math.round(r.width * 10) / 10, h: Math.round(r.height * 10) / 10, ...Object.fromEntries(props.map(p => [p, s.getPropertyValue(p)])) } }
|
||||
const box = ["background-color", "background-image", "border-top", "border-radius", "box-shadow", "color", "font-family", "font-size", "font-weight", "padding", "margin"]
|
||||
return {
|
||||
header: pick('[data-example="a"] .hm-sw-header', box),
|
||||
field: pick('[data-example="a"] .hm-sw-header__field--layer', box),
|
||||
documentButton: pick('[data-example="a"] .hm-sw-header__document-button', box),
|
||||
toggle: pick('[data-example="a"] .hm-sw-header__toggle', box),
|
||||
open: pick('[data-example="f"] [data-document-open]', box),
|
||||
toolText: pick('[data-example="f"] .hm-sw-tool__text-button', box),
|
||||
toolPill: pick('[data-example="f"] .hm-sw-tool__pill-button', box),
|
||||
popup: pick('[data-example="e"] .hm-sw-property', box),
|
||||
popupTitle: pick('[data-example="e"] .hm-sw-property__title', box),
|
||||
popupRow: pick('[data-example="e"] .hm-sw-property__row', box),
|
||||
popupInput: pick('[data-example="e"] .hm-sw-property__value .hm-sw-header__input', box),
|
||||
searchInput: pick('[data-example="d"] .hm-sw-header__input', box),
|
||||
uiButton: pick('[data-example="ui"] [data-slot="button"]', box),
|
||||
uiSwitch: pick('[data-example="ui"] [data-slot="switch"]', box),
|
||||
uiSelect: pick('[data-example="ui"] [data-slot="select-trigger"]', box),
|
||||
}
|
||||
})
|
||||
|
||||
for (const mode of ["sw-first", "ui-first"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
|
||||
page.on("pageerror", error => report.errors.push(`${mode}: ${error.message}`))
|
||||
page.on("console", message => { if (message.type() === "error") report.errors.push(`${mode}: ${message.text()}`) })
|
||||
const events = async () => (await page.locator("#events").textContent()).split("\n").filter(Boolean)
|
||||
const last = async () => (await events()).at(-1)
|
||||
const section = id => page.locator(`[data-example="${id}"]`)
|
||||
await page.goto(`${base}${mode}/`)
|
||||
await section("f").locator("header.hm-sw-header").waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.mouse.move(1, 1)
|
||||
const assets = await page.evaluate(() => [...document.images].map(image => image.complete && image.naturalWidth > 0))
|
||||
check(`${mode}: every SVG/PNG asset decodes`, assets.length > 0 && assets.every(Boolean), assets.length)
|
||||
report.styles[mode] = await snapshot(page)
|
||||
await page.screenshot({ path: path.join(out, `${mode}-1920.png`), fullPage: true })
|
||||
|
||||
// F: Open next to the logo, no + New, no freeze / lock, tool bar.
|
||||
const f = section("f")
|
||||
const open = f.locator("[data-document-open]")
|
||||
check(`${mode} F: Open next to the logo with documentOpenLabel`, await open.textContent() === "파일 열기" && await open.evaluate(e => e.closest(".hm-sw-header__leading")?.previousElementSibling?.classList.contains("hm-sw-header__home") === true))
|
||||
await open.click()
|
||||
check(`${mode} F: Open calls onDocumentOpen`, await last() === "document:open", await last())
|
||||
await f.locator(".hm-sw-header__control[data-control=\"document\"] .hm-sw-header__document-button").click()
|
||||
check(`${mode} F: document dropdown without + New and Open (both hidden or placed)`, await f.locator(".hm-sw-header__document-row").count() === 2 && await f.locator(".hm-sw-header__document-footer").count() === 0)
|
||||
await page.keyboard.press("Escape")
|
||||
await f.locator(".hm-sw-header__field--layer").click()
|
||||
check(`${mode} F: layer rows have no freeze / lock buttons`, await f.locator(".hm-sw-header__layer-toggle--freeze, .hm-sw-header__layer-toggle--lock").count() === 0 && await f.locator(".hm-sw-header__layer-row [data-empty]").count() === 8)
|
||||
await page.keyboard.press("Escape")
|
||||
await f.getByRole("radio", { name: "Model" }).focus()
|
||||
await page.keyboard.press("ArrowRight")
|
||||
check(`${mode} F: Model / Layout radio group by keyboard`, await last() === "view:layout" && await f.getByRole("radio", { name: "Layout" }).getAttribute("aria-checked") === "true", await last())
|
||||
await f.getByRole("button", { name: "2.5D" }).click()
|
||||
check(`${mode} F: 2.5D toggle`, await last() === "2.5d:true" && await f.getByRole("button", { name: "2.5D" }).getAttribute("aria-pressed") === "true")
|
||||
await f.getByRole("button", { name: "화면 맞춤" }).click()
|
||||
check(`${mode} F: Fit pill`, await last() === "fit")
|
||||
await page.setViewportSize({ width: 640, height: 1080 })
|
||||
await page.waitForTimeout(200)
|
||||
const narrow = await f.locator("header.hm-sw-header").evaluate(h => ({ open: h.querySelectorAll("[data-document-open]").length, overflow: h.querySelector(".hm-sw-header__bar").scrollWidth - h.querySelector(".hm-sw-header__bar").clientWidth, height: h.getBoundingClientRect().height }))
|
||||
await f.locator(".hm-sw-header__more-trigger").click()
|
||||
const moreOpen = f.locator(".hm-sw-header__more-item", { hasText: "파일 열기" })
|
||||
const inMore = await moreOpen.isVisible()
|
||||
if (inMore) await moreOpen.click()
|
||||
check(`${mode} F 640px: Open moved into More (measured), works there, no overflow`, narrow.open === 0 && inMore && await last() === "document:open" && narrow.overflow <= 1 && narrow.height === 49, { narrow, inMore })
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await page.waitForTimeout(200)
|
||||
check(`${mode} F 1920px again: Open returns next to the logo`, await f.locator("[data-document-open]").count() === 1)
|
||||
|
||||
// E: custom popup.
|
||||
const e = section("e")
|
||||
const frame = await e.locator(".hm-sw-property").evaluate(el => { const s = getComputedStyle(el); return { w: el.getBoundingClientRect().width, bg: s.backgroundColor, border: s.borderTopColor } })
|
||||
check(`${mode} E: SW popup frame (314, #E4DBC9, #A67D03)`, frame.w === 314 && frame.bg === "rgb(228, 219, 201)" && frame.border === "rgb(166, 125, 3)", frame)
|
||||
const name = e.getByLabel("이름")
|
||||
await name.click(); await name.fill("평면도-02"); await name.press("Enter")
|
||||
check(`${mode} E: row input commits`, await last() === "name:평면도-02", await last())
|
||||
await e.locator(".hm-sw-header__field--layer").click()
|
||||
const menu = e.locator(".hm-sw-header__layer-menu")
|
||||
check(`${mode} E: layer dropdown inside the popup is a fixed overlay`, await menu.evaluate(el => getComputedStyle(el).position) === "fixed")
|
||||
await menu.locator(".hm-sw-header__layer-item", { hasText: "DIM" }).click()
|
||||
check(`${mode} E: layer selection callback`, await last() === "layer:dim", await last())
|
||||
|
||||
// HM UI inside an SW popup.
|
||||
const ui = section("ui")
|
||||
await ui.getByRole("switch", { name: "스냅" }).click()
|
||||
check(`${mode} UI in popup: Switch`, await last() === "ui:snap:false", await last())
|
||||
await ui.getByRole("combobox", { name: "단위" }).click()
|
||||
await page.getByRole("option", { name: "cm" }).click()
|
||||
check(`${mode} UI in popup: Select (portal)`, await last() === "ui:unit:cm", await last())
|
||||
await ui.getByRole("slider", { name: "투명도" }).focus()
|
||||
await page.keyboard.press("ArrowRight")
|
||||
await page.waitForTimeout(100)
|
||||
check(`${mode} UI in popup: Slider`, /ui:opacity:70/.test(await last()), await last())
|
||||
await ui.getByRole("button", { name: "적용" }).click()
|
||||
check(`${mode} UI in popup: Button`, await last() === "ui:apply", await last())
|
||||
|
||||
// Escape stack: the UI popup was used last, so Escape outside closes it only; then the example E popup.
|
||||
await page.mouse.click(1900, 1060)
|
||||
await page.keyboard.press("Escape")
|
||||
check(`${mode} Escape outside closes only the last used popup`, await last() === "ui:close" && await e.locator(".hm-sw-property").count() === 1, await last())
|
||||
await e.getByLabel("X").click()
|
||||
await page.keyboard.press("Escape")
|
||||
check(`${mode} Escape inside a popup closes that popup`, await last() === "popup:close", await last())
|
||||
await page.close()
|
||||
}
|
||||
|
||||
const keys = Object.keys(report.styles["sw-first"])
|
||||
const differences = keys.filter(key => JSON.stringify(report.styles["sw-first"][key]) !== JSON.stringify(report.styles["ui-first"][key]))
|
||||
check("stylesheet order (SW first / UI first) changes no computed SW or UI style", differences.length === 0 && keys.every(key => report.styles["sw-first"][key] !== null), { differences, missing: keys.filter(key => report.styles["sw-first"][key] === null) })
|
||||
check("no page/console errors", report.errors.length === 0, report.errors)
|
||||
await writeFile(path.join(out, "integrated-consumer-result.json"), JSON.stringify(report, null, 2) + "\n")
|
||||
await browser.close()
|
||||
server.close()
|
||||
const failed = report.checks.filter(item => item.status === "FAIL").length
|
||||
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
|
||||
process.exitCode = failed ? 1 : 0
|
||||
@@ -0,0 +1,118 @@
|
||||
// Independent consumer of the 2026-10-19 SW integrated improvement: React 19 + TypeScript + Vite + Tailwind CSS 4
|
||||
// with BOTH tarballs (@hm-design/sw and @hm-design/ui). Two builds load the same app with the stylesheets in both
|
||||
// orders (SW first / UI first) to show that they do not change each other. The shipped ai/examples (A–F) are
|
||||
// copied from node_modules, plus a popup holding HM UI components.
|
||||
// Usage: node packages/sw/tests/integrated-consumer.mjs <empty-dir-outside-the-repo>
|
||||
// Build first: npm run build -w @hm-design/sw && npm run build -w @hm-design/ui
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { cp, mkdir, readdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
||||
const repoRoot = path.resolve(packageRoot, "../..")
|
||||
const out = path.resolve(process.argv[2] ?? "")
|
||||
const relative = path.relative(repoRoot, out)
|
||||
if (!process.argv[2] || !(relative.startsWith("..") || path.isAbsolute(relative))) throw new Error("Pass an empty directory outside the repository")
|
||||
await mkdir(out, { recursive: true })
|
||||
if ((await readdir(out)).length) throw new Error(`${out} is not empty`)
|
||||
|
||||
const npm = (args, cwd) => execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: process.platform === "win32" })
|
||||
npm(["pack", "--ignore-scripts", "--pack-destination", out], packageRoot)
|
||||
npm(["pack", "--ignore-scripts", "--pack-destination", out], path.join(repoRoot, "packages/ui"))
|
||||
const tarballs = (await readdir(out)).filter(name => name.endsWith(".tgz"))
|
||||
|
||||
const app = `import { useState } from "react"
|
||||
import { SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
|
||||
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Switch } from "@hm-design/ui"
|
||||
import { ExampleADefaultHeader } from "./examples/ExampleA-DefaultHeader"
|
||||
import { ExampleBViewerHeader } from "./examples/ExampleB-ViewerHeader"
|
||||
import { ExampleCFeatureHeader } from "./examples/ExampleC-FeatureHeader"
|
||||
import { ExampleDSearchExtension } from "./examples/ExampleD-SearchExtension"
|
||||
import { ExampleECustomPopup } from "./examples/ExampleE-CustomPopup"
|
||||
import { ExampleFViewerToolbar } from "./examples/ExampleF-ViewerToolbar"
|
||||
|
||||
const examples = { a: ExampleADefaultHeader, b: ExampleBViewerHeader, c: ExampleCFeatureHeader, d: ExampleDSearchExtension, e: ExampleECustomPopup, f: ExampleFViewerToolbar }
|
||||
|
||||
function UiPopup({ onEvent }: { onEvent: (name: string) => void }) {
|
||||
const [snap, setSnap] = useState(true)
|
||||
const [unit, setUnit] = useState("mm")
|
||||
const [opacity, setOpacity] = useState([60])
|
||||
return (
|
||||
<SWTheme>
|
||||
<SWPopup title="HM UI in SW Popup" onClose={() => onEvent("ui:close")}>
|
||||
<SWPopupSection title="SW">
|
||||
<SWPopupRow label="메모"><SWHeaderInput aria-label="메모" value="A" onCommit={next => onEvent("ui:memo:" + next)} /></SWPopupRow>
|
||||
</SWPopupSection>
|
||||
<SWPopupSection title="HM UI">
|
||||
<SWPopupRow label="스냅" field="none"><Switch aria-label="스냅" checked={snap} onCheckedChange={next => { setSnap(next); onEvent("ui:snap:" + next) }} /></SWPopupRow>
|
||||
<SWPopupRow label="단위" field="none">
|
||||
<Select value={unit} onValueChange={next => { setUnit(next); onEvent("ui:unit:" + next) }}>
|
||||
<SelectTrigger aria-label="단위"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{["mm", "cm", "m"].map(item => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</SWPopupRow>
|
||||
<SWPopupRow label="투명도" field="none"><Slider aria-label="투명도" value={opacity} min={0} max={100} step={10} onValueChange={setOpacity} onValueCommit={next => onEvent("ui:opacity:" + next[0])} /></SWPopupRow>
|
||||
</SWPopupSection>
|
||||
<Button size="sm" onClick={() => onEvent("ui:apply")}>적용</Button>
|
||||
</SWPopup>
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [events, setEvents] = useState<string[]>([])
|
||||
const onEvent = (name: string) => setEvents(list => [...list, name])
|
||||
return (
|
||||
<>
|
||||
{Object.entries(examples).map(([id, Example]) => (
|
||||
<section key={id} data-example={id} style={{ marginBottom: 360 }}><Example onEvent={onEvent} /></section>
|
||||
))}
|
||||
<section data-example="ui" style={{ padding: 16, background: "#3a3a3a" }}><UiPopup onEvent={onEvent} /></section>
|
||||
<pre id="events">{events.join("\\n")}</pre>
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
// The bundler merges the CSS of pages that share modules, so the two stylesheet orders are two builds
|
||||
// (--mode sw-first / ui-first): "app-order.css" is aliased to the stylesheet entry of that order.
|
||||
const entry = `import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "app-order.css"
|
||||
import { App } from "./App"
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>)
|
||||
`
|
||||
const viteConfig = `import { fileURLToPath } from "node:url"
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { alias: { "app-order.css": fileURLToPath(new URL(mode === "ui-first" ? "./src/ui-first.css" : "./src/sw-first.css", import.meta.url)) } },
|
||||
build: { outDir: mode === "ui-first" ? "dist/ui-first" : "dist/sw-first" },
|
||||
}))
|
||||
`
|
||||
const ui = `@import "tailwindcss";\n@import "@hm-design/ui/tailwind.css";\n@import "@hm-design/ui/theme.css";\n`
|
||||
const sw = `@import "@hm-design/sw/styles.css";\n`
|
||||
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SW consumer</title><link rel="icon" href="data:," /><style>body { margin: 0; }</style></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n`
|
||||
const files = {
|
||||
"package.json": JSON.stringify({ name: "sw-integrated-consumer", private: true, type: "module", scripts: { typecheck: "tsc -p tsconfig.json", build: "vite build --mode sw-first && vite build --mode ui-first" } }, null, 2),
|
||||
"tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: false, isolatedModules: true, noUnusedLocals: true, noUnusedParameters: true, types: ["vite/client"] }, include: ["src"] }, null, 2),
|
||||
"vite.config.ts": viteConfig,
|
||||
"index.html": html,
|
||||
"src/sw-first.css": sw + ui,
|
||||
"src/ui-first.css": ui + sw,
|
||||
"src/App.tsx": app,
|
||||
"src/main.tsx": entry,
|
||||
}
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
await mkdir(path.dirname(path.join(out, name)), { recursive: true })
|
||||
await writeFile(path.join(out, name), text)
|
||||
}
|
||||
npm(["install", "--no-audit", "--no-fund", ...tarballs.map(name => `./${name}`), "react@19.3.0", "react-dom@19.3.0", "tailwindcss@4.3.3"], out)
|
||||
npm(["install", "--no-audit", "--no-fund", "-D", "typescript@7.0.2", "vite@8.3.0", "@vitejs/plugin-react@6.1.1", "@tailwindcss/vite@4.3.3", "@types/react@19.3.0", "@types/react-dom@19.3.0"], out)
|
||||
await cp(path.join(out, "node_modules/@hm-design/sw/ai/examples"), path.join(out, "src/examples"), { recursive: true })
|
||||
npm(["run", "typecheck"], out)
|
||||
npm(["run", "build"], out)
|
||||
console.log(`Consumer ready: ${out}. Next: node packages/sw/tests/integrated-consumer.browser.mjs ${out} <evidence-dir>`)
|
||||
@@ -0,0 +1,202 @@
|
||||
// Functional checks of the 2026-10-19 SW integrated improvement on the built Storybook (storybook-static).
|
||||
// Usage: node packages/sw/tests/integrated-improvement.browser.mjs <evidence-dir> [section ...]
|
||||
// Sections: options, tool, popup (default: all). Screenshots and result JSON go to <evidence-dir>.
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
|
||||
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||||
const require = createRequire(path.join(runtime, 'index.js'))
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/integrated-improvement-qa/verify')
|
||||
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'tool', 'popup']
|
||||
await mkdir(out, { recursive: true })
|
||||
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||||
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const { chromium } = require('playwright')
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } })
|
||||
const report = { browser: browser.version(), checks: [], errors: [], data: {} }
|
||||
page.on('pageerror', e => report.errors.push(e.message))
|
||||
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
|
||||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
|
||||
const goto = async id => {
|
||||
await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`)
|
||||
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.mouse.move(1, 1)
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
const log = () => page.locator('[data-callback-log]').first().textContent()
|
||||
const rect = locator => locator.evaluate(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, right: r.right } })
|
||||
|
||||
if (sections.includes('options')) {
|
||||
await goto('sw-header-options--open-placement')
|
||||
const frames = page.locator('.sw-story-frame')
|
||||
// Default placement unchanged: no header Open, dropdown footer keeps + New / Open.
|
||||
const plain = frames.nth(0).locator('header.hm-sw-header')
|
||||
check('menu (default): no Open next to the logo', await plain.locator('[data-document-open]').count() === 0)
|
||||
await plain.locator('.hm-sw-header__document-button').click()
|
||||
check('menu (default): dropdown footer keeps + New and Open', JSON.stringify(await plain.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New","Open"]')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const plainHome = await rect(plain.locator('.hm-sw-header__home')), plainFile = await rect(plain.locator('.hm-sw-header__document-button'))
|
||||
const junction = plainFile.x - plainHome.right // the file name's place after the logo in the unchanged header
|
||||
report.data.junction = junction
|
||||
const header = page.locator('[data-open-placement="header"] header.hm-sw-header')
|
||||
const open = header.locator('[data-document-open]')
|
||||
const home = await rect(header.locator('.hm-sw-header__home')), openBox = await rect(open), docBox = await rect(header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button'))
|
||||
check('header: Open takes the file name place right of the logo (same junction offset, 48px)', Math.abs(openBox.x - home.right - junction) < 0.6 && openBox.h === 48, { home, openBox, junction })
|
||||
check('header: file name follows Open without overlap', docBox.x >= openBox.right - 0.5, { openBox, docBox })
|
||||
const styles = await open.evaluate(e => { const s = getComputedStyle(e); return { cls: e.className, bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft, borderL: s.borderLeftWidth, borderR: s.borderRightWidth } })
|
||||
const fileStyles = await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').evaluate(e => { const s = getComputedStyle(e); return { bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft } })
|
||||
check('header: Open uses hm-sw-header__document-button (same surface, color, weight as the file name)', styles.cls.includes('hm-sw-header__document-button') && styles.bg === fileStyles.bg && styles.color === fileStyles.color && styles.weight === fileStyles.weight && styles.padL === '5px', { styles, fileStyles })
|
||||
check('D-1: file name after Open starts with 12px padding', fileStyles.padL === '12px', fileStyles)
|
||||
await open.hover()
|
||||
check('header: Open hover turns only the text #FFC600', await open.locator('.hm-sw-header__document-name').evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
|
||||
await page.mouse.move(1, 1)
|
||||
await page.locator('[data-open-placement="header"]').screenshot({ path: path.join(out, 'open-placement-header.png') })
|
||||
await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').click()
|
||||
check('header: dropdown footer has + New only (no duplicate Open)', JSON.stringify(await header.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
|
||||
await page.keyboard.press('Escape')
|
||||
await open.click()
|
||||
check('header: Open calls onDocumentOpen', (await log()).includes('onDocumentOpen'))
|
||||
check('documentOpenLabel is the Open text', await frames.nth(2).locator('[data-document-open]').textContent() === '파일 열기')
|
||||
|
||||
// Responsive: measured room. No overflow anywhere; Open is either shown or reachable in More.
|
||||
const widths = []
|
||||
for (const item of await page.locator('[data-responsive-open] > [data-width]').all()) {
|
||||
const width = Number(await item.getAttribute('data-width'))
|
||||
const h = item.locator('header.hm-sw-header')
|
||||
const state = await h.evaluate(header => {
|
||||
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
|
||||
const open = header.querySelector('[data-document-open]'), doc = header.querySelector('.hm-sw-header__bar > .hm-sw-header__document')
|
||||
const layer = header.querySelector('.hm-sw-header__field--layer')
|
||||
const parts = [...bar.children].filter(e => getComputedStyle(e).display !== 'none').map(e => e.getBoundingClientRect())
|
||||
const overlap = parts.some((r, i) => i > 1 && r.left < parts[i - 1].right - 0.5)
|
||||
return {
|
||||
height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth,
|
||||
headerRight: header.getBoundingClientRect().right, barRight: Math.max(...parts.map(r => r.right)), overlap,
|
||||
open: open ? open.getBoundingClientRect().width : 0, doc: doc ? doc.getBoundingClientRect().width : 0, layer: layer ? layer.getBoundingClientRect().width : 0,
|
||||
more: !!header.querySelector('.hm-sw-header__more') && getComputedStyle(header.querySelector('.hm-sw-header__more')).display !== 'none',
|
||||
}
|
||||
})
|
||||
let inMore = null
|
||||
if (!state.open) {
|
||||
await h.locator('.hm-sw-header__more-trigger').click()
|
||||
const itemOpen = h.locator('.hm-sw-header__more-item', { hasText: /^Open$/ })
|
||||
inMore = await itemOpen.isVisible()
|
||||
if (inMore) { await itemOpen.click(); inMore = (await log()).endsWith('onDocumentOpen') } else await page.keyboard.press('Escape')
|
||||
}
|
||||
widths.push({ width, ...state, inMore })
|
||||
check(`responsive ${width}: 49px, no overflow or overlap, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && !state.overlap && state.barRight <= state.headerRight + 0.5, state)
|
||||
check(`responsive ${width}: Open ${state.open ? 'shown next to the logo' : 'moved into More and works there'}`, state.open > 0 || inMore === true, { open: state.open, inMore })
|
||||
if (width >= 520) check(`responsive ${width}: layer field keeps its Figma width (234) while Open is shown`, !state.open || Math.abs(state.layer - 234) < 0.6 || state.layer === 0, state.layer)
|
||||
}
|
||||
report.data.responsive = widths
|
||||
await page.locator('[data-responsive-open]').screenshot({ path: path.join(out, 'open-placement-responsive.png') })
|
||||
// Hysteresis: shrink a header below the collapse width, then grow it back; Open returns, never flickers in between.
|
||||
const probe = page.locator('[data-responsive-open] > [data-width="520"] .sw-story-frame')
|
||||
const shown = async () => probe.locator('[data-document-open]').count()
|
||||
const trace = []
|
||||
for (const w of [520, 560, 600, 640, 680, 720, 760, 820, 900, 1080]) { await probe.evaluate((e, w) => { e.style.width = `${w}px` }, w); await page.waitForTimeout(80); trace.push([w, await shown()]) }
|
||||
report.data.hysteresis = trace
|
||||
const flips = trace.slice(1).filter((step, index) => step[1] !== trace[index][1]).length
|
||||
check('resize: collapsed Open returns once when the header grows (measured, with hysteresis)', trace[0][1] === 0 && trace.at(-1)[1] === 1 && flips === 1, trace)
|
||||
|
||||
await goto('sw-header-options--hidden-sub-controls')
|
||||
const onlyOpen = page.locator('[data-hidden="new"] .hm-sw-header__document-footer')
|
||||
const footer = await rect(onlyOpen), action = await rect(onlyOpen.locator('.hm-sw-header__document-action'))
|
||||
check('hidden documentNew: footer keeps Open on its right side', JSON.stringify(await onlyOpen.locator('.hm-sw-header__document-action').allTextContents()) === '["Open"]' && Math.abs(footer.right - 8 - action.right) < 1, { footer, action })
|
||||
check('hidden documentNew + documentOpen: no footer', await page.locator('[data-hidden="new-open"] .hm-sw-header__document-footer').count() === 0 && await page.locator('[data-hidden="new-open"] .hm-sw-header__document-row').count() === 2)
|
||||
const layer = page.locator('[data-hidden="layer-states"]')
|
||||
const icons = await layer.locator('.hm-sw-header__field--layer .hm-sw-header__layer-icons > *').evaluateAll(list => list.map(e => ({ empty: e.hasAttribute('data-empty'), w: e.getBoundingClientRect().width })))
|
||||
check('hidden layerFreeze / layerLock: field draws the bulb only, two empty 20px places', icons.length === 3 && !icons[0].empty && icons[1].empty && icons[2].empty && icons.every(i => i.w === 20), icons)
|
||||
const rows = await layer.locator('.hm-sw-header__layer-row').evaluateAll(list => list.map(row => ({ buttons: [...row.querySelectorAll('button')].map(b => b.className.split(' ').at(-1)), empty: row.querySelectorAll('[data-empty]').length, name: row.querySelector('.hm-sw-header__layer-name').getBoundingClientRect().x })))
|
||||
check('hidden layerFreeze / layerLock: rows have no snowflake / lock button, places kept', rows.every(r => r.empty === 2 && !r.buttons.some(b => /freeze|lock/.test(b))), rows)
|
||||
await layer.locator('.hm-sw-header__layer-toggle--visibility').first().click()
|
||||
check('hidden layer states: the bulb still reports its callback', (await log()).includes('onLayerVisibilityChange(0, false)'))
|
||||
await page.locator('[data-hidden="layer-states"]').screenshot({ path: path.join(out, 'hidden-layer-states.png') })
|
||||
await page.locator('[data-hidden="new"]').screenshot({ path: path.join(out, 'hidden-document-new.png') })
|
||||
|
||||
await goto('sw-header-options--composed-open')
|
||||
const bar = page.locator('[data-composed-open]')
|
||||
const cHome = await rect(bar.locator('.hm-sw-header__home')), cOpen = await rect(bar.locator('.hm-sw-header__document[data-tool] button')), cDoc = bar.locator('.hm-sw-header__document:not([data-tool]) .hm-sw-header__document-button')
|
||||
check('composed: Open right of the logo at the same junction offset as SWHeader', Math.abs(cOpen.x - cHome.right - report.data.junction) < 0.6 && cOpen.h === 48, { cHome, cOpen })
|
||||
check('composed: file name after Open has 12px padding', await cDoc.evaluate(e => getComputedStyle(e).paddingLeft) === '12px')
|
||||
await bar.locator('.hm-sw-header__document[data-tool] button').click()
|
||||
check('composed: Open callback', (await log()).includes('onDocumentOpen'))
|
||||
await cDoc.click()
|
||||
check('composed: showOpen={false} removes Open from the dropdown', JSON.stringify(await bar.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
|
||||
await page.keyboard.press('Escape')
|
||||
await bar.screenshot({ path: path.join(out, 'composed-open.png') })
|
||||
}
|
||||
|
||||
if (sections.includes('options') || sections.includes('composed')) {
|
||||
await goto('sw-header-options--composed-responsive')
|
||||
const rows = []
|
||||
for (const item of await page.locator('[data-composed-responsive] > [data-width]').all()) {
|
||||
const width = Number(await item.getAttribute('data-width'))
|
||||
const h = item.locator('header.hm-sw-header')
|
||||
const state = await h.evaluate(header => {
|
||||
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
|
||||
const visible = selector => [...header.querySelectorAll(selector)].filter(e => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden').length
|
||||
const clipped = [...header.querySelectorAll('.hm-sw-header__control:not([data-hidden]) .hm-sw-header__field')].some(f => f.getBoundingClientRect().width < 40)
|
||||
return { height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth, open: visible('[data-document-open]'), more: visible('.hm-sw-header__more-trigger'), clipped, right: Math.max(...[...bar.children].map(e => e.getBoundingClientRect().right)) - header.getBoundingClientRect().right }
|
||||
})
|
||||
rows.push({ width, ...state })
|
||||
check(`composed responsive ${width}: 49px, no overflow, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && state.right <= 0.5, state)
|
||||
if (!state.open) {
|
||||
await h.locator('.hm-sw-header__more-trigger').click()
|
||||
const entry = h.locator('.hm-sw-header__more-item', { hasText: '파일 열기' })
|
||||
const ok = await entry.isVisible()
|
||||
if (ok) await entry.click(); else await page.keyboard.press('Escape')
|
||||
check(`composed responsive ${width}: 파일 열기 reachable in More`, ok && (await log()).endsWith('onDocumentOpen'))
|
||||
}
|
||||
}
|
||||
report.data.composedResponsive = rows
|
||||
await page.locator('[data-composed-responsive]').screenshot({ path: path.join(out, 'composed-responsive.png') })
|
||||
}
|
||||
|
||||
if (sections.includes('tool')) {
|
||||
await goto('sw-tool--tool-buttons')
|
||||
const group = page.locator('[data-view-group]').first()
|
||||
check('tool: Model / Layout is a radio group', await group.getAttribute('role') === 'radiogroup' && await group.locator('[role="radio"]').count() === 2)
|
||||
await group.locator('[role="radio"]').first().focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
check('tool: arrow key moves focus and selects Layout', await group.locator('[role="radio"]').nth(1).getAttribute('aria-checked') === 'true' && (await log()).includes('view(layout)'))
|
||||
const depth = page.locator('[data-display-group] button').first()
|
||||
await depth.click()
|
||||
check('tool: 2.5D toggle reports and shows pressed (#FFC600)', await depth.getAttribute('aria-pressed') === 'true' && await depth.evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
|
||||
const text = await page.locator('[data-tool-states] .hm-sw-header__toggle').first().evaluate(e => { const s = getComputedStyle(e); return { weight: s.fontWeight, h: e.getBoundingClientRect().height } })
|
||||
const ref = await page.locator('[data-display-group]').first().evaluate(e => getComputedStyle(e.querySelector('.hm-sw-header__properties-caption')).fontSize)
|
||||
check('tool: text buttons are the 속성Bar toggle look (700, 19px row, 12px caption)', text.weight === '700' && text.h === 19 && ref === '12px', { text, ref })
|
||||
const pill = page.locator('[data-fit-group] button').first()
|
||||
const pillBox = await rect(pill)
|
||||
await pill.hover()
|
||||
const hover = await pill.evaluate(e => ({ ring: getComputedStyle(e, '::after').opacity, icon: getComputedStyle(e.querySelector('.hm-sw-header__icon')).visibility, hoverIcon: getComputedStyle(e.querySelector('.hm-sw-header__icon-hover')).visibility }))
|
||||
check('tool: pill button is 40 x 38 with the yellow ring and hover icon', pillBox.w === 40 && pillBox.h === 38 && hover.ring === '1' && hover.icon === 'hidden' && hover.hoverIcon === 'visible', { pillBox, hover })
|
||||
await page.mouse.move(1, 1)
|
||||
await pill.click()
|
||||
check('tool: pill click callback', (await log()).includes('fit'))
|
||||
check('tool: pill accessible name from its text', await pill.evaluate(e => e.textContent.trim()) === '화면 맞춤')
|
||||
const toolBar = page.locator('[data-tool-bar]')
|
||||
const tHome = await rect(toolBar.locator('.hm-sw-header__home')), tOpen = await rect(toolBar.locator('.hm-sw-header__document[data-tool] button'))
|
||||
check('tool: document look keeps the logo junction in a composed bar', Math.abs(tOpen.x - tHome.right - (report.data.junction ?? -15)) < 0.6, { tHome, tOpen })
|
||||
for (const [name, locator] of [['tool-in-header', page.locator('.sb-page-section').nth(0)], ['tool-composed', page.locator('.sb-page-section').nth(1)], ['tool-states', page.locator('.sb-page-section').nth(2)]]) await locator.screenshot({ path: path.join(out, `${name}.png`) })
|
||||
}
|
||||
|
||||
if (sections.includes('popup')) {
|
||||
const { popupChecks } = await import('./integrated-popup.checks.mjs')
|
||||
await popupChecks({ page, goto, check, log, rect, out, report })
|
||||
}
|
||||
|
||||
check('no browser console or page errors', report.errors.length === 0, report.errors)
|
||||
const failed = report.checks.filter(c => c.status === 'FAIL').length
|
||||
report.status = failed ? 'FAIL' : 'PASS'
|
||||
await writeFile(path.join(out, 'integrated-improvement-result.json'), JSON.stringify(report, null, 2) + '\n')
|
||||
console.log(`TOTAL ${report.checks.length} FAIL ${failed}`)
|
||||
await browser.close(); server.close()
|
||||
if (failed) process.exitCode = 1
|
||||
@@ -0,0 +1,92 @@
|
||||
// SW/Popup checks for integrated-improvement.browser.mjs (2026-10-19): frame identity, free content (SW + HM UI),
|
||||
// callbacks, dropdown clipping, Escape / focus with several popups, scrolling.
|
||||
import path from 'node:path'
|
||||
|
||||
export async function popupChecks({ page, goto, check, log, rect, out, report }) {
|
||||
await goto('sw-popup--custom-content')
|
||||
const frames = page.locator('[data-popups] .hm-sw-property')
|
||||
const frame = await frames.evaluateAll(list => list.map(e => { const s = getComputedStyle(e), t = getComputedStyle(e.querySelector('.hm-sw-property__title')); return { w: e.getBoundingClientRect().width, bg: s.backgroundColor, border: s.borderTopColor, radius: s.borderRadius, shadow: s.boxShadow, title: t.color, weight: t.fontWeight, titlebar: e.querySelector('.hm-sw-property__titlebar').getBoundingClientRect().height, role: e.getAttribute('role'), modal: e.getAttribute('aria-modal') } }))
|
||||
report.data.popupFrames = frame
|
||||
const same = frame.every(f => JSON.stringify(f) === JSON.stringify(frame[2]))
|
||||
check('popup: SWPopup frames match the property window frame (314, surface, border, radius, title)', frame.length === 3 && same && frame[0].w === 314 && frame[0].bg === 'rgb(228, 219, 201)' && frame[0].border === 'rgb(166, 125, 3)' && frame[0].radius === '4px' && frame[0].title === 'rgb(255, 198, 0)' && frame[0].titlebar === 30 && frame[0].role === 'dialog' && frame[0].modal === 'false', frame)
|
||||
const settings = page.locator('[data-popup="settings"]')
|
||||
const commit = async (locator, value) => { await locator.click(); await locator.fill(value); await locator.press('Enter') }
|
||||
await commit(settings.getByLabel('이름'), '평면도-02')
|
||||
await commit(settings.getByLabel('축척'), '50')
|
||||
check('popup content: SWHeaderInput rows commit through the host callbacks', (await log()).includes('name=평면도-02') && (await log()).includes('scale=50'))
|
||||
const rowStyle = await settings.locator('.hm-sw-property__row').first().evaluate(e => ({ label: getComputedStyle(e.querySelector('label')).width, minH: getComputedStyle(e).minHeight, box: getComputedStyle(e.querySelector('.hm-sw-property__value')).height }))
|
||||
check('popup content: SWPopupRow is the property row (72px label, 32px row, 28px box)', rowStyle.label === '72px' && rowStyle.minH === '32px' && rowStyle.box === '28px', rowStyle)
|
||||
await settings.locator('[data-ui-switch]').click()
|
||||
check('popup content: HM UI Switch works inside', (await log()).includes('snap=false'))
|
||||
await settings.locator('[data-ui-select]').click()
|
||||
await page.getByRole('option', { name: 'cm' }).click()
|
||||
check('popup content: HM UI Select (portal) works inside', (await log()).includes('unit=cm'))
|
||||
await settings.locator('[data-ui-slider] [role="slider"]').focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await page.waitForTimeout(100)
|
||||
check('popup content: HM UI Slider works inside', /opacity=70/.test(await log()), await log())
|
||||
await settings.locator('[data-ui-apply]').click()
|
||||
check('popup content: HM UI Button works inside', (await log()).includes('apply'))
|
||||
const layerField = settings.locator('[data-popup-layer] button.hm-sw-header__field')
|
||||
await layerField.click()
|
||||
const fieldBox = await rect(layerField), menu = settings.locator('.hm-sw-header__layer-menu')
|
||||
const menuBox = await rect(menu)
|
||||
const hit = await menu.locator('.hm-sw-header__layer-row').last().evaluate(row => { const r = row.getBoundingClientRect(); const e = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return row.contains(e) })
|
||||
check('popup dropdown: SW layer dropdown opens right under its field (fixed overlay) and is not clipped', await menu.evaluate(e => getComputedStyle(e).position) === 'fixed' && Math.abs(menuBox.y - (fieldBox.y + fieldBox.h)) < 1 && Math.abs(menuBox.x - fieldBox.x) < 1 && hit, { fieldBox, menuBox, hit })
|
||||
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdown.png') })
|
||||
await menu.locator('.hm-sw-header__layer-item', { hasText: 'DIM' }).click()
|
||||
check('popup dropdown: selection callback, list closes, popup stays', (await log()).includes('layer=dim') && await settings.locator('.hm-sw-header__layer-menu').count() === 0 && await settings.isVisible())
|
||||
await settings.locator('[data-popup-color] button.hm-sw-header__field').click()
|
||||
const colorPanel = settings.locator('.hm-sw-header__menu-panel')
|
||||
check('popup dropdown: SW color dropdown becomes a fixed overlay', await colorPanel.evaluate(e => getComputedStyle(e).position) === 'fixed')
|
||||
await colorPanel.getByLabel('초록').click()
|
||||
check('popup dropdown: color chip callback', (await log()).includes('color=green'))
|
||||
await page.keyboard.press('Escape')
|
||||
check('popup dropdown: Escape closes the dropdown only', await settings.locator('.hm-sw-header__menu-panel').count() === 0 && await settings.isVisible())
|
||||
|
||||
const object = page.locator('[data-popup="object"]')
|
||||
check('object popup: read-only rows', await object.getByLabel('이름').evaluate(e => e.readOnly) && await object.getByLabel('길이').evaluate(e => e.readOnly))
|
||||
await commit(object.getByLabel('X'), '1500')
|
||||
check('object popup: number row commits', (await log()).includes('x=1500'))
|
||||
const compact = await object.getByLabel('Y').evaluate(e => ({ cls: e.className, w: e.getBoundingClientRect().width }))
|
||||
check('object popup: compact row uses the 41px hug field', compact.cls.includes('hm-sw-property__compact') && compact.w < 60, compact)
|
||||
const panel = page.locator('[data-popups] .hm-sw-property').nth(2)
|
||||
const order = await panel.locator('.hm-sw-property__body > *').evaluateAll(list => list.map(e => e.getAttribute('aria-label')))
|
||||
check('property panel + children: children follow the property groups in the same body', JSON.stringify(order) === '["공통","메모"]', order)
|
||||
await commit(panel.getByLabel('설명'), '내벽')
|
||||
check('property panel + children: child content callback', (await log()).includes('memo=내벽'))
|
||||
await settings.locator('.hm-sw-property__close').click()
|
||||
check('popup: close button calls onClose', (await log()).includes('settings.onClose'))
|
||||
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-content.png') })
|
||||
|
||||
await goto('sw-popup--multiple-popups')
|
||||
const popup = id => page.locator(`[data-popup="${id}"]`)
|
||||
const b = popup('b')
|
||||
await b.locator('[data-popup-layer] button.hm-sw-header__field').click()
|
||||
const bMenu = b.locator('.hm-sw-header__layer-menu'), bHit = await bMenu.locator('.hm-sw-header__layer-footer').evaluate(row => { const r = row.getBoundingClientRect(); return row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) })
|
||||
const bBox = await rect(b), menuB = await rect(bMenu)
|
||||
check('short popup: dropdown extends past the popup and stays fully visible (no clipping)', menuB.y + menuB.h > bBox.y + bBox.h && bHit, { bBox, menuB, bHit })
|
||||
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') })
|
||||
await page.keyboard.press('Escape')
|
||||
check('several popups: Escape in a dropdown closes only the dropdown', await bMenu.count() === 0 && await popup('a').count() === 1 && await b.count() === 1 && await popup('c').count() === 1)
|
||||
const aInput = popup('a').getByLabel('A 값')
|
||||
await aInput.click(); await aInput.fill('A2'); await aInput.press('Escape')
|
||||
check('several popups: Escape on an edited input cancels the edit only', await aInput.inputValue() === 'A' && await popup('a').count() === 1)
|
||||
await aInput.press('Escape')
|
||||
check('several popups: Escape inside a popup closes that popup only', (await log()).endsWith('close(a)') && await b.count() === 1 && await popup('c').count() === 1, await log())
|
||||
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.press('Escape')
|
||||
// B was used last (its dropdown), so B is the top popup, not C (opened last).
|
||||
check('several popups: Escape outside closes only the top popup (last used: B)', (await log()).endsWith('close(b)') && await popup('c').count() === 1, await log())
|
||||
await page.locator('[data-reopen]').click()
|
||||
await popup('a').locator('.hm-sw-property__titlebar').click()
|
||||
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.press('Escape')
|
||||
check('several popups: the popup used last becomes the top', (await log()).endsWith('close(a)'), await log())
|
||||
const c = popup('c').locator('.hm-sw-property__body')
|
||||
const scroll = await c.evaluate(e => ({ sh: e.scrollHeight, ch: e.clientHeight, overflow: getComputedStyle(e).overflowY }))
|
||||
await c.evaluate(e => { e.scrollTop = e.scrollHeight })
|
||||
const lastVisible = await popup('c').getByLabel('필드 30').evaluate(e => { const r = e.getBoundingClientRect(); return e.closest('.hm-sw-property__row').contains(document.elementFromPoint(r.x + 4, r.y + r.height / 2)) })
|
||||
check('long content: the body scrolls inside the frame and the last row is reachable', scroll.sh > scroll.ch && scroll.overflow === 'auto' && lastVisible && (await rect(popup('c'))).h === 360, { scroll, lastVisible })
|
||||
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-stack.png') })
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Visual preservation for the SW integrated improvement (2026-10-19).
|
||||
// capture: screenshots every existing SW Story (1920) and the Header Stories at 10 widths from storybook-static.
|
||||
// compare: pixel-diffs two capture directories and writes compare-result.json.
|
||||
// Usage: node packages/sw/tests/integrated-visual.browser.mjs capture <dir>
|
||||
// node packages/sw/tests/integrated-visual.browser.mjs compare <before-dir> <after-dir> <out-dir>
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
|
||||
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||||
const require = createRequire(path.join(runtime, 'index.js'))
|
||||
const [mode, a, b, c] = process.argv.slice(2)
|
||||
|
||||
// Stories that existed before the improvement; IDs are fixed so new Stories never enter the comparison.
|
||||
export const baselineStories = ['overview', 'detail-buttons', 'interactive-states', 'window-move-motion', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls']
|
||||
const widthStories = ['overview', 'responsive-more']
|
||||
const widths = [1920, 1800, 1440, 1180, 1080, 900, 768, 640, 520, 390]
|
||||
|
||||
if (mode === 'capture') {
|
||||
const out = path.resolve(a)
|
||||
await mkdir(out, { recursive: true })
|
||||
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||||
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const { chromium } = require('playwright')
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, reducedMotion: 'reduce' })
|
||||
const errors = []
|
||||
page.on('pageerror', e => errors.push(e.message))
|
||||
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
|
||||
const shots = []
|
||||
const shoot = async (id, width) => {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
|
||||
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.mouse.move(1, 1)
|
||||
await page.addStyleTag({ content: '*,*::before,*::after{animation-play-state:paused!important;animation-delay:0s!important;transition:none!important;caret-color:transparent!important}' })
|
||||
await page.waitForTimeout(150)
|
||||
const file = `${id}-${width}.png`
|
||||
await page.screenshot({ path: path.join(out, file), fullPage: true })
|
||||
shots.push(file)
|
||||
}
|
||||
for (const id of baselineStories) await shoot(id, 1920)
|
||||
for (const id of widthStories) for (const width of widths.slice(1)) await shoot(id, width)
|
||||
await writeFile(path.join(out, 'capture.json'), JSON.stringify({ browser: browser.version(), shots, errors }, null, 2))
|
||||
await browser.close(); server.close()
|
||||
console.log(`captured ${shots.length}, errors ${errors.length}`)
|
||||
} else if (mode === 'compare') {
|
||||
const { PNG } = require('pngjs')
|
||||
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
|
||||
const before = path.resolve(a), after = path.resolve(b), out = path.resolve(c)
|
||||
await mkdir(out, { recursive: true })
|
||||
const results = []
|
||||
for (const file of (await readdir(before)).filter(name => name.endsWith('.png'))) {
|
||||
let next
|
||||
try { next = PNG.sync.read(await readFile(path.join(after, file))) } catch { results.push({ file, status: 'MISSING' }); continue }
|
||||
const prev = PNG.sync.read(await readFile(path.join(before, file)))
|
||||
if (prev.width !== next.width || prev.height !== next.height) { results.push({ file, status: 'SIZE', before: [prev.width, prev.height], after: [next.width, next.height] }); continue }
|
||||
const diff = new PNG({ width: prev.width, height: prev.height })
|
||||
const changed = pixelmatch(prev.data, next.data, diff.data, prev.width, prev.height, { threshold: 0 })
|
||||
if (changed) await writeFile(path.join(out, `diff-${file}`), PNG.sync.write(diff))
|
||||
results.push({ file, status: changed ? 'DIFF' : 'SAME', changed })
|
||||
}
|
||||
const summary = { total: results.length, same: results.filter(r => r.status === 'SAME').length, results }
|
||||
await writeFile(path.join(out, 'compare-result.json'), JSON.stringify(summary, null, 2))
|
||||
console.log(`${summary.same}/${summary.total} identical`)
|
||||
for (const r of results.filter(r => r.status !== 'SAME')) console.log(r.status, r.file, r.changed ?? '')
|
||||
} else throw new Error('mode: capture | compare')
|
||||
@@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import { parse } from '@babel/parser'
|
||||
import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds } from './storybook-inventory.mjs'
|
||||
import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds, integratedChangedFiles } from './storybook-inventory.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-overview-qa')
|
||||
await mkdir(out, { recursive: true })
|
||||
@@ -24,7 +24,9 @@ const oldStories = stories(baseline), newStories = stories(source)
|
||||
const retiredExports = ['LayerDropdown','ColorDropdown','LineStyleDropdown','TextDimensionStyleDropdown','ComposedControls']
|
||||
check('retained non-Overview declarations byte-identical; relocated composition separately verified', Object.keys(oldStories).filter(k => k !== 'Overview' && !retiredExports.includes(k)).every(k => oldStories[k] === newStories[k]))
|
||||
check('retired Dropdown IDs absent', retiredDropdownIds.every(id => !after.some(e => e.id === id)))
|
||||
check('package production source untouched', !execFileSync('git', ['diff', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }))
|
||||
// Since 2026-10-19 the production source changes only in the integrated-improvement files (their own suites verify them).
|
||||
const sourceDiff = execFileSync('git', ['diff', '--name-only', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }).trim().split(/\r?\n/).filter(Boolean)
|
||||
check('package production source untouched except the integrated-improvement files', sourceDiff.every(file => integratedChangedFiles.includes(file)), sourceDiff.filter(file => !integratedChangedFiles.includes(file)))
|
||||
|
||||
const root = path.resolve('storybook-static'), mime = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.ttf':'font/ttf','.png':'image/png' }
|
||||
const server = http.createServer(async(req,res) => {
|
||||
|
||||
@@ -10,3 +10,13 @@ export const swStoryEntries = index => Object.values(index.entries).filter(entry
|
||||
export const retainedInventory = entries => entries.filter(entry => ![...retiredStage1Ids, ...retiredDropdownIds].includes(entry.id))
|
||||
.map(entry => entry.id === 'sw-header--composed-controls' ? { ...entry, name: 'Dropdown', title: 'SW/Header' } : entry)
|
||||
export const inventorySignature = entries => entries.map(entry => [entry.id, entry.name, entry.tags]).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
// 2026-10-19 SW integrated improvement (0.2.0): the only Stories added after the unused-demo removal, under their
|
||||
// own titles (SW/Header keeps its 10 Stories), and the only files of the package it changed.
|
||||
export const integratedStoryIds = ['sw-header-options--open-placement', 'sw-header-options--hidden-sub-controls', 'sw-header-options--composed-open', 'sw-header-options--composed-responsive', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
|
||||
export const integratedChangedFiles = [
|
||||
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
|
||||
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
|
||||
'packages/sw/src/index.ts', 'packages/sw/src/theme/sw.css',
|
||||
'packages/sw/src/components/sw-header/SWHeader.tsx', 'packages/sw/src/components/sw-header/index.ts', 'packages/sw/src/components/sw-header/types.ts',
|
||||
...['SWHeaderDocumentTitle', 'SWHeaderDropdown', 'SWHeaderFields', 'SWHeaderLayerMenu', 'SWHeaderLineStyleMenu', 'SWHeaderMore', 'SWHeaderPropertyFields', 'SWHeaderPropertyPanel'].map(name => `packages/sw/src/components/sw-header/components/${name}.tsx`),
|
||||
]
|
||||
@@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import { parse } from '@babel/parser'
|
||||
import { swStoryEntries, retiredUnusedDemoIds } from './storybook-inventory.mjs'
|
||||
import { swStoryEntries, retiredUnusedDemoIds, integratedStoryIds, integratedChangedFiles } from './storybook-inventory.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-unused-story-removal-qa/verify')
|
||||
const qa = 'docs/sw/storybook-unused-story-removal-qa/before'
|
||||
@@ -26,10 +26,12 @@ const beforeSW = swStoryEntries(beforeIndex), afterSW = swStoryEntries(afterInde
|
||||
const expected = beforeSW.filter(e => !retiredUnusedDemoIds.includes(e.id))
|
||||
report.data.inventory = { before: [stories(beforeIndex).length, beforeSW.length], after: [stories(afterIndex).length, afterSW.length], sw: afterSW.map(e => [e.id, e.name]) }
|
||||
await writeFile(path.join(out, 'story-inventory-after.json'), JSON.stringify({ total: stories(afterIndex).length, sw: afterSW.map(e => ({ id: e.id, name: e.name, title: e.title, tags: e.tags })) }, null, 2) + '\n')
|
||||
check('inventory: 19 -> 10 SW Stories, 338 -> 329 total', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329, report.data.inventory)
|
||||
// 2026-10-19 integrated improvement: 329 + its 7 Stories under SW/Header/Options, SW/Tool and SW/Popup (SW/Header stays 10).
|
||||
const added = stories(afterIndex).filter(e => !beforeIndex.entries[e.id])
|
||||
check('inventory: 19 -> 10 SW/Header Stories, 338 -> 329 total plus only the 7 integrated-improvement Stories', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329 + integratedStoryIds.length && JSON.stringify(added.map(e => e.id).sort()) === JSON.stringify([...integratedStoryIds].sort()), { ...report.data.inventory, added: added.map(e => e.id) })
|
||||
check('inventory: the nine requested IDs existed before and are absent now', retiredUnusedDemoIds.length === 9 && retiredUnusedDemoIds.every(id => beforeIndex.entries[id] && !afterIndex.entries[id]))
|
||||
check('inventory: retained SW IDs, names, titles, tags and order unchanged', sig(expected) === sig(afterSW), afterSW.map(e => e.id))
|
||||
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id))
|
||||
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id) && !integratedStoryIds.includes(e.id) && !/^sw-(header-options|tool|popup)--docs$/.test(e.id))
|
||||
check('inventory: every non-removed index entry identical (docs and other packages included)', JSON.stringify(others(beforeIndex)) === JSON.stringify(others(afterIndex)))
|
||||
check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header')
|
||||
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel · 4개 비교')
|
||||
@@ -44,9 +46,12 @@ const removedExports = ['PropertyFields', 'PropertyShapeSlot', 'Composition', 'C
|
||||
check('source: the nine Story exports are removed', removedExports.every(name => oldExports[name] && !newExports[name]))
|
||||
const changed = Object.keys(oldExports).filter(name => !removedExports.includes(name) && oldExports[name] !== newExports[name])
|
||||
check('source: retained exports (Stories and ComposedDemo) byte-identical', changed.length === 0 && Object.keys(newExports).every(name => oldExports[name]), changed)
|
||||
// The SW package may change only in the files of the 2026-10-19 integrated improvement (verified by its own suites);
|
||||
// other packages, Overview, Dropdown CSF, assets and Storybook config stay untouched.
|
||||
const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'packages/sw/README.md', 'packages/ui', 'packages/org-chart', 'packages/org-chart-react', 'packages/center-graphic', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-header-dropdown.stories.tsx', 'src/sw/sw-composed-controls.css', 'src/sw/assets', '.storybook']
|
||||
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim()
|
||||
check('source: runtime, public API, AI template, other packages, Overview, Dropdown CSF, assets and Storybook config untouched', diff === '', diff)
|
||||
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean)
|
||||
const unexpected = diff.filter(file => !integratedChangedFiles.includes(file))
|
||||
check('source: other packages, Overview, Dropdown CSF, assets and Storybook config untouched; SW package only within the integrated-improvement files', unexpected.length === 0, { unexpected, integrated: diff.length })
|
||||
|
||||
// ---- Browser
|
||||
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.gif': 'image/gif', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||||
|
||||
Reference in new issue
Block a user