feat(sw): viewer header logo menu and file list; simplify SW Storybook
SW Viewer Header (approved optional props, public export names unchanged at 170): - SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel; measured responsive toggles (full -> icons -> More) with the SWHeader More / file-list styling. - SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the logo-document-menu tests with their QA record. SW Storybook cleanup (Story IDs and test fixtures kept): - Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer", Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants -> "Settings Popup" (merges the settings popup page). - Hidden by the verification tag (direct URL kept): Header Dropdown, Document Open, Interactive States, Document Dropdown, Hidden Sub Controls, Home Menu standalone; Footer tool groups / custom content / parts; SW/Theme; Viewer menu groups / item states / variable size / settings panel / Header + Dropdown. - Removed demo UI: Overview size and callback output (demo data and selection only with &verification), Home Menu Figma node table and width demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen button, property-panel comparison captions, Viewer context-menu demo (&verification only). - Tests: storybook-inventory records the hidden and renamed Stories; overview / range-cross-browser / viewer open the demo controls with &verification; integrated-popup reloads the Story instead of the reopen button; dropdown-consolidation asserts Dropdown is hidden from the sidebar and still opens by its direct URL.
No files matched your search
@@ -21,6 +21,8 @@ const preview: Preview = {
|
||||
},
|
||||
options: {
|
||||
storySort: {
|
||||
// Story names sort only where the order names them (SW Header first); other Stories keep file order.
|
||||
includeNames: true,
|
||||
order: [
|
||||
"Foundation",
|
||||
["Color", "Typography", "Layout"],
|
||||
@@ -47,8 +49,11 @@ const preview: Preview = {
|
||||
["Overview", "Department Colors", "Full Composition", "Expanded OrgChart"],
|
||||
"Center Graphic",
|
||||
"SW",
|
||||
["Header", "Footer", "Popup", "Viewer", "Theme"],
|
||||
["Header", ["SW Header", "*"], "Footer", ["SW Footer", "*"], "Popup", "Viewer", "Theme"],
|
||||
"*",
|
||||
// With includeNames, keeps the Loader group after the Runtime Contract Stories (its place without names).
|
||||
"Theme",
|
||||
["*", "Runtime Contract", ["*", "Loader"]],
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# SW Viewer Header — 로고 파일 열기 메뉴 · 파일명 샘플 목록 · 반응형 (2026-10-21)
|
||||
|
||||
기준: `main` 46119ea (미커밋 작업). 승인 범위: `SWViewerHeader` optional Props `logoMenu`, `documents`, `activeDocumentId`, `onDocumentSelect` 추가.
|
||||
|
||||
## 1. 동작
|
||||
|
||||
| 클릭 대상 | 표시 | 재사용 |
|
||||
|---|---|---|
|
||||
| Viewer 로고 | 로고 아래 파일 열기 메뉴(Open) | `SWHeaderMore`(SWHeader 반응형 `...` 메뉴) — 같은 면·행·Hover·Escape·바깥 클릭·ArrowUp/Down |
|
||||
| 파일명 | 파일명 아래 샘플 문서 목록 | `SWHeaderDocumentTitle`(SWHeader 파일명 목록) `showNew={false}` `showOpen={false}` |
|
||||
| 메뉴의 Open | 기존 파일 열기 명령(`onSelect` → `onCommand({ id: "open", itemId: "open" })`) | — |
|
||||
| 샘플 항목 | `onDocumentSelect` → 프로그램이 불러오고 `documentTitle`·`activeDocumentId` 갱신 | — |
|
||||
|
||||
- 로고 메뉴와 파일명 목록은 하나의 상태(`openMenu`)로 제어해 동시에 열리지 않습니다(포인터·키보드 모두).
|
||||
- 로고 메뉴: `title`과 보이는 항목이 모두 있을 때만 타이틀 + 구분선, `title`이 없으면 항목만, 보이는 항목이 없으면 메뉴·버튼을 만들지 않고 기존 로고(`onHomeClick`)로 둡니다. `logoMenu`는 `onHomeClick`보다 우선합니다.
|
||||
- 로고 버튼: `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`, ArrowDown = 첫 항목 Focus, 선택·Escape 후 로고로 Focus 복귀.
|
||||
- `SWHeaderMore`(내부, 미공개)에 `renderTrigger`·`title`·`label`·`open`/`onOpenChange`·`align`·`className`을 추가했습니다. 기본값은 기존 `...` 버튼 동작 그대로이며, ArrowDown 열기·선택 후 Focus 복귀는 `renderTrigger`를 쓸 때만 적용합니다.
|
||||
|
||||
## 2. 반응형 (최종: 실제 가용 폭 기준 3단계, 2026-10-21)
|
||||
|
||||
- 고정 Breakpoint 대신 `SWViewerHeader`가 Header 행의 남는 폭을 측정합니다(SWHeader의 measured More와 같은 방식: `data-measuring` 상태에서 각 부분을 자연 폭으로 두고 한 번 측정, 그리지 않음). 파일명은 글자 폭까지만 계산해 짧은 파일명이면 메뉴가 더 오래 유지됩니다.
|
||||
- ① 공간 충분: 아이콘+텍스트(1440·1100에서 전체 보기·레이어·설정·속성 모두 표시) → ② 부족: `icon`+`iconOnlyBelow` 항목(전체 보기·레이어·설정·속성)을 한 번에 아이콘만(라벨은 Tooltip·접근성 이름) → ③ 더 부족: `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저: 어두운 배경 → 2.5D → 전체 보기 → 도면 공간). 넓어지면 역순 복귀. 각 단계가 확보한 폭을 기록해 그 폭이 다시 생길 때만 되돌려 경계에서 깜빡이지 않습니다.
|
||||
- Storybook 기본 구성 실측 경계(파일명 `C0060202-001-평면및종단면도.dwg`): 1080px 이상 기본, 1070px 아이콘만, 810px 이하 More. 380px까지 가로 넘침 없음. 줄일 때와 늘릴 때 같은 폭의 상태가 같습니다. 짧은 파일명(`simple.dxf`)에서는 경계가 더 좁은 폭으로 이동합니다.
|
||||
- `collapseBelow`·`iconOnlyBelow` Props 형식은 그대로이고 숫자는 순서만 정합니다. `types.ts`의 두 Props 설명("Header width (px) below which…")은 공개 선언 파일 변경을 피하려고 이번에 고치지 않았습니다(`SWViewerHeader`·VIEWER.md에 실제 동작 기재). API 기준 갱신 작업 때 함께 고치는 것을 권합니다.
|
||||
- 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김은 메뉴 이동이 아닌 CSS container 규칙으로 그대로 둡니다.
|
||||
|
||||
## 2a. 레이어 · 위치 · 스타일 (2026-10-21)
|
||||
|
||||
- 파일명 목록이 로고를 가리던 원인: 패널 위 표시를 위해 파일명 요소를 z-index 60으로 올려 로고 면(z-index 2) 위로 파일명 칸이 그려졌습니다. SWHeader는 로고(2) > 파일명(1) 순서를 유지합니다. 수정: 내부 순서를 SWHeader와 같게 되돌리고, 메뉴가 열린 동안만 Header 전체를 기존 뷰어 Dropdown 층(SWViewerMenu, 60)으로 올립니다(닫히면 층 없음).
|
||||
- 로고 메뉴: Header 컨테이너 기준(`top: calc(100% + 1px)`, `left: 0`)으로 Header 하단 경계에 바로 붙고 로고 왼쪽에 맞춤. 크기·간격·글자·구분선은 `SWViewerMenu`(상단 메뉴 + Dropdown)와 같게, 색(면 #0e3d2f·Border·글자·Hover)은 유지.
|
||||
- 파일명 목록: SWHeader 파일명 목록과 같은 모양 그대로(스타일 변경 없음).
|
||||
|
||||
## 3. 공개 API
|
||||
|
||||
- Export 170개 유지, 기존 Props 변경·삭제 없음.
|
||||
- `public-api.check.mjs`: 6/7 PASS. FAIL 1건은 `components/sw-viewer/SWViewerHeader.d.ts` 해시 변경으로, 승인된 optional Props 추가에 따른 예상 차이입니다. 새로 노출된 내부 선언 파일은 없습니다. 승인 JSON·기준 Commit은 수정하지 않았습니다(별도 API 기준 갱신 작업).
|
||||
|
||||
## 4. 검증
|
||||
|
||||
- `packages/sw/tests/viewer-logo-document-menu.browser.mjs` 42/42 PASS (증거 `docs/sw/viewer-logo-document-menu-qa/`).
|
||||
- `packages/sw/tests/viewer.browser.mjs` 70/70 PASS (참고 뷰어 비교 단계는 참고 URL 없이 생략). 제거된 샘플·파일 열기 버튼 대상 검사를 새 구조에 맞게 수정했습니다.
|
||||
- 이 checkout의 `node_modules`에 workspace 링크(`@hm-design/sw` 등)와 `orgchart` 의존성이 없어 루트 `tsc -b`, `build-storybook`은 기존 환경 문제로 실패합니다. Storybook 실행을 위해 `node_modules/@hm-design/sw` → `packages/sw` Junction만 추가했습니다(git 무시 대상).
|
||||
|
||||
## 5. 환경 복구 후 재검증 (2026-10-21)
|
||||
|
||||
- 원인: `node_modules`가 2026-10-12에 설치된 뒤 Pull로 `package-lock.json`에 workspace 링크 3개(`@hm-design/sw`·`org-chart`·`org-chart-react`)와 `orgchart` 6.0.0 등 13개 패키지가 추가되었으나 `npm install`이 다시 실행되지 않았습니다(설치본에만 있는 패키지·버전 차이 없음).
|
||||
- 복구: `npm install`(제자리, `npm ci` 아님). npm이 lockfile의 `org-chart-react` `peerDependencies` 키 순서만 바꿔 설치 직전 백업(=HEAD와 동일)으로 되돌렸습니다. `package.json`·lockfile 변경 없음, Git 상태 설치 전과 동일.
|
||||
- `npm run typecheck`(루트 `tsc -b` + 전체 workspace) PASS, `build:packages` PASS, `build-storybook` PASS.
|
||||
- Public API: 6/7, FAIL은 `SWViewerHeader.d.ts`뿐. HEAD(46119ea)로 별도 build한 선언 해시가 승인 해시와 같고, 현재와의 차이는 승인된 optional Props 4개, 이미 공개된 `SWHeaderDocument` type import, `document` Prop 설명 한 문장뿐입니다.
|
||||
|
||||
| 검사 (storybook-static) | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| viewer-logo-document-menu | 42/42 | |
|
||||
| viewer-logo-document-menu-firefox (Firefox 158, BiDi) | 30/30 | 증거 `viewer-logo-document-menu-qa/firefox/` |
|
||||
| viewer | 70/70 | |
|
||||
| keyboard-dismiss / header-composition / home-menu-integration | 22/22 / 161/161 / 23/23 | |
|
||||
| integrated-improvement / hover-stability(chromium) / storybook-simplification / footer-popup-viewer-refinement | 90/90 / 201/201 / 9/9 / 47/47 | |
|
||||
| overview / dropdown-consolidation / storybook-unused-removal | 53/54 / 227/228 / 92/93 | FAIL은 모두 source guard: 승인 목록 밖 변경 파일로 이번 작업 파일만 표시 |
|
||||
| text-weight | 실패(중단) → 수정 후 아래 §6 | 제거된 `파일 열기`·`샘플` 버튼과 `.hm-sw-viewer-header__document-name`을 찾는 기존 단언 |
|
||||
| theme-inheritance | 실패(중단) → 수정 후 아래 §6 | Viewer Header 첫 `aria-haspopup="menu"`를 SWViewerMenu(샘플)로 가정, 지금은 로고 메뉴 |
|
||||
| followup-four | 실패(중단) | 기존 문제: 5faa1f1에서 삭제된 `sw-header--property-bar` Story를 엶 |
|
||||
|
||||
## 6. text-weight · theme-inheritance 갱신 (2026-10-21, 사용자 승인)
|
||||
|
||||
- **text-weight:** Header의 Bold 대상(파일명·레이어·설정·속성·Model/Layout = 14px 700 + 1.3px 외곽선)은 그대로 단언하고, 제거된 `파일 열기`·`샘플`·`전체 보기`가 Header 라벨에 없음을 확인합니다. 로고 메뉴 Open 행과 샘플 목록 행은 같은 실행에서 SWHeader `...` 메뉴(400, 외곽선 없음)와 SWHeader 파일명 목록(14px 700 + 1.3px 외곽선)을 기준으로 측정해 굵기·크기·글꼴·외곽선이 같아야 PASS입니다. 로고 메뉴 타이틀은 700(외곽선 없음). Hover 시 글자 위치·크기·굵기 유지 검사를 Open 행·파일명·샘플 행·레이어·Layout에 적용합니다.
|
||||
- **theme-inheritance:** 기존 Dropdown 글꼴 상속 검사를 로고 메뉴와 파일명 버튼·샘플 목록으로 옮겼습니다(기본 Noto Sans KR / 뷰어 테마 Dotum). 추가로 두 메뉴의 배경·글자·Border·Hover 색이 기본에서는 SWHeader 토큰 값과 같고, 가장 바깥 SW 범위에 설정한 색 토큰이 두 메뉴까지 상속되는지 확인합니다(검사 중에만 설정 후 제거). 로고 메뉴 면 `#0e3d2f`는 SWHeader `...` 메뉴의 고정값이라 그 값과 비교합니다. 전체 Story 회귀(새 상속 구조 = 기존 구조) 검사는 그대로입니다.
|
||||
- **발견·수정한 결함:** theme-inheritance의 뷰어 테마 화면(레이어 패널이 로고 아래에 열린 상태)에서 로고 메뉴가 SWViewerPanel 제목 줄 아래에 깔려 Open을 누를 수 없었습니다. 두 메뉴의 바깥 요소가 z-index 2·1의 쌓임 맥락 안에 있었기 때문입니다. `sw-viewer.css`에 메뉴가 열린 동안만 그 바깥 요소를 기존 뷰어 Dropdown 층(SWViewerMenu, 60)으로 올리는 규칙 한 개를 추가했습니다(닫히면 원래 값). Chromium·Firefox 테스트에 패널 위 표시 검사를 추가했습니다.
|
||||
|
||||
| 재실행 (수정 후 storybook-static) | 결과 |
|
||||
|---|---|
|
||||
| text-weight / theme-inheritance(회귀 67/67 포함) | 51/51 / 28/28 |
|
||||
| viewer-logo-document-menu / Firefox 158 | 46/46 / 31/31 |
|
||||
| viewer / keyboard-dismiss / header-composition / home-menu-integration | 70/70 / 22/22 / 161/161 / 23/23 |
|
||||
| integrated-improvement / hover-stability / storybook-simplification / footer-popup-viewer-refinement | 90/90 / 201/201 / 9/9 / 47/47 |
|
||||
| overview / dropdown-consolidation / storybook-unused-removal | 53/54 / 227/228 / 92/93 — source guard만 FAIL(이번 작업 파일 4개) |
|
||||
| `npm run typecheck`, `build-storybook`, Public API | PASS, PASS, 6/7(`SWViewerHeader.d.ts` 승인 Props 차이만) |
|
||||
|
||||
## 7. 반응형 · 레이어 · 로고 메뉴 스타일 재검증 (2026-10-21, 최종 소스)
|
||||
|
||||
이번 실행(영향 범위만): `npm run typecheck` PASS, `build-storybook` PASS, `check:ai-docs` PASS, Public API 6/7(`SWViewerHeader.d.ts`만: 승인 Props + 설명 주석).
|
||||
|
||||
| 검사 | 결과 |
|
||||
|---|---|
|
||||
| viewer-logo-document-menu (Chromium, 폭 Sweep·경계·파일명 길이·열린 상태 Resize·로고 하단 정렬·로고 가림 픽셀·SWViewerMenu 비교 포함) | 58/58 |
|
||||
| viewer-logo-document-menu-firefox (Firefox 158) | 34/34 |
|
||||
| viewer / text-weight / theme-inheritance(회귀 67/67 포함) | 70/70 / 52/52 / 28/28 |
|
||||
|
||||
이번에 재실행하지 않음(이번 라운드에서 `SWHeaderMore`·`sw.css` 등 SW Header 공통 소스 변경 없음, 직전 결과 유지): keyboard-dismiss 22/22, header-composition 161/161, home-menu-integration 23/23, integrated-improvement 90/90, hover-stability 201/201, storybook-simplification 9/9, footer-popup-viewer-refinement 47/47, overview 53/54·dropdown-consolidation 227/228·storybook-unused-removal 92/93(source guard만 FAIL).
|
||||
|
||||
## 8. 로고 메뉴 구성 · 파일명 Open · 최소 너비 · Dropdown 면 통일 (2026-10-21)
|
||||
|
||||
- 로고 메뉴: 타이틀 `파일` + 아이콘 `파일 열기`(Storybook fixture). 타이틀과 목록은 한 그룹이라 구분선 없음(`SWHeaderMore`가 타이틀 아래 구분선을 그리지 않음). `logoMenu`는 한 그룹 구조라 그룹 사이 구분선이 생길 경우가 없습니다(여러 그룹은 `SWViewerMenu`의 label·separator 데이터로 표현).
|
||||
- 파일명 목록: `SWHeaderDocumentTitle`의 `showOpen` 재사용, 목록 아래 SWHeader와 같은 Open(+ New 없음). 목록 외곽 너비 ≥ max(파일명 버튼 외곽 너비, SWHeader 312px), 긴 항목이면 넓어짐(CSS `min-width`·`width: max-content`만). 실측 버튼→목록: 1440 359.5→361.5, 1100 274.5→312, 860 214.5→312, 520/400 140→312, 열린 채 Resize·짧은 파일명도 조건 유지.
|
||||
- 두 파일 열기 진입점은 같은 `openFile` 핸들러(로고 항목 `onSelect`, 목록 `onDocumentOpen`). 목록 Open은 `onCommand({ id: "open", itemId: "document" })`도 보냄.
|
||||
- Dropdown 면 통일: 기준 520px More 실측값(면 rgb(245,240,229), Border 1px rgb(137,116,69), 반경 4px, 그림자 0 0 12px rgba(0,0,0,.2), 글자 rgb(10,34,29) Noto Sans KR 14/20 400, 행 36px·좌우 12px·간격 8px, Hover rgba(189,223,212,.45)/rgb(18,51,40), 타이틀 rgb(110,103,89) 12/18 500, 구분선 1px rgb(148,136,113))을 기존 `--hm-sw-viewer-more-*`·`--hm-sw-viewer-menu-*` 토큰으로 로고 메뉴에 적용, Header 메뉴 버튼 Dropdown은 `surface="beige"`. 파일명 목록과 단독 `SWViewerMenu`(우클릭)는 제외.
|
||||
- 공개 API 추가(보고): `logoMenu.items[].icon?`, `onDocumentOpen?`, `documentOpenLabel?`(모두 Optional, `SWViewerHeader.d.ts` 안, Export 170 유지). 아이콘·Open 핸들러를 넘길 기존 Props가 없어 추가했습니다.
|
||||
- 검증(최종 소스): typecheck·build-storybook·check:ai-docs PASS, Public API 6/7(`SWViewerHeader.d.ts`만), viewer-logo-document-menu 63/63, Firefox 36/36, viewer 70/70, theme-inheritance 27/27(고정 면 검사 → 토큰 검사로 대체), 이전 실행(같은 라운드 SWHeaderMore 변경 후): text-weight 52/52, keyboard-dismiss 22/22, header-composition 161/161, integrated-improvement 90/90.
|
||||
|
||||
## 9. Dropdown 색 최종 (2026-10-21)
|
||||
|
||||
- 기준: 일반 SWHeader의 `...` 메뉴(`.hm-sw-header__more-menu`). 로고 메뉴(같은 컴포넌트)는 Viewer 색 재정의를 제거해 sw.css 색을 그대로 사용하고, Header 메뉴 버튼 Dropdown·More(SWViewerMenu, Header 안)는 Viewer 범위 CSS로 같은 값을 칠합니다: 면 #0e3d2f·그림자 0 4px 12px rgba(0,0,0,.5)(sw.css 고정값), Border `--hm-sw-header-object-border-color`, 글자 `--hm-sw-header-text-color`(#f6f4f2), 보조 글자 같은 색 80%, Hover `--hm-sw-header-hover`(rgba(255,255,255,.08), 글자 유지), 구분선 `--hm-sw-header-document-menu-divider-color`. 크기·간격·글자 크기는 SWViewerMenu 그대로. 파일명 목록·단독 SWViewerMenu는 제외.
|
||||
- 검증(이번 실행): build-storybook, viewer-logo-document-menu 63/63(More·Dropdown·로고 메뉴 색 = 같은 실행에서 측정한 SWHeader `...` 메뉴), Firefox 36/36, viewer 70/70, theme-inheritance 28/28. 재사용: typecheck(직전 CSS 외 소스 변경 없음), text-weight 52/52, keyboard-dismiss 22/22, header-composition 161/161, integrated-improvement 90/90, Public API 6/7(`SWViewerHeader.d.ts`만).
|
||||
|
||||
## 10. 파일 열기 버튼으로 변경 (2026-10-21)
|
||||
|
||||
- 로고 클릭 메뉴 대신 로고 오른쪽 파일 열기 버튼: SWHeader `documentOpenPlacement="header"`와 같은 `SWToolButton appearance="document"` 면 + 폴더 아이콘(테두리·배경 없음, 사용자 요청) + `documentOpenLabel`. 기존 `onDocumentOpen`·`documentOpenLabel` 재사용(목록 아래 Open → Header 버튼). 공간이 부족하면 측정 2단계에서 라벨만 숨김(Tooltip·접근성 이름 `파일 열기`), More로 이동하지 않음.
|
||||
- 파일명 목록: Open·+ New 없음(`showOpen={false}`), 최소 너비 조건 유지. 파일명 영역 컨테이너(`.hm-sw-viewer-header__document`)는 Open 버튼 뒤에서 왼쪽 padding 0, 파일명 버튼(`.hm-sw-header__document-button`) padding은 변경하지 않음(사용자 결정).
|
||||
- 로고 메뉴: 뷰어 화면·Story·AI 예시에서 사용 중단. `logoMenu` Prop과 `SWHeaderMore` 확장(제목·아이콘·외부 트리거)은 공개·승인된 API라 코드 유지 — API 기준 갱신 작업에서 삭제(또는 유지) 결정 필요.
|
||||
- 검증: viewer-logo-document-menu 47/47, Firefox 23/23, viewer 70/70, text-weight 50/50, theme-inheritance 25/25, check:ai-docs PASS.
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,450 @@
|
||||
{
|
||||
"base": "http://127.0.0.1:6040",
|
||||
"checks": [
|
||||
{
|
||||
"n": "SWHeader ... menu opens (reference colors)",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"colors": {
|
||||
"background": "rgb(14, 61, 47)",
|
||||
"border": "rgb(19, 86, 67)",
|
||||
"shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
|
||||
"item": "rgb(246, 244, 242)"
|
||||
},
|
||||
"metrics": {
|
||||
"border": "1px solid",
|
||||
"radius": "3px",
|
||||
"padding": "4px 4px",
|
||||
"minWidth": "200px",
|
||||
"font": "14px/normal 400",
|
||||
"itemHeight": 32,
|
||||
"itemInset": 5,
|
||||
"itemPadding": "10px 10px",
|
||||
"itemGap": "12px",
|
||||
"itemRadius": "2px",
|
||||
"itemFont": "Noto Sans KR 14px/normal 400"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "SWHeader ... menu Escape closes, focus back on ...",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"n": "SWViewerMenu reference opens (36px rows, 4px padding)",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"border": "1px solid",
|
||||
"radius": "4px",
|
||||
"padding": "4px 4px",
|
||||
"minWidth": "160px",
|
||||
"font": "14px/20px 400",
|
||||
"itemHeight": 36,
|
||||
"itemInset": 5,
|
||||
"itemPadding": "12px 12px",
|
||||
"itemGap": "8px",
|
||||
"itemRadius": "2px",
|
||||
"itemFont": "Noto Sans KR 14px/20px 400"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "logo opens no dropdown",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"menu": false,
|
||||
"list": false,
|
||||
"nameExpanded": "false",
|
||||
"name": "C0060202-001-평면및종단면도.dwg",
|
||||
"focus": "Name\n Description\n Default\n Control \n \n \n \n \n propertyName*\n \n This is a short description\n \n summary\n \n \n \n defaultValue\n \n Set string\n \n \n propertyName*\n \n This is a short description\n \n summary\n \n \n \n defaultValue\n \n Set string\n \n \n propertyName*\n \n This is a short description\n \n summary\n \n \n \n defaultValue\n \n Set string\n \n \n \n\n\n\n \n No Preview\n Sorry, but you either have no stories or none are selected somehow.\n \n Please check the Storybook config.\n Try reloading the page.\n \n \n If the problem persists, check the browser console, or the terminal you've run Storybook from.\n \n \n\n\n\n \n \n \n The component failed to render properly, likely due to a configuration issue in Storybook.\n Here are some common causes and how you can address them:\n \n \n \n Missing Context/Providers: You can use decorators to supply specific\n contexts or providers, which are sometimes necessary for components to render correctly. For\n detailed instructions on using decorators, please visit the\n Decorators documentation.\n \n \n Misconfigured Webpack or Vite: Verify that Storybook picks up all necessary\n settings for loaders, plugins, and other relevant parameters. You can find step-by-step\n guides for configuring\n Webpack or\n Vite\n with Storybook.\n \n \n Missing Environment Variables: Your Storybook may require specific\n environment variables to function as intended. You can set up custom environment variables\n as outlined in the\n Environment Variables documentation.\n \n \n \n \n\n\n HM DESIGN SYSTEM / SWSW Viewer — SWViewerHeader참고 DWG/DXF 뷰어의 상단 메뉴를 패키지 컴포넌트로 옮긴 뷰어 모드 전용 Header입니다. 로고 면 → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 패널·상태·정보 영역이며, 메뉴는 groups 데이터로 구성합니다. 레이어·설정·속성 토글은 팝업을 열고, 정보 버튼은 모달 팝업을 엽니다.기본형 (1440px, 도면 열림)로고 오른쪽 파일 열기 버튼(공간이 부족하면 테두리 아이콘만)으로 파일을 열고, 파일명을 누르면 샘플 도면 목록이 열립니다. Model/Layout 탭, 2.5D·어두운 배경 스위치, 패널 토글을 직접 눌러 보세요. Command 줄에 onCommand 결과가 표시됩니다.Command: 없음파일 열기C0060202-001-평면및종단면도.dwgModel17,377Layout237전체 보기2.5D어두운 배경레이어설정속성도면 열림: C0060202-001-평면및종단면도.dwg 열림\n \n \n function __onViteAppLoadingError(event) {\n const hostname = globalThis.location.hostname;\n if (hostname !== 'localhost' && globalThis.CONFIG_TYPE === 'DEVELOPMENT') {\n const message = `Failed to load the Storybook preview file 'vite-app.js':\n\n It looks like you're visiting the Storybook development server on another hostname than localhost: '${hostname}', but you haven't configured the necessary security features to support this.\n Please re-run your Storybook development server with the '--host ${hostname}' flag, or manually configure your Vite allowedHosts configuration with viteFinal.\n\n See:`;\n const docs = [\n 'https://storybook.js.org/docs/api/cli-options#dev',\n 'https://storybook.js.org/docs/api/main-config/main-config-vite-final',\n 'https://vite.dev/config/server-options.html#server-allowedhosts',\n ];\n console.error(`${message}\\n${docs.map((doc) => `- ${doc}`).join('\\n')}`);\n\n document.getElementById('storybook-root').innerHTML =\n `<p style=\"color: red; max-width: 70ch\">${message.replaceAll(\n '\\n',\n '<br/>'\n )}<ul>${docs.map((doc) => `<li><a href='${doc}' target='_blank'>${doc}</a></li>`).join('')}</ul></p>`;\n return;\n }\n }\n \n \n\n\n #storybook-highlights-menu {\n position: absolute;\n z-index: 2147483647;\n width: 300px;\n padding: 0px;\n margin: 15px 0 0 0;\n transform: translateX(-50%);\n font-family: \"Nunito Sans\", -apple-system, \".SFNSText-Regular\", \"San Francisco\", BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 12px;\n background: white;\n border: none;\n Line truncated
|
||||
"log": "Command: 없음"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "file open button after the logo: SWHeader document surface, icon + 파일 열기",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"surface": true,
|
||||
"afterLogo": true,
|
||||
"label": "파일 열기",
|
||||
"icon": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "file open icon: no border, transparent, no shadow",
|
||||
"ok": true,
|
||||
"d": "0px | none | rgb(240, 247, 245) | 0px | rgba(0, 0, 0, 0) | none"
|
||||
},
|
||||
{
|
||||
"n": "file open button runs the existing file open",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"menu": false,
|
||||
"list": false,
|
||||
"nameExpanded": "false",
|
||||
"name": "C0060202-001-평면및종단면도.dwg",
|
||||
"focus": "파일 열기",
|
||||
"log": "Command: 파일 선택창 (미연결) → document/open"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "open list never covers the logo cap (pixels identical)",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"n": "file name click opens the 3 samples only (no Open / + New), SWHeader face",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"rows": [
|
||||
"BasicSample.dwg",
|
||||
"C0060202-001-평면및종단면도.dwg",
|
||||
"simple.dxf"
|
||||
],
|
||||
"actions": 0,
|
||||
"active": "C0060202-001-평면및종단면도.dwg",
|
||||
"width": 361.5,
|
||||
"button": 359.5,
|
||||
"left": -2,
|
||||
"right": 0,
|
||||
"row": 32,
|
||||
"gap": 0,
|
||||
"background": "rgb(16, 49, 39)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "document list >= file-name button (outer) and >= 312px, covers it",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"rows": [
|
||||
"BasicSample.dwg",
|
||||
"C0060202-001-평면및종단면도.dwg",
|
||||
"simple.dxf"
|
||||
],
|
||||
"actions": 0,
|
||||
"active": "C0060202-001-평면및종단면도.dwg",
|
||||
"width": 361.5,
|
||||
"button": 359.5,
|
||||
"left": -2,
|
||||
"right": 0,
|
||||
"row": 32,
|
||||
"gap": 0,
|
||||
"background": "rgb(16, 49, 39)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "list painted on top (not cut)",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"n": "sample select loads it, renames, closes, focus on the file name",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"menu": false,
|
||||
"list": false,
|
||||
"nameExpanded": "false",
|
||||
"name": "simple.dxf",
|
||||
"focus": "name",
|
||||
"log": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "ArrowDown opens the list on its first row",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"menu": false,
|
||||
"list": true,
|
||||
"nameExpanded": "true",
|
||||
"name": "simple.dxf",
|
||||
"focus": "BasicSample.dwg",
|
||||
"log": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "Escape closes the list and refocuses the file name",
|
||||
"ok": true,
|
||||
"d": {
|
||||
"menu": false,
|
||||
"list": false,
|
||||
"nameExpanded": "false",
|
||||
"name": "simple.dxf",
|
||||
"focus": "name",
|
||||
"log": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": "document list above an open SWViewerPanel",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"n": "no horizontal overflow at 1440 / 1100 / 860 / 520",
|
||||
"ok": true,
|
||||
"d": [
|
||||
[
|
||||
1440,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
1100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
860,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
520,
|
||||
0,
|
||||
0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": "layer / settings / properties always in the header with tooltips",
|
||||
"ok": true,
|
||||
"d": [
|
||||
[
|
||||
1440,
|
||||
[
|
||||
[
|
||||
"레이어",
|
||||
true,
|
||||
true
|
||||
],
|
||||
[
|
||||
"설정",
|
||||
true,
|
||||
true
|
||||
],
|
||||
[
|
||||
"속성",
|
||||
true,
|
||||
true
|
||||
]
|
||||
]
|
||||
],
|
||||
[
|
||||
1100,
|
||||
[
|
||||
[
|
||||
"레이어",
|
||||
false,
|
||||
true
|
||||
],
|
||||
[
|
||||
"설정",
|
||||
false,
|
||||
true
|
||||
],
|
||||
[
|
||||
"속성",
|
||||
false,
|
||||
true
|
||||
]
|
||||
]
|
||||
],
|
||||
[
|
||||
860,
|
||||
[
|
||||
[
|
||||
"레이어",
|
||||
false,
|
||||
true
|
||||
],
|
||||
[
|
||||
"설정",
|
||||
false,
|
||||
true
|
||||
],
|
||||
[
|
||||
"속성",
|
||||
false,
|
||||
true
|
||||
]
|
||||
]
|
||||
],
|
||||
[
|
||||
520,
|
||||
[
|
||||
[
|
||||
"레이어",
|
||||
false,
|
||||
true
|
||||
],
|
||||
[
|
||||
"설정",
|
||||
false,
|
||||
true
|
||||
],
|
||||
[
|
||||
"속성",
|
||||
false,
|
||||
true
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": "1440 full (파일 열기 + 전체 보기 labels, no More); 1100 icons only, no More; 860 / 520 icons + More",
|
||||
"ok": true,
|
||||
"d": [
|
||||
[
|
||||
1440,
|
||||
true,
|
||||
false,
|
||||
[
|
||||
true,
|
||||
"",
|
||||
34
|
||||
],
|
||||
[
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
],
|
||||
[
|
||||
1100,
|
||||
true,
|
||||
false,
|
||||
[
|
||||
false,
|
||||
"파일 열기",
|
||||
34
|
||||
],
|
||||
[
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
],
|
||||
[
|
||||
860,
|
||||
true,
|
||||
true,
|
||||
[
|
||||
false,
|
||||
"파일 열기",
|
||||
34
|
||||
],
|
||||
[
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
],
|
||||
[
|
||||
520,
|
||||
false,
|
||||
true,
|
||||
[
|
||||
false,
|
||||
"파일 열기",
|
||||
34
|
||||
],
|
||||
[
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": "logo, file open, file name and toolbar never overlap",
|
||||
"ok": true,
|
||||
"d": [
|
||||
[
|
||||
1440,
|
||||
[
|
||||
65,
|
||||
65,
|
||||
203.61666870117188,
|
||||
205.61666870117188,
|
||||
565.1166687011719,
|
||||
567.11669921875
|
||||
]
|
||||
],
|
||||
[
|
||||
1100,
|
||||
[
|
||||
65,
|
||||
65,
|
||||
136,
|
||||
138,
|
||||
412.5,
|
||||
414.5
|
||||
]
|
||||
],
|
||||
[
|
||||
860,
|
||||
[
|
||||
65,
|
||||
65,
|
||||
136,
|
||||
138,
|
||||
352.5,
|
||||
354.5
|
||||
]
|
||||
],
|
||||
[
|
||||
520,
|
||||
[
|
||||
65,
|
||||
65,
|
||||
136,
|
||||
138,
|
||||
278,
|
||||
280
|
||||
]
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": "520: document list shown inside the header width",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"n": "520: icon-only file open runs the file open",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"n": "narrow header (771px): icon toggles open / close panels, pressed #FFC600, tooltip",
|
||||
"ok": true,
|
||||
"d": [
|
||||
{
|
||||
"name": "레이어",
|
||||
"pressed": "true",
|
||||
"color": "rgb(255, 198, 0)",
|
||||
"title": "레이어 패널 열기 / 닫기",
|
||||
"open": true,
|
||||
"closed": true
|
||||
},
|
||||
{
|
||||
"name": "설정",
|
||||
"pressed": "true",
|
||||
"color": "rgb(255, 198, 0)",
|
||||
"title": "옵션 패널 열기 / 닫기",
|
||||
"open": true,
|
||||
"closed": true
|
||||
},
|
||||
{
|
||||
"name": "속성",
|
||||
"pressed": "true",
|
||||
"color": "rgb(255, 198, 0)",
|
||||
"title": "속성 패널 열기 / 닫기",
|
||||
"open": true,
|
||||
"closed": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": "no console errors",
|
||||
"ok": true,
|
||||
"d": []
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,691 @@
|
||||
{
|
||||
"storybook": "http://127.0.0.1:6040",
|
||||
"results": [
|
||||
{
|
||||
"name": "reference SWViewerMenu: 36px rows, 4px padding / radius, 14px 400, 4px under its trigger",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"colors": {
|
||||
"background": "rgb(14, 61, 47)",
|
||||
"border": "rgb(19, 86, 67)",
|
||||
"shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
|
||||
"item": "rgb(246, 244, 242)",
|
||||
"label": "rgb(246, 244, 242)"
|
||||
},
|
||||
"metrics": {
|
||||
"border": "1px solid",
|
||||
"radius": "4px",
|
||||
"padding": "4px",
|
||||
"minWidth": "160px",
|
||||
"font": "14px/20px 400",
|
||||
"itemHeight": 36,
|
||||
"itemInset": 5,
|
||||
"itemPadding": "12px 12px",
|
||||
"itemGap": "8px",
|
||||
"itemRadius": "2px",
|
||||
"itemFont": "\"Noto Sans KR\" 14px/20px 400",
|
||||
"itemOutline": "none",
|
||||
"label": "6px 12px 12px/18px 500",
|
||||
"separator": "1px 4px -4px",
|
||||
"icon": "16x16 @12",
|
||||
"textLeft": 36
|
||||
},
|
||||
"place": {
|
||||
"dy": 4,
|
||||
"dx": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "More (520px) colors = SWHeader ... menu (face, border, shadow, text); caption = text color",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"more": {
|
||||
"background": "rgb(14, 61, 47)",
|
||||
"border": "rgb(19, 86, 67)",
|
||||
"shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
|
||||
"item": "rgb(246, 244, 242)",
|
||||
"label": "rgb(246, 244, 242)"
|
||||
},
|
||||
"hover": "rgba(255, 255, 255, 0.08) / rgb(246, 244, 242)",
|
||||
"swHeader": {
|
||||
"background": "rgb(14, 61, 47)",
|
||||
"border": "rgb(19, 86, 67)",
|
||||
"shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
|
||||
"item": "rgb(246, 244, 242)",
|
||||
"label": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "header dropdown (상단 메뉴 + Dropdown) = More face and colors",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"dropdown": {
|
||||
"background": "rgb(14, 61, 47)",
|
||||
"border": "rgb(19, 86, 67)",
|
||||
"shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
|
||||
"item": "rgb(246, 244, 242)",
|
||||
"label": "rgb(246, 244, 242)"
|
||||
},
|
||||
"more": {
|
||||
"background": "rgb(14, 61, 47)",
|
||||
"border": "rgb(19, 86, 67)",
|
||||
"shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
|
||||
"item": "rgb(246, 244, 242)",
|
||||
"label": "rgb(246, 244, 242)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "SWHeader ... menu still right-aligned under its trigger (no title, no divider)",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "SWHeader ... menu Escape closes and refocuses ...",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "logo opens no dropdown (no menu trigger)",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "file open button after the logo: SWHeader document surface, icon + 파일 열기",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"surface": true,
|
||||
"afterLogo": true,
|
||||
"beforeName": true,
|
||||
"label": "파일 열기",
|
||||
"icons": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file open icon: no border, transparent, no shadow; 34px slot",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"style": "0px none rgb(240, 247, 245) | 0px | rgba(0, 0, 0, 0) | none",
|
||||
"size": [
|
||||
34,
|
||||
34
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file name area: no left padding, right after the file open button",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
"0px",
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "no 샘플 header button, no + New",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "file open button runs the existing file open (onDocumentOpen + onCommand open/document)",
|
||||
"result": "PASS",
|
||||
"detail": "Command: 파일 선택창 (미연결) → document/open"
|
||||
},
|
||||
{
|
||||
"name": "file name is the SWHeader document button",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "open list never covers the logo cap (pixels identical)",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "file name click opens the sample list",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
"BasicSample.dwg",
|
||||
"C0060202-001-평면및종단면도.dwg",
|
||||
"simple.dxf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "no Open and no + New below the samples",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "current sample marked active",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "SWHeader list face (32px rows, #103127, at least the SWHeader 312px)",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"background": "rgb(16, 49, 39)",
|
||||
"width": 362,
|
||||
"row": 32
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list opens directly below the file name",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"list": {
|
||||
"x": 202.90625,
|
||||
"y": 368.5,
|
||||
"width": 361.5,
|
||||
"height": 98
|
||||
},
|
||||
"name": {
|
||||
"x": 204.90625,
|
||||
"y": 318.5,
|
||||
"width": 359.5,
|
||||
"height": 50
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sample select loads it and renames the file",
|
||||
"result": "PASS",
|
||||
"detail": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
|
||||
},
|
||||
{
|
||||
"name": "list closes after select, focus on the file name",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "ArrowDown opens the list on its first row",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "Escape closes the list and refocuses the file name",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "outside click closes the list",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"first": {
|
||||
"width": 1440,
|
||||
"label": true,
|
||||
"aria": null,
|
||||
"title": "",
|
||||
"icon": [
|
||||
34,
|
||||
34
|
||||
],
|
||||
"overflow": 0
|
||||
},
|
||||
"firstIcon": {
|
||||
"width": 1080,
|
||||
"label": false,
|
||||
"aria": "파일 열기",
|
||||
"title": "파일 열기",
|
||||
"icon": [
|
||||
34,
|
||||
34
|
||||
],
|
||||
"overflow": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "label comes back at the same widths when the header grows; no overflow",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "file open never moves into More",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
"Model · 17,377개",
|
||||
"Layout · 237개",
|
||||
"전체 보기",
|
||||
"2.5D",
|
||||
"어두운 배경"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "icon-only button runs the same file open",
|
||||
"result": "PASS",
|
||||
"detail": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf → 파일 선택창 (미연결) → document/open"
|
||||
},
|
||||
{
|
||||
"name": "document list above an open SWViewerPanel",
|
||||
"result": "PASS",
|
||||
"detail": "60"
|
||||
},
|
||||
{
|
||||
"name": "closed list drops back (no raised layer at rest), cap over the open button and file name",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "no horizontal overflow at 1440 / 1100 / 860 / 520",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
[
|
||||
1440,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
1100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
860,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
520,
|
||||
0,
|
||||
0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "layer / settings / properties always in the header",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
[
|
||||
"레이어",
|
||||
"설정",
|
||||
"속성"
|
||||
],
|
||||
[
|
||||
"레이어",
|
||||
"설정",
|
||||
"속성"
|
||||
],
|
||||
[
|
||||
"레이어",
|
||||
"설정",
|
||||
"속성"
|
||||
],
|
||||
[
|
||||
"레이어",
|
||||
"설정",
|
||||
"속성"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "1440: full items with labels (전체 보기 shown), no More; 1100: icons only, no More",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
[
|
||||
1440,
|
||||
true,
|
||||
false
|
||||
],
|
||||
[
|
||||
1100,
|
||||
true,
|
||||
false
|
||||
],
|
||||
[
|
||||
860,
|
||||
true,
|
||||
true
|
||||
],
|
||||
[
|
||||
520,
|
||||
false,
|
||||
true
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "860: icons only (tooltips kept) + More",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"width": 860,
|
||||
"overflow": 0,
|
||||
"objectOverflow": 0,
|
||||
"toggles": [
|
||||
{
|
||||
"name": "레이어",
|
||||
"label": false,
|
||||
"title": "레이어 패널 열기 / 닫기"
|
||||
},
|
||||
{
|
||||
"name": "설정",
|
||||
"label": false,
|
||||
"title": "옵션 패널 열기 / 닫기"
|
||||
},
|
||||
{
|
||||
"name": "속성",
|
||||
"label": false,
|
||||
"title": "속성 패널 열기 / 닫기"
|
||||
}
|
||||
],
|
||||
"fitShown": true,
|
||||
"more": true,
|
||||
"logoRight": 65,
|
||||
"docLeft": 138,
|
||||
"docRight": 352.5,
|
||||
"objectLeft": 354.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "520: icons only, then More for what still does not fit",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"width": 520,
|
||||
"overflow": 0,
|
||||
"objectOverflow": 0,
|
||||
"toggles": [
|
||||
{
|
||||
"name": "레이어",
|
||||
"label": false,
|
||||
"title": "레이어 패널 열기 / 닫기"
|
||||
},
|
||||
{
|
||||
"name": "설정",
|
||||
"label": false,
|
||||
"title": "옵션 패널 열기 / 닫기"
|
||||
},
|
||||
{
|
||||
"name": "속성",
|
||||
"label": false,
|
||||
"title": "속성 패널 열기 / 닫기"
|
||||
}
|
||||
],
|
||||
"fitShown": false,
|
||||
"more": true,
|
||||
"logoRight": 65,
|
||||
"docLeft": 138,
|
||||
"docRight": 278,
|
||||
"objectLeft": 280
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "logo, file name and toolbar never overlap",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
[
|
||||
1440,
|
||||
65,
|
||||
204.90625,
|
||||
564.40625,
|
||||
566.40625
|
||||
],
|
||||
[
|
||||
1100,
|
||||
65,
|
||||
138,
|
||||
412.5,
|
||||
414.5
|
||||
],
|
||||
[
|
||||
860,
|
||||
65,
|
||||
138,
|
||||
352.5,
|
||||
354.5
|
||||
],
|
||||
[
|
||||
520,
|
||||
65,
|
||||
138,
|
||||
278,
|
||||
280
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "520: header + More hold every item exactly once (panel toggles never in More)",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"inHeader": [
|
||||
"레이어",
|
||||
"설정",
|
||||
"속성",
|
||||
"정보 및 라이선스"
|
||||
],
|
||||
"moreItems": [
|
||||
"Model",
|
||||
"Layout",
|
||||
"전체 보기",
|
||||
"2.5D",
|
||||
"어두운 배경"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "icon toggle pressed + command at 520",
|
||||
"result": "PASS",
|
||||
"detail": "Command: layers=true"
|
||||
},
|
||||
{
|
||||
"name": "520: document list shown, inside the header width",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"list": {
|
||||
"x": 136,
|
||||
"y": 1171.5,
|
||||
"width": 312,
|
||||
"height": 98
|
||||
},
|
||||
"header": {
|
||||
"x": 40,
|
||||
"y": 1120.5,
|
||||
"width": 520,
|
||||
"height": 52
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "narrow header (771px): icon toggles with tooltip, hover, pressed #FFC600, panel open / close",
|
||||
"result": "PASS",
|
||||
"detail": [
|
||||
{
|
||||
"name": "레이어",
|
||||
"iconOnly": true,
|
||||
"title": "레이어 패널 열기 / 닫기",
|
||||
"hover": "rgb(38, 102, 81)",
|
||||
"open": true,
|
||||
"pressed": "true",
|
||||
"color": "rgb(255, 198, 0)",
|
||||
"closed": true
|
||||
},
|
||||
{
|
||||
"name": "설정",
|
||||
"iconOnly": true,
|
||||
"title": "옵션 패널 열기 / 닫기",
|
||||
"hover": "rgb(38, 102, 81)",
|
||||
"open": true,
|
||||
"pressed": "true",
|
||||
"color": "rgb(255, 198, 0)",
|
||||
"closed": true
|
||||
},
|
||||
{
|
||||
"name": "속성",
|
||||
"iconOnly": true,
|
||||
"title": "속성 패널 열기 / 닫기",
|
||||
"hover": "rgb(38, 102, 81)",
|
||||
"open": true,
|
||||
"pressed": "true",
|
||||
"color": "rgb(255, 198, 0)",
|
||||
"closed": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "document list >= file-name button (outer), covers it, >= 312px, no row cut — long / short name, 1440–400, resize while open",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"bad": [],
|
||||
"cases": [
|
||||
{
|
||||
"label": "long@1440",
|
||||
"button": 359.5,
|
||||
"list": 361.5,
|
||||
"left": -2,
|
||||
"right": 0,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "long@1100",
|
||||
"button": 274.5,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 35.5,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "long@860",
|
||||
"button": 214.5,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 95.5,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "long@520",
|
||||
"button": 140,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 170,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "long@400",
|
||||
"button": 91,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 219,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "short@1440",
|
||||
"button": 359.5,
|
||||
"list": 361.5,
|
||||
"left": -2,
|
||||
"right": 0,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "short@1100",
|
||||
"button": 155.2,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 154.8,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "short@860",
|
||||
"button": 171.8,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 138.3,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "short@520",
|
||||
"button": 140,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 170,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "short@400",
|
||||
"button": 91,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 219,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "open-resize@1200",
|
||||
"button": 207.2,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 102.8,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "open-resize@900",
|
||||
"button": 211.8,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 98.3,
|
||||
"cut": []
|
||||
},
|
||||
{
|
||||
"label": "open-resize@600",
|
||||
"button": 149.5,
|
||||
"list": 312,
|
||||
"left": -2,
|
||||
"right": 160.5,
|
||||
"cut": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sweep: no overflow at any width (400–1440)",
|
||||
"result": "PASS",
|
||||
"detail": []
|
||||
},
|
||||
{
|
||||
"name": "sweep: icons switch together and always before More",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "sweep: narrower never shows more (monotonic: full → icons → More, fewer items)",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "sweep: growing back gives the same state at every width (reverse order, no stuck item)",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "sweep: 1440 full; each stage reached by measured room only",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"icons": 1300,
|
||||
"more": 910
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sweep: shorter file name moves the steps to narrower widths",
|
||||
"result": "PASS",
|
||||
"detail": {
|
||||
"long": {
|
||||
"icons": 1300,
|
||||
"more": 910
|
||||
},
|
||||
"short": {
|
||||
"icons": 1080,
|
||||
"more": 830
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no page errors",
|
||||
"result": "PASS",
|
||||
"detail": ""
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -137,7 +137,7 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
|
||||
|
||||
| 컴포넌트 | 용도 | 주요 Props | 검증 |
|
||||
|---|---|---|---|
|
||||
| `SWViewerHeader` | 52px 뷰어 Header(로고 면 → 파일명 면 → 메뉴 그룹 → 오른쪽 끝), 반응형 More | `groups: SWViewerHeaderGroup[]`(필수), `onCommand(SWViewerCommand)`, `documentTitle`(`string \| null`), `documentPlaceholder`, `documentTooltip`, `document`, `logo`, `logoSrc`, `logoAlt`, `onHomeClick`, `trailing`, `moreLabel`, `toolbarLabel`, `<header>` 속성·`ref` | ◻ |
|
||||
| `SWViewerHeader` | 52px 뷰어 Header(로고 면 → 파일명 면 → 메뉴 그룹 → 오른쪽 끝), 반응형 More. 로고 클릭 = 파일 열기 메뉴(`SWHeader` `...` 메뉴 모양), 파일명 클릭 = 문서 목록(`SWHeaderDocumentTitle`, + New 없음, `onDocumentOpen`이 있으면 아래 Open), 두 메뉴는 동시에 열리지 않음 | `groups: SWViewerHeaderGroup[]`(필수), `onCommand(SWViewerCommand)`, `documentTitle`(`string \| null`), `documentPlaceholder`, `documentTooltip`, `document`, `logoMenu`(`{ title?, items: { id, label, icon?, value?, checked?, disabled?, hidden?, onSelect? }[] }`, 기본 없음), `documents`(`SWHeaderDocument[]`), `activeDocumentId`, `onDocumentSelect`, `onDocumentOpen`(있을 때만 목록 아래 Open), `documentOpenLabel`(기본 `Open`), `logo`, `logoSrc`, `logoAlt`, `onHomeClick`, `trailing`, `moreLabel`, `toolbarLabel`, `<header>` 속성·`ref` | ◻ |
|
||||
| `SWViewerHeaderButton` | `trailing`·`custom` 항목용 Header 버튼(정보 버튼 등) | `label`(필수), `icon`, `iconOnly`(`true` = 32 × 40, `"compact"` = 34 × 34), `pressed`(주면 토글), `button` 속성 | ◻ |
|
||||
| `SWViewerMenu` | 단독 Dropdown·우클릭 메뉴(Header `type: "menu"`와 같은 메뉴) | `entries: SWViewerMenuEntry[]`, `open`, `onOpenChange`(필수), `anchorRef` 또는 `anchorPoint`, `align`(`start`/`end`), `width`, `surface`(`light` 기본 / `beige` = More 면), `label`, `onEntrySelect`, `initialFocus`(`first`/`menu`), `id`, `className` | ◻ |
|
||||
| `SWViewerIcon` | 직접 그린 아이콘에 SW 아이콘 외곽선(SVG stroke) 적용 | `children`(필수), `className` | ◻ |
|
||||
|
||||
@@ -135,23 +135,22 @@ SW **뷰어 모드**(도면 보기) 화면입니다. 모드 선택은 [AI_USAGE.
|
||||
|
||||
```tsx
|
||||
const groups: SWViewerHeaderGroup[] = [
|
||||
{ id: "file", label: "파일", items: [
|
||||
{ type: "button", id: "open", label: "파일 열기", icon: icons.open, iconOnlyBelow: 560 },
|
||||
{ type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180, entries: sampleEntries },
|
||||
] },
|
||||
{ id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [
|
||||
{ type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace, options },
|
||||
] },
|
||||
{ id: "view", label: "화면 보기", surface: "field", items: [
|
||||
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName },
|
||||
// 실제 가용 폭: 공간이 있으면 표시 → 아이콘만 → More (숫자는 순서만 정함).
|
||||
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, collapseBelow: 1180, disabled: !fileName },
|
||||
{ type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth },
|
||||
] },
|
||||
{ id: "panels", label: "패널", placement: "end", items: [
|
||||
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: optionsOpen, collapseBelow: 900, onPressedChange: setOptionsOpen },
|
||||
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: optionsOpen, iconOnlyBelow: 900, onPressedChange: setOptionsOpen },
|
||||
] },
|
||||
]
|
||||
|
||||
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand}
|
||||
onDocumentOpen={openFile} documentOpenLabel="파일 열기"
|
||||
documents={samples} activeDocumentId={sampleId} onDocumentSelect={loadSample}
|
||||
trailing={<>
|
||||
<SWViewerStatus state={fileName ? "ready" : "idle"} />
|
||||
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
|
||||
@@ -171,12 +170,13 @@ const groups: SWViewerHeaderGroup[] = [
|
||||
|
||||
| 구성 | 사용 |
|
||||
|---|---|
|
||||
| Header 메뉴 | `groups` 데이터: 파일(`button`·`menu`), 도면 공간(`tabs`, `surface: "field"`), 화면 보기(`button`·`switch`, `field`), 패널 토글(`toggle`, `placement: "end"`) |
|
||||
| 파일 열기·샘플 | 로고 오른쪽 파일 열기 버튼 `onDocumentOpen`·`documentOpenLabel`(`SWHeader` `documentOpenPlacement="header"` 면 + 테두리 아이콘, 공간이 부족하면 아이콘만, More로 가지 않음), 파일명 클릭 `documents`·`activeDocumentId`·`onDocumentSelect`(`SWHeader` 파일명 목록, Open·+ New 없음) |
|
||||
| Header 메뉴 | `groups` 데이터: 도면 공간(`tabs`, `surface: "field"`), 화면 보기(`button`·`switch`, `field`), 패널 토글(`toggle`, `placement: "end"`) |
|
||||
| 오른쪽 끝 | `trailing`: `SWViewerStatus` + `SWViewerHeaderButton iconOnly` |
|
||||
| 도구 창 | 비모달 `SWViewerPanel`(드래그·크기 조절·접기 기본). 여러 개면 `inset`·`defaultPosition`으로 겹치지 않게 |
|
||||
| 확인·정보 | `SWViewerPanel modal` + `footer`의 `SWViewerButton variant="primary"` |
|
||||
| 패널 내용 | `SWViewerSection` > `SWViewerField` > `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput`, 목록은 `surface="plain"` + 프로그램 목록, 설명 `SWViewerHint` |
|
||||
| 반응형 | 참고값 `collapseBelow` 1180(샘플·도면 공간·2.5D) / 900(패널 토글), `iconOnlyBelow` 560(파일 열기·전체 보기) |
|
||||
| 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만 |
|
||||
| 우클릭 메뉴 | `SWViewerMenu` + `anchorPoint` |
|
||||
|
||||
- 상태(파일명·패널 열림·탭 값)는 프로그램이 가지고, 명령은 각 Callback 또는 `onCommand` 한 곳에서 받는다. 도면 영역 높이·렌더러 크기는 앱 레이아웃 담당(VIEWER.md §7).
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
|
||||
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
|
||||
| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 |
|
||||
| `documents?: SWHeaderDocument[]`, `activeDocumentId?`, `onDocumentSelect?(document)` | 파일명 클릭 시 아래 문서 목록(샘플 도면 등). `SWHeader` 파일명 목록(`SWHeaderDocumentTitle`)과 같은 모양·키보드(ArrowDown 열기, Escape, 바깥 클릭)이며 **Open·+ New는 표시하지 않음**. 목록 외곽 너비는 파일명 버튼 외곽 너비와 `SWHeader` 목록 너비(312px) 이상이며 긴 항목이면 넓어집니다. 열려도 로고가 위에 표시됨. 선택 시 프로그램이 파일을 불러오고 `documentTitle`·`activeDocumentId`를 갱신. `document`가 있으면 `document`가 우선 |
|
||||
| `onDocumentOpen?()`, `documentOpenLabel?` | 로고 오른쪽 파일 열기 버튼(`SWHeader` `documentOpenPlacement="header"`와 같은 파일명 면 `SWToolButton appearance="document"` + `SWHeader` `...` 버튼 테두리의 폴더 아이콘 + `documentOpenLabel`, 기본 `Open`). 공간이 부족하면 아이콘만(라벨은 Tooltip·접근성 이름) 표시하고 More로 옮기지 않습니다. 실행 후 `onCommand({ id: "open", itemId: "document" })` |
|
||||
| `logoMenu?: { title?, items }` | (2026-10-21 이후 뷰어 화면에서 사용하지 않음, 호환용) 로고 클릭 메뉴. 파일 열기는 `onDocumentOpen` 버튼을 씁니다. 항목 `{ id, label, icon?, value?, checked?, disabled?, hidden?, onSelect? }` |
|
||||
| `logo?`, `logoSrc?`, `logoAlt?`, `onHomeClick?` | `SWHeader`와 같은 로고 Props. **주지 않으면 아무것도 그리지 않습니다**(“HM” 문자·기본 아이콘 없음, 로고 면은 사선 끝 24px만). 주면 72px 면(로고가 더 넓으면 그만큼), 높이 최대 24px |
|
||||
| `trailing?: ReactNode` | 오른쪽 끝 자유 영역: `SWViewerStatus`, `SWViewerHeaderButton` |
|
||||
| `moreLabel?`, `toolbarLabel?`, `<header>` 속성, `ref` | |
|
||||
|
||||
- **반응형(참고 뷰어와 동일):** 항목별 `collapseBelow`로 More(⋯, 베이지 260px Dropdown)로 이동합니다. 참고 뷰어 기준값: 샘플·도면 공간·2.5D·어두운 배경 `1180`, 패널 토글 `900`, 파일 열기·전체 보기 `iconOnlyBelow: 560`. 1180px 미만은 간격 2px, 560px 미만은 상태 글자 숨김(CSS container). 가로 스크롤은 만들지 않습니다.
|
||||
- **반응형(2026-10-21, 실제 가용 폭 기준 3단계):** 고정 Breakpoint를 쓰지 않고 Header 행의 실제 남는 폭(로고·파일명·메뉴·Gap·Padding·More 버튼 포함, 파일명은 글자 폭까지만)을 측정합니다. ① 모두 들어가면 기본 형태(아이콘+텍스트), ② 부족하면 `icon`과 `iconOnlyBelow`가 있는 항목을 모두 아이콘만(라벨은 Tooltip·접근성 이름), ③ 그래도 부족하면 `collapseBelow` 항목을 하나씩 More(⋯, 베이지 260px Dropdown)로 옮깁니다(값이 큰 것부터, 같으면 오른쪽 먼저). 넓어지면 More → 아이콘 → 기본 순서로 복귀하고, 상단과 More에 같은 항목이 동시에 나오지 않습니다. `collapseBelow`·`iconOnlyBelow`의 숫자는 순서만 정합니다(Props 형식 동일). 파일 열기는 `logoMenu`, 샘플은 `documents`. 레이어·설정·속성은 `collapseBelow` 없이 아이콘까지만. 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김(CSS container)은 그대로입니다. 가로 스크롤은 만들지 않습니다.
|
||||
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
|
||||
- **Dropdown 색(2026-10-21):** Header 안의 Dropdown — 메뉴 버튼(`type: "menu"`), More(⋯), 로고 메뉴 — 은 크기·간격·글자는 `SWViewerMenu` 그대로, 색은 일반 `SWHeader`의 `...` 메뉴와 같습니다(면 #0e3d2f, Border `--hm-sw-header-object-border-color`, 그림자 0 4px 12px rgba(0,0,0,.5), 글자 `--hm-sw-header-text-color`, 보조 글자는 같은 색 80%, Hover `--hm-sw-header-hover`, 구분선 `--hm-sw-header-document-menu-divider-color`). 파일명 목록(`documents`)은 `SWHeader` 파일명 목록 모양을 유지하고, 단독 `SWViewerMenu`(우클릭 메뉴 등)는 기존 색 그대로입니다.
|
||||
- **상태 표현:** Hover = #266651 면, Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(선택 박스 없음), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선.
|
||||
- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `SWViewerIcon`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke를 **SVG stroke**로 재현: 아이콘 뒤 복사본의 모든 도형을 검정, 원래 선 두께 + 화면 기준 양쪽 1px(`--hm-sw-viewer-icon-outline-width`)로 그림. 위 아이콘은 그대로라 색·모양·선 두께 유지. CSS `filter` 미사용, glow는 생략). Header 항목·`SWViewerHeaderButton`의 `icon`은 자동 적용, `custom` / `trailing`의 직접 그린 아이콘은 `<SWViewerIcon>`으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
|
||||
|
||||
@@ -82,9 +86,11 @@ import "@hm-design/sw/styles.css"
|
||||
|
||||
const groups: SWViewerHeaderGroup[] = [
|
||||
{ id: "view", label: "화면 보기", surface: "field", items: [{ type: "button", id: "fit", label: "전체 보기", icon: <FitIcon />, onSelect: fit }] },
|
||||
{ id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: <OptionsIcon />, pressed: open, onPressedChange: setOpen, collapseBelow: 900 }] },
|
||||
{ id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: <OptionsIcon />, pressed: open, onPressedChange: setOpen, iconOnlyBelow: 900 }] },
|
||||
]
|
||||
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand} />
|
||||
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand}
|
||||
onDocumentOpen={openFile} documentOpenLabel="파일 열기"
|
||||
documents={samples} activeDocumentId={sampleId} onDocumentSelect={loadSample} />
|
||||
<SWViewerPanel title="옵션" open={open} onClose={() => setOpen(false)}>
|
||||
<SWViewerSection title="표시">
|
||||
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
|
||||
@@ -97,7 +103,7 @@ const groups: SWViewerHeaderGroup[] = [
|
||||
| 하지 말 것 | 이유 / 대신 |
|
||||
|---|---|
|
||||
| `<SWViewerHeader logo="HM" />`처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
|
||||
| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고 클릭은 `onHomeClick`, 파일 열기·샘플은 `groups`의 `button`·`menu` 항목 |
|
||||
| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고의 파일 열기 메뉴는 `logoMenu`, 샘플 목록은 `documents`(직접 만든 Dropdown 금지) |
|
||||
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
|
||||
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
|
||||
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 가장 바깥 SW 범위(`<SWTheme>` 또는 단독 컴포넌트 루트)의 `style`로 조정(기본값 유지 권장, [AI_USAGE.md](./AI_USAGE.md) §3.1) |
|
||||
|
||||
@@ -12,7 +12,6 @@ const Icon = ({ d }: { d: string }) => (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
|
||||
)
|
||||
const icons = {
|
||||
open: <Icon d="M3 7h6l2 2h10v10H3z" />,
|
||||
fit: <Icon d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" />,
|
||||
layers: <Icon d="M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5" />,
|
||||
options: <Icon d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0" />,
|
||||
@@ -28,25 +27,23 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
|
||||
const [zoomSpeed, setZoomSpeed] = useState(1)
|
||||
const [about, setAbout] = useState(false)
|
||||
|
||||
// File open: the header button after the logo (icon + label, icon only when the header is narrow). File name click:
|
||||
// the sample list only (no Open / + New).
|
||||
const openFile = () => onEvent?.("file:open")
|
||||
const samples = [{ id: "sample-a", name: "BasicSample.dwg", title: "기본 도면 · DWG" }, { id: "sample-b", name: "simple.dxf", title: "기본 도형 · DXF" }]
|
||||
const groups: SWViewerHeaderGroup[] = [
|
||||
{ id: "file", label: "파일", items: [
|
||||
{ type: "button", id: "open", label: "파일 열기", icon: icons.open, iconOnlyBelow: 560 },
|
||||
{ type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180, entries: [
|
||||
{ type: "item", id: "sample-a", label: "기본 도면 · DWG", onSelect: () => setFileName("BasicSample.dwg") },
|
||||
{ type: "submenu", id: "recent", label: "최근 도면", entries: [{ type: "item", id: "recent-1", label: "simple.dxf", onSelect: () => setFileName("simple.dxf") }] },
|
||||
] },
|
||||
] },
|
||||
{ id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [
|
||||
{ type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace,
|
||||
options: [{ value: "model", label: "Model", count: 1200 }, { value: "layout", label: "Layout", count: 40 }] },
|
||||
] },
|
||||
{ id: "view", label: "화면 보기", surface: "field", items: [
|
||||
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName },
|
||||
// Measured steps: full while it fits, icon only next, then into More (the numbers only give the order).
|
||||
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, collapseBelow: 1180, disabled: !fileName },
|
||||
{ type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth },
|
||||
] },
|
||||
{ id: "panels", label: "패널", placement: "end", items: [
|
||||
{ type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, collapseBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) },
|
||||
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, collapseBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) },
|
||||
{ type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, iconOnlyBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) },
|
||||
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, iconOnlyBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) },
|
||||
] },
|
||||
]
|
||||
|
||||
@@ -54,6 +51,7 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
|
||||
<>
|
||||
{/* No logo prop: nothing is drawn on the cap (no "HM" text). Pass logoSrc / logo to show the program's logo. */}
|
||||
<SWViewerHeader documentTitle={fileName} groups={groups}
|
||||
documents={samples} activeDocumentId={samples.find(sample => sample.name === fileName)?.id} onDocumentSelect={sample => setFileName(sample.name)} onDocumentOpen={openFile} documentOpenLabel="파일 열기"
|
||||
onCommand={command => onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)}
|
||||
trailing={<>
|
||||
<SWViewerStatus state={fileName ? "ready" : "idle"} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
|
||||
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
|
||||
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface SWHeaderMoreItem {
|
||||
visible?: boolean
|
||||
id: string
|
||||
label: string
|
||||
/** 16px icon left of the label (SWViewerHeader logo menu, 2026-10-21). SWHeader's own items have none. */
|
||||
icon?: ReactNode
|
||||
/** Current value shown at the right (e.g. the line style name). */
|
||||
value?: string
|
||||
/**
|
||||
@@ -24,29 +26,73 @@ export interface SWHeaderMoreItem {
|
||||
guide?: boolean
|
||||
}
|
||||
|
||||
/** Props of a replacement trigger (`renderTrigger`): spread them on one button. */
|
||||
export interface SWHeaderMoreTriggerProps {
|
||||
ref: RefObject<HTMLButtonElement | null>
|
||||
"aria-haspopup": "menu"
|
||||
"aria-expanded": boolean
|
||||
"aria-controls"?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface SWHeaderMoreProps {
|
||||
items: SWHeaderMoreItem[]
|
||||
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
|
||||
forceVisible?: boolean
|
||||
/**
|
||||
* Internal reuse of the same menu with another trigger (SWViewerHeader logo menu, 2026-10-21). Default: the
|
||||
* `...` button. With it, ArrowDown on the closed trigger opens the menu on its first item and a selected item
|
||||
* returns focus to the trigger; the `...` button keeps its existing behavior.
|
||||
*/
|
||||
renderTrigger?: (props: SWHeaderMoreTriggerProps) => ReactNode
|
||||
/** Title above the items, shown only when at least one item follows. Title and items are one group: no divider. */
|
||||
title?: string
|
||||
/** Accessible name of the menu when there is no `title`. Default `추가 도구`. */
|
||||
label?: string
|
||||
/** Controlled open state (menus that must not be open together). Leave undefined to let the component manage it. */
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
/** Menu edge aligned to the trigger. Default `end` (the `...` button at the right). */
|
||||
align?: "start" | "end"
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Overflow menu that keeps every moved control reachable. */
|
||||
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
export function SWHeaderMore({ items, forceVisible, renderTrigger, title, label = "추가 도구", open: openProp, onOpenChange, align = "end", className }: SWHeaderMoreProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const open = openProp ?? internalOpen
|
||||
const setOpen = (next: boolean) => {
|
||||
if (openProp === undefined) setInternalOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuId = useId()
|
||||
const guideId = useId()
|
||||
const guideRef = useRef<HTMLButtonElement>(null)
|
||||
const titleId = useId()
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
// Set when the keyboard opens a replacement trigger, so the first item gets focus once the menu renders.
|
||||
const focusFirstRef = useRef(false)
|
||||
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
|
||||
|
||||
useOutsidePointerDown(open, rootRef, () => setOpen(false))
|
||||
|
||||
useEffect(() => {
|
||||
if (open && focusFirstRef.current) {
|
||||
focusFirstRef.current = false
|
||||
visibleItems()[0]?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && open) {
|
||||
if (renderTrigger && event.key === "ArrowDown" && !open && event.target === triggerRef.current) {
|
||||
event.preventDefault()
|
||||
focusFirstRef.current = true
|
||||
setOpen(true)
|
||||
} else if (event.key === "Escape" && open) {
|
||||
event.stopPropagation()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
@@ -59,9 +105,18 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerProps: SWHeaderMoreTriggerProps = {
|
||||
ref: triggerRef,
|
||||
"aria-haspopup": "menu",
|
||||
"aria-expanded": open,
|
||||
"aria-controls": open ? menuId : undefined,
|
||||
onClick: () => setOpen(!open),
|
||||
}
|
||||
const titled = !!title && items.some(item => item.visible !== false)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
<button
|
||||
<div ref={rootRef} className={`hm-sw-header__more${className ? ` ${className}` : ""}`} data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
|
||||
{renderTrigger ? renderTrigger(triggerProps) : <button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="hm-sw-header__more-trigger"
|
||||
@@ -69,16 +124,19 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
onClick={() => setOpen(value => !value)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<svg width="16" height="4" viewBox="0 0 16 4" aria-hidden="true" focusable="false">
|
||||
<circle cx="2" cy="2" r="1.6" fill="currentColor" />
|
||||
<circle cx="8" cy="2" r="1.6" fill="currentColor" />
|
||||
<circle cx="14" cy="2" r="1.6" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</button>}
|
||||
{open && (
|
||||
<div id={menuId} role="menu" aria-label="추가 도구" className="hm-sw-header__more-menu">
|
||||
<div id={menuId} role="menu" aria-label={titled ? undefined : label} aria-labelledby={titled ? titleId : undefined}
|
||||
className="hm-sw-header__more-menu" data-align={align === "start" ? "start" : undefined}>
|
||||
{/* Title and its items are one group: no divider between them (dividers only separate groups). */}
|
||||
{titled && <div id={titleId} className="hm-sw-header__more-title">{title}</div>}
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -100,8 +158,10 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
||||
onClick={() => {
|
||||
item.onSelect?.()
|
||||
setOpen(false)
|
||||
if (renderTrigger) triggerRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="hm-sw-header__more-icon" aria-hidden="true">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
{item.value && <span className="hm-sw-header__more-value">{item.value}</span>}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { moveRovingFocus } from "../shared/roving-focus"
|
||||
import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle"
|
||||
import { SWHeaderMore } from "../sw-header/components/SWHeaderMore"
|
||||
import type { SWHeaderDocument } from "../sw-header/types"
|
||||
import { SWToolButton } from "../sw-tool/SWTool"
|
||||
import { SWViewerMenu } from "./SWViewerMenu"
|
||||
import { SWViewerSwitch } from "./SWViewerControls"
|
||||
import { SWViewerIcon } from "./SWViewerIcon"
|
||||
@@ -25,8 +29,42 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
|
||||
documentPlaceholder?: string
|
||||
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
|
||||
documentTooltip?: string
|
||||
/** Replaces the file-name text (the dark document well stays). */
|
||||
/** Replaces the file-name text (the dark document well stays). Takes precedence over `documents`. */
|
||||
document?: ReactNode
|
||||
/**
|
||||
* Logo menu (2026-10-21): the logo cap opens a file menu flush under the header (SWHeaderMore behavior: Escape,
|
||||
* outside click, arrow keys; ArrowDown opens it on its first item and focus returns to the logo) with the face, colors,
|
||||
* size, spacing and type of the other viewer header dropdowns (the More face). `title` adds a
|
||||
* title row directly above the items (title + items are one group: no divider), shown only when an item follows;
|
||||
* without a title only the items are listed. `icon`: 16px icon left of the label (SWViewerMenu icon slot).
|
||||
* Each item runs its `onSelect`, then `onCommand` with `{ id, itemId: id }`. Takes precedence over `onHomeClick`;
|
||||
* with no visible item the cap keeps the plain logo / `onHomeClick`. Never open together with the document list.
|
||||
*/
|
||||
logoMenu?: {
|
||||
title?: string
|
||||
items: { id: string; label: string; icon?: ReactNode; value?: string; checked?: boolean; disabled?: boolean; hidden?: boolean; onSelect?: () => void }[]
|
||||
}
|
||||
/**
|
||||
* Document list below the file name (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle)
|
||||
* without + New and Open (file open is the header button, `onDocumentOpen`). The button shows `documentTitle` (or `documentPlaceholder`); the list
|
||||
* is at least as wide as the file-name button and the SWHeader list, wider for longer rows. Omit to keep the plain
|
||||
* file name.
|
||||
*/
|
||||
documents?: SWHeaderDocument[]
|
||||
/** Id of the current document in `documents` (its name in #FFC600; the list order never changes). */
|
||||
activeDocumentId?: string
|
||||
/** A document row was chosen. The host loads it and updates `documentTitle` / `activeDocumentId`. */
|
||||
onDocumentSelect?: (document: SWHeaderDocument) => void
|
||||
/**
|
||||
* File open button in the header (2026-10-21), like SWHeader `documentOpenPlacement="header"`: the SWHeader file-name
|
||||
* surface (SWToolButton `appearance="document"`) right after the logo, with a bordered folder icon and the label.
|
||||
* Shown only with this callback; the document list never shows Open or + New in the viewer. When the header has no
|
||||
* room it shows the icon only (label as tooltip and accessible name, measured like the other items), never in More.
|
||||
* Runs the callback, then `onCommand` with `{ id: "open", itemId: "document" }`.
|
||||
*/
|
||||
onDocumentOpen?: () => void
|
||||
/** Label of that button. Default `Open`. */
|
||||
documentOpenLabel?: string
|
||||
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
|
||||
groups: SWViewerHeaderGroup[]
|
||||
/** Free content after the trailing groups (status, info button, ...). */
|
||||
@@ -42,20 +80,64 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
|
||||
const MoreIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="3" cy="8" r="1.4" /><circle cx="8" cy="8" r="1.4" /><circle cx="13" cy="8" r="1.4" /></svg>
|
||||
)
|
||||
const FolderIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></svg>
|
||||
)
|
||||
const ChevronDown = () => (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
)
|
||||
|
||||
const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow)
|
||||
/** Step id of the header Open button (icon only in stage 2). */
|
||||
const OPEN_ID = ":document-open"
|
||||
|
||||
const isShown = (item: { id: string; hidden?: boolean }, collapsed: ReadonlySet<string>) => !item.hidden && !collapsed.has(item.id)
|
||||
|
||||
/**
|
||||
* Measured responsive steps (2026-10-21), applied one at a time while the header has no room left and undone in reverse
|
||||
* order once the room a step freed is back: 1. every item with an `icon` and `iconOnlyBelow` shows its icon only (label
|
||||
* kept as tooltip and accessible name); 2. `collapseBelow` items move into More one by one, the higher value first and,
|
||||
* for the same value, the rightmost first. No fixed width decides a step: the values only give the order.
|
||||
*/
|
||||
type Step = { kind: "icons"; ids: string[] } | { kind: "collapse"; id: string }
|
||||
function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = []): Step[] {
|
||||
const icons: string[] = [...extraIcons], collapse: { id: string; rank: number; order: number }[] = []
|
||||
groups.filter(group => !group.hidden).flatMap(group => group.items).forEach((item, order) => {
|
||||
if (item.hidden) return
|
||||
if (item.type !== "custom" && item.icon && !item.iconOnly && item.iconOnlyBelow !== undefined) icons.push(item.id)
|
||||
if (item.collapseBelow !== undefined) collapse.push({ id: item.id, rank: item.collapseBelow, order })
|
||||
})
|
||||
collapse.sort((a, b) => b.rank - a.rank || b.order - a.order)
|
||||
return [...(icons.length ? [{ kind: "icons" as const, ids: icons }] : []), ...collapse.map(item => ({ kind: "collapse" as const, id: item.id }))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Room left in the header row (negative: missing) with every part at its natural width: the file name at its text
|
||||
* width up to its usual width (so a short name leaves room to the menus), the object surface without stretching and
|
||||
* the trailing area without its auto margin (`data-measuring`, sw-viewer.css). Read and reset in one layout effect,
|
||||
* never painted; positioned layers (menus, logo menu) never count. Same method as SWHeader's measured More.
|
||||
*/
|
||||
function measureRoom(root: HTMLElement) {
|
||||
root.setAttribute("data-measuring", "")
|
||||
const style = getComputedStyle(root), box = root.getBoundingClientRect()
|
||||
const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
|
||||
let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
|
||||
for (const child of Array.from(root.children)) {
|
||||
const childStyle = getComputedStyle(child)
|
||||
if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
|
||||
used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
|
||||
}
|
||||
root.removeAttribute("data-measuring")
|
||||
return end - used
|
||||
}
|
||||
|
||||
/** More entries of the collapsed items, one section per group (menus and tabs get their own section). */
|
||||
function moreEntries(groups: SWViewerHeaderGroup[], width: number, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
|
||||
function moreEntries(groups: SWViewerHeaderGroup[], collapsedIds: ReadonlySet<string>, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
|
||||
const sections: SWViewerMenuEntry[][] = []
|
||||
// Dropdown entries keep their own callbacks; the More menu reports them to onCommand with their owner item.
|
||||
const own = (entries: SWViewerMenuEntry[], itemId: string) => entries.forEach(entry => { owners.set(entry.id, itemId); if (entry.type === "submenu") own(entry.entries, itemId) })
|
||||
for (const group of groups) {
|
||||
if (group.hidden) continue
|
||||
const collapsed = group.items.filter(item => !item.hidden && item.collapseBelow !== undefined && width < item.collapseBelow)
|
||||
const collapsed = group.items.filter(item => !item.hidden && collapsedIds.has(item.id))
|
||||
let plain: SWViewerMenuEntry[] = []
|
||||
const flush = () => { if (plain.length) sections.push([{ type: "label", id: `${group.id}:more`, label: group.moreLabel ?? group.label }, ...plain]); plain = [] }
|
||||
for (const item of collapsed) {
|
||||
@@ -103,7 +185,8 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
|
||||
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
|
||||
</button>
|
||||
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
|
||||
{/* 2026-10-21: header dropdowns use the More face (beige), like every menu of the viewer header. */}
|
||||
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} surface="beige" label={item.label} initialFocus={focus}
|
||||
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
|
||||
</>
|
||||
)
|
||||
@@ -143,15 +226,15 @@ function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, {
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
|
||||
function Item({ item, compact, onCommand }: { item: SWViewerHeaderItem; compact: boolean; onCommand: (c: SWViewerCommand) => void }) {
|
||||
if (item.type === "custom") return <>{item.render()}</>
|
||||
const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
|
||||
const iconOnly = !!item.icon && (item.iconOnly ? "always" : compact)
|
||||
if (item.type === "menu") return <MenuButton item={item} iconOnly={iconOnly} onCommand={onCommand} />
|
||||
if (item.type === "tabs") return <Tabs item={item} onCommand={onCommand} />
|
||||
if (item.type === "switch") return <SwitchField item={item} onCommand={onCommand} />
|
||||
const toggle = item.type === "toggle"
|
||||
return (
|
||||
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title} disabled={item.disabled}
|
||||
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title ?? (iconOnly ? item.label : undefined)} disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) }
|
||||
else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) }
|
||||
@@ -192,19 +275,23 @@ export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeader
|
||||
|
||||
/**
|
||||
* SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green
|
||||
* object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown
|
||||
* by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every
|
||||
* object surface holding data-driven menu groups, with a trailing action area. Responsive by the measured room of the
|
||||
* header row, not by fixed widths (2026-10-21): full items while they fit, then icons only (`iconOnlyBelow` items with
|
||||
* an icon), then `collapseBelow` items move into the More dropdown one by one; they come back in reverse order once
|
||||
* the room is there. The numbers only set the order. Never horizontal scroll. Pure presentation: values are props, every
|
||||
* action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode.
|
||||
*/
|
||||
export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(function SWViewerHeader(
|
||||
{
|
||||
logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode,
|
||||
logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel,
|
||||
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const rootRef = useRef<HTMLElement | null>(null)
|
||||
const [width, setWidth] = useState(1920)
|
||||
// Header width: a resize re-renders, and the layout pass below measures again.
|
||||
const [, setWidth] = useState(1920)
|
||||
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
@@ -215,23 +302,73 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
|
||||
observer.observe(root)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
const start = groups.filter(group => group.placement !== "end")
|
||||
const end = groups.filter(group => group.placement === "end")
|
||||
// Measured responsive steps (see responsiveSteps): `level` steps are applied. Each layout pass measures the room; a
|
||||
// missing room applies the next step, and the last step is undone once the room it freed (measured right after it was
|
||||
// applied) is there again. The passes are synchronous layout effects, so only the settled layout is painted.
|
||||
const steps = responsiveSteps([...start, ...end], onDocumentOpen ? [OPEN_ID] : [])
|
||||
const [level, setLevel] = useState(0)
|
||||
const applied = steps.slice(0, level)
|
||||
const collapsed = new Set(applied.flatMap(step => (step.kind === "collapse" ? [step.id] : [])))
|
||||
const compact = new Set(applied.flatMap(step => (step.kind === "icons" ? step.ids : [])))
|
||||
const freedRef = useRef<{ before: number; room?: number }[]>([])
|
||||
// Steps or texts that change widths start the measurement over (file name, labels, items shown).
|
||||
const layoutKey = JSON.stringify([documentTitle, documentPlaceholder, documentOpenLabel, !!onDocumentOpen, steps, groups.map(group => [group.hidden, group.items.map(item => [item.id, item.hidden, "label" in item ? item.label : ""])])])
|
||||
const keyRef = useRef(layoutKey)
|
||||
// Web fonts change text widths without a resize: measure again once they are loaded.
|
||||
const [, setFontsLoaded] = useState(0)
|
||||
useEffect(() => {
|
||||
const fonts = typeof document === "undefined" ? undefined : document.fonts
|
||||
if (!fonts) return
|
||||
let live = true
|
||||
const loaded = () => { if (live) setFontsLoaded(count => count + 1) }
|
||||
fonts.addEventListener?.("loadingdone", loaded)
|
||||
fonts.ready.then(loaded)
|
||||
return () => { live = false; fonts.removeEventListener?.("loadingdone", loaded) }
|
||||
}, [])
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
if (keyRef.current !== layoutKey) {
|
||||
keyRef.current = layoutKey
|
||||
freedRef.current = []
|
||||
if (level !== 0) return setLevel(0)
|
||||
}
|
||||
if (level > steps.length) return setLevel(steps.length)
|
||||
const room = measureRoom(root)
|
||||
const freed = freedRef.current
|
||||
const last = level > 0 ? freed[level - 1] : undefined
|
||||
if (last && last.room === undefined) last.room = room - last.before
|
||||
if (room < -0.5) {
|
||||
if (level < steps.length) {
|
||||
freed[level] = { before: room }
|
||||
setLevel(level + 1)
|
||||
}
|
||||
} else if (level > 0 && (last?.room === undefined || room >= last.room)) {
|
||||
freed.length = level - 1
|
||||
setLevel(level - 1)
|
||||
}
|
||||
})
|
||||
const command = (value: SWViewerCommand) => onCommand?.(value)
|
||||
const owners = new Map<string, string>()
|
||||
const more = moreEntries(groups, width, command, owners)
|
||||
const more = moreEntries(groups, collapsed, command, owners)
|
||||
const moreRef = useRef<HTMLButtonElement>(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
|
||||
// The logo menu and the document list are never open together (opening one closes the other).
|
||||
const [openMenu, setOpenMenu] = useState<"logo" | "document" | null>(null)
|
||||
const menuOpenChange = (menu: "logo" | "document") => (next: boolean) => setOpenMenu(current => (next ? menu : current === menu ? null : current))
|
||||
const logoItems = logoMenu?.items.filter(item => !item.hidden) ?? []
|
||||
const renderGroup = (group: SWViewerHeaderGroup) => {
|
||||
const items = group.items.filter(item => isShown(item, width))
|
||||
const items = group.items.filter(item => isShown(item, collapsed))
|
||||
if (group.hidden || !items.length) return null
|
||||
return (
|
||||
<div key={group.id} role="group" aria-label={group.label} className="hm-sw-viewer-header__group" data-surface={group.surface ?? "plain"} data-group={group.id}>
|
||||
{items.map(item => <Item key={item.id} item={item} width={width} onCommand={command} />)}
|
||||
{items.map(item => <Item key={item.id} item={item} compact={compact.has(item.id)} onCommand={command} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const start = groups.filter(group => group.placement !== "end")
|
||||
const end = groups.filter(group => group.placement === "end")
|
||||
const name = documentTitle || documentPlaceholder
|
||||
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
|
||||
const brandContent = (
|
||||
@@ -253,11 +390,36 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
|
||||
return (
|
||||
<header ref={node => { rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }}
|
||||
className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}>
|
||||
{onHomeClick
|
||||
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
|
||||
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
|
||||
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면">
|
||||
{documentNode ?? <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>}
|
||||
{logoItems.length > 0
|
||||
? <SWHeaderMore className="hm-sw-viewer-header__logo-menu" align="start" title={logoMenu?.title} label={logoMenu?.title ?? (logoAlt ? `${logoAlt} 메뉴` : "메뉴")}
|
||||
open={openMenu === "logo"} onOpenChange={menuOpenChange("logo")}
|
||||
items={logoItems.map(item => ({
|
||||
id: item.id, label: item.label, icon: item.icon, value: item.value, checked: item.checked, disabled: item.disabled, tier: "action" as const, visible: true,
|
||||
onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) },
|
||||
}))}
|
||||
renderTrigger={({ ref, ...trigger }) => (
|
||||
<button ref={ref} type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} data-state={trigger["aria-expanded"] ? "open" : undefined}
|
||||
aria-label={logoAlt ? `${logoAlt} 메뉴` : "메뉴"} title={logoAlt} {...trigger}>{brandContent}</button>
|
||||
)} />
|
||||
: onHomeClick
|
||||
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
|
||||
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
|
||||
{onDocumentOpen && (() => {
|
||||
const label = documentOpenLabel ?? "Open", iconOnly = compact.has(OPEN_ID)
|
||||
return (
|
||||
<SWToolButton appearance="document" className="hm-sw-viewer-header__open" data-document-open="" data-icon-only={iconOnly ? "" : undefined}
|
||||
aria-label={iconOnly ? label : undefined} title={iconOnly ? label : undefined}
|
||||
onClick={() => { onDocumentOpen(); command({ id: "open", itemId: "document" }) }}>
|
||||
<span className="hm-sw-viewer-header__open-icon" aria-hidden="true"><FolderIcon /></span>
|
||||
{!iconOnly && <span className="hm-sw-viewer-header__open-label">{label}</span>}
|
||||
</SWToolButton>
|
||||
)
|
||||
})()}
|
||||
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면" data-list={!documentNode && documents ? "" : undefined}>
|
||||
{documentNode ?? (documents
|
||||
? <SWHeaderDocumentTitle title={name} label="도면" documents={documents} activeDocumentId={activeDocumentId} onDocumentSelect={onDocumentSelect}
|
||||
showNew={false} showOpen={false} menuOpen={openMenu === "document"} onMenuOpenChange={menuOpenChange("document")} />
|
||||
: <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>)}
|
||||
</div>
|
||||
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
|
||||
{start.map(renderGroup)}
|
||||
|
||||
@@ -114,6 +114,131 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
||||
}
|
||||
.hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
||||
/* Logo menu (2026-10-21): the brand cap is the trigger of the SWHeader `...` menu (SWHeaderMore, sw.css). The wrapper
|
||||
takes the cap's place in the row but is not positioned: the cap keeps its own layer (z-index 2 over the file name,
|
||||
like the SWHeader logo), and the menu is placed by the header container itself — flush under the header's bottom
|
||||
border (100% of its padding box + its 1px border) at the left edge of the cap, at any header height or width. */
|
||||
.hm-sw-viewer-header__logo-menu.hm-sw-header__more {
|
||||
position: static;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||
margin-left: 0;
|
||||
}
|
||||
.hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu {
|
||||
top: calc(100% + 1px);
|
||||
left: 0;
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Logo menu (2026-10-21): the size, spacing and type of the viewer header dropdowns (SWViewerMenu); its colors are
|
||||
those of the SWHeader `...` menu it is (sw.css .hm-sw-header__more-menu: face, border, shadow, hover); its place
|
||||
stays flush under the header. Only this menu: the document list stays the SWHeader file-name list. */
|
||||
.hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu {
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
border-width: 1px;
|
||||
border-radius: var(--hm-sw-viewer-radius);
|
||||
color: var(--hm-sw-header-text-color);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item {
|
||||
gap: 8px;
|
||||
min-height: var(--hm-sw-viewer-menu-item-height);
|
||||
padding: 0 12px;
|
||||
color: inherit;
|
||||
}
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:is(:hover, :focus):not(:disabled) { background: var(--hm-sw-header-hover); outline: none; }
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:disabled { opacity: 0.5; }
|
||||
/* Title = SWViewerMenu label, directly above its items (one group, no divider); icons = SWViewerMenu icon slot. */
|
||||
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-title { min-height: 0; padding: 6px 12px; font-size: 12px; font-weight: 500; line-height: 18px; }
|
||||
|
||||
/* Document list (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle) fill the well.
|
||||
The button draws the well (border, gradient), sized to the viewer row; the text keeps the viewer inset. */
|
||||
.hm-sw-viewer-header__document[data-list] {
|
||||
--hm-sw-header-bar-height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Header Open (2026-10-21, `onDocumentOpen`): SWHeader's documentOpenPlacement="header" surface (SWToolButton
|
||||
appearance="document", the file-name surface at its content width) right after the logo, sized to the viewer row,
|
||||
with the label after a folder icon (no border, no face). Icon only when the header has no room (measured,
|
||||
SWViewerHeader); the file name after it has no left padding (2026-10-21 user request). */
|
||||
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] {
|
||||
--hm-sw-header-bar-height: calc(var(--hm-sw-viewer-height) - 2px);
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
line-height: normal;
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-header__open { padding: 0 12px 0 16px; }
|
||||
.hm-sw-viewer-header__open .hm-sw-header__document-name { display: inline-flex; align-items: center; gap: 8px; overflow: visible; }
|
||||
.hm-sw-viewer-header__open-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: var(--hm-sw-viewer-button-height);
|
||||
height: var(--hm-sw-viewer-button-height);
|
||||
/* 2026-10-21 user request: no border and no face (transparent); the icon keeps its color, size and place. */
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
.hm-sw-viewer-header__open-icon > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
|
||||
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] + .hm-sw-viewer-header__document { padding-left: 0; }
|
||||
.hm-sw-viewer-header__document[data-list] > .hm-sw-header__document { flex: 1 1 auto; min-width: 0; }
|
||||
/* No file open button before it: the file name starts 10px after the slanted logo edge (at mid height), as in
|
||||
SWHeader (text 10px after its home slant; measured 10.5px here with 18px). */
|
||||
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-button { padding: 0 16px 0 18px; }
|
||||
/* After the file open button (2026-10-21 user decision): 8px in. */
|
||||
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] + .hm-sw-viewer-header__document[data-list] .hm-sw-header__document-button { padding: 0 16px 0 8px; }
|
||||
/* The list (SWHeader face, placement and rows unchanged) is never narrower than the file-name button it opens from:
|
||||
its outer width is at least the button's (100% of the wrapper, plus the two borders the list starts left of it) and
|
||||
at least the SWHeader list width; longer rows widen it. min-width wins over the SWHeader max-width. */
|
||||
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-menu {
|
||||
width: max-content;
|
||||
min-width: max(
|
||||
calc(var(--hm-sw-header-document-menu-width) + 2 * var(--hm-sw-header-document-menu-border-width)),
|
||||
calc(100% + 2 * var(--hm-sw-header-document-menu-border-width))
|
||||
);
|
||||
}
|
||||
/* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the
|
||||
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the
|
||||
object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text
|
||||
unchanged. Size, spacing and type stay SWViewerMenu. A standalone SWViewerMenu (context menu) and the document list
|
||||
keep their own colors. */
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu[data-surface] {
|
||||
border-color: var(--hm-sw-header-object-border-color);
|
||||
background: #0e3d2f;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
color: var(--hm-sw-header-text-color);
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) {
|
||||
background: var(--hm-sw-header-hover);
|
||||
color: inherit;
|
||||
}
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu :is(.hm-sw-viewer-menu__label, .hm-sw-viewer-menu__shortcut, .hm-sw-viewer-menu__chevron) { color: inherit; opacity: 0.8; }
|
||||
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__separator { background: var(--hm-sw-header-document-menu-divider-color); }
|
||||
|
||||
/* Measured responsive steps (2026-10-21, SWViewerHeader measureRoom): set only inside one layout pass, never painted.
|
||||
Every part at its natural width: the file name at its text width up to its usual flex basis, the object surface
|
||||
neither stretched nor shrunk, the trailing area without its auto margin. */
|
||||
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex: 0 0 auto; width: max-content; max-width: clamp(140px, 25cqw, 360px); }
|
||||
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__object { flex: 0 0 auto; }
|
||||
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__actions { margin-left: 0; }
|
||||
/* While the logo menu or the document list is open, the header takes the viewer dropdown layer (SWViewerMenu, 60) so
|
||||
the parts hanging below it stay above SWViewerPanel popups (41+). Inside, the SWHeader order is kept: the logo cap
|
||||
(z-index 2) over the file name and its list (z-index 1), so an open list never covers the logo. Closed: no layer. */
|
||||
.hm-sw-viewer-header:has(> .hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu, > .hm-sw-viewer-header__document .hm-sw-header__document-menu) { z-index: 60; }
|
||||
|
||||
.hm-sw-viewer-header__object {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
--hm-sw-header-document-menu-inset-shadow: inset 0 4px 8px 0 rgba(0, 0, 0, 0.4);
|
||||
--hm-sw-header-document-menu-divider-color: rgba(194, 217, 210, 0.2);
|
||||
--hm-sw-header-document-list-max-height: 320px;
|
||||
/* Document list scrollbar (2026-10-21): the Storybook sidebar scrollbar (.storybook/manager.css, #BDDFD4 thumb at
|
||||
50% / 80% on hover, 6px, round, transparent track). */
|
||||
--hm-sw-header-document-scrollbar-thumb: rgba(189, 223, 212, 0.5);
|
||||
--hm-sw-header-document-scrollbar-thumb-hover: rgba(189, 223, 212, 0.8);
|
||||
--hm-sw-header-document-action-background: rgba(182, 208, 201, 0.2);
|
||||
--hm-sw-header-document-action-color: rgba(0, 0, 0, 0.7);
|
||||
/* Figma hover variants: list 26:10680, + New 26:10762, Open 26:10805 */
|
||||
@@ -422,6 +426,18 @@
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--hm-sw-header-document-menu-inset-shadow);
|
||||
}
|
||||
/* Scrollbar (2026-10-21): like the Storybook sidebar — 6px, round thumb, transparent track 3px in from top and bottom,
|
||||
thumb 50% (80% on the thumb). Shown whenever the list scrolls: Chromium does not repaint a custom scrollbar on the
|
||||
list's own :hover, so a hover-only thumb could stay hidden. Chromium / Safari draw it with ::-webkit-scrollbar;
|
||||
Firefox, which has no such pseudo-elements, uses the SW standard thin scrollbar with the same colors. Only the
|
||||
scrollbar changes: list size, rows and scrolling stay as they are. */
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
.hm-sw-header__document-list { scrollbar-width: thin; scrollbar-color: var(--hm-sw-header-document-scrollbar-thumb) transparent; }
|
||||
}
|
||||
.hm-sw-header__document-list::-webkit-scrollbar { width: 6px; background: transparent; }
|
||||
.hm-sw-header__document-list::-webkit-scrollbar-track { margin: 3px 0; background: transparent; }
|
||||
.hm-sw-header__document-list::-webkit-scrollbar-thumb { border-radius: 9px; background: var(--hm-sw-header-document-scrollbar-thumb); }
|
||||
.hm-sw-header__document-list::-webkit-scrollbar-thumb:hover { background: var(--hm-sw-header-document-scrollbar-thumb-hover); }
|
||||
/* Row = select button + optional Close Document button (siblings). Hover and the current
|
||||
document are styled on the row so the overlay stays while the pointer is on the X button. */
|
||||
.hm-sw-header__document-row {
|
||||
@@ -1310,6 +1326,22 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-header__more-item[role="menuitemcheckbox"]::after { content: ""; width: 14px; text-align: center; }
|
||||
.hm-sw-header__more-item[aria-checked="true"]::after { content: "✓"; }
|
||||
.hm-sw-header__more-value { font-size: var(--hm-sw-header-label-size); opacity: 0.8; }
|
||||
/* Optional title and item icons (SWViewerHeader logo menu, 2026-10-21). The title is rendered only with at least one
|
||||
item and has no divider (title + items are one group); the `...` menu of SWHeader passes neither and is unchanged. */
|
||||
.hm-sw-header__more-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hm-sw-header__more-item:has(> .hm-sw-header__more-icon) { justify-content: flex-start; }
|
||||
.hm-sw-header__more-icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; }
|
||||
.hm-sw-header__more-icon > svg { width: 16px; height: 16px; }
|
||||
.hm-sw-header__more-item:has(> .hm-sw-header__more-icon) > .hm-sw-header__more-value { margin-left: auto; }
|
||||
.hm-sw-header__more-menu[data-align="start"] { right: auto; left: 0; }
|
||||
|
||||
/* Window area: print / guide pill, window move, minimize, close. */
|
||||
.hm-sw-header__window {
|
||||
|
||||
@@ -279,12 +279,17 @@ try {
|
||||
check('Overview keeps one Header at 49px and 16:9 Stage', await page.locator('header.hm-sw-header').count() === 1 && await page.locator('[data-preview-stage]').evaluate(e => Math.abs(e.getBoundingClientRect().width / e.getBoundingClientRect().height - 16 / 9) < 0.001) && await page.locator('header.hm-sw-header').evaluate(e => e.getBoundingClientRect().height === 49))
|
||||
await go('window-move-motion')
|
||||
check('Window Move Motion retains only nine 3x steps', JSON.stringify(await page.locator('section > header h2').allTextContents()) === JSON.stringify(['단계별 진행 (3배 확대)']) && await page.locator('[data-motion-time]').count() === 9 && await page.locator('.sw-story-zoom').first().evaluate(e => getComputedStyle(e).zoom === '3'))
|
||||
// 2026-10-21 (SW Storybook menu cleanup, user request): Dropdown left the sidebar by the verification tag (storybook-inventory
|
||||
// verificationTaggedIds); its ID and direct URL stay. Read the sidebar with the Header group open (SW Header page),
|
||||
// since opening the hidden Story itself leaves the group collapsed and would make an absent link trivially true.
|
||||
await page.goto(`${base}/?path=/story/sw-header--overview`)
|
||||
await page.locator('a[href="/?path=/story/sw-header--overview"]').waitFor()
|
||||
const headerLinks = await page.locator('a[href^="/?path=/story/sw-header--"]').evaluateAll(list => list.map(a => a.getAttribute('href')))
|
||||
check('manager hides the Dropdown link under the open Header group, no Composed Controls submenu', headerLinks.length > 0 && !headerLinks.includes('/?path=/story/sw-header--composed-controls') && await page.getByRole('link', { name: 'Dropdown', exact: true }).count() === 0 && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0, headerLinks)
|
||||
await page.goto(`${base}/?path=/story/sw-header--composed-controls`)
|
||||
await page.locator('#storybook-preview-iframe').waitFor()
|
||||
const dropdownLink = page.getByRole('link', { name: 'Dropdown', exact: true })
|
||||
await dropdownLink.waitFor()
|
||||
await dropdownLink.scrollIntoViewIfNeeded()
|
||||
check('manager shows a single Dropdown link directly under Header, no Composed Controls submenu', await dropdownLink.count() === 1 && await dropdownLink.getAttribute('href') === '/?path=/story/sw-header--composed-controls' && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0)
|
||||
await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').waitFor()
|
||||
check('Dropdown direct URL still opens the Story (ID sw-header--composed-controls)', await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').count() === 1)
|
||||
check('retired Dropdown links absent in manager', await page.locator(retiredDropdownIds.map(id => `a[href*="${id}"]`).join(',')).count() === 0)
|
||||
await page.screenshot({ path: path.join(out, 'composed-manager.png'), fullPage: true })
|
||||
check('no browser console or page errors', report.errors.length === 0, report.errors)
|
||||
|
||||
@@ -83,7 +83,8 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
|
||||
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()
|
||||
// Reloading the Story reopens A, B and C (the page has no reopen button since 2026-10-21).
|
||||
await goto('sw-popup--multiple-popups')
|
||||
await popup('a').locator('.hm-sw-property__titlebar').click()
|
||||
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
@@ -68,7 +68,7 @@ try {
|
||||
page = await browser.newPage({ viewport:{width:1440,height:1000} }); page.setDefaultTimeout(6000)
|
||||
page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if(m.type() === 'error') report.errors.push(m.text()) })
|
||||
const go = async(id='overview') => {
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story${id === 'overview' ? '&verification' : ''}`)
|
||||
await page.locator('.hm-sw-header,.hm-sw-theme').first().waitFor(); await page.evaluate(() => document.fonts.ready)
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox
|
||||
// The Firefox driver talks WebDriver BiDi over the global WebSocket (Node 22+; Node 20: --experimental-websocket).
|
||||
if (typeof WebSocket === 'undefined') throw new Error('No global WebSocket: run with Node 22+ or `node --experimental-websocket` (Node 20).')
|
||||
const { chromium } = loadPlaywright()
|
||||
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story`
|
||||
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story&verification`
|
||||
const VIEW = { width: 1400, height: 1000 }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
@@ -61,12 +61,23 @@ export const footerStoryIds = ['full-footer', 'tool-groups', 'figma-variants', '
|
||||
export const viewerStoryIds = ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'compare-with-sw-header', 'logo'].map(id => `sw-viewer--${id}`)
|
||||
export const themeStoryIds = ['sw-theme--theme-inheritance']
|
||||
// 2b0e1a2 sidebar simplification: ID and direct URL kept, hidden from the sidebar by the verification tag.
|
||||
export const verificationTaggedIds = ['sw-header--window-move-motion', 'sw-header--k-guide']
|
||||
// 2026-10-21 (SW Storybook menu cleanup, user request): Interactive States and Document Dropdown left the sidebar the
|
||||
// same way; Overview is named "SW Header" and sorted first (preview storySort). menuCleanupHiddenIds lists every Story the
|
||||
// cleanup hid (also the approved additions hidden-sub-controls / home-menu-standalone and three Footer pages).
|
||||
export const verificationTaggedIds = ['sw-header--window-move-motion', 'sw-header--k-guide', 'sw-header--interactive-states', 'sw-header--document-dropdown', 'sw-header--composed-controls']
|
||||
export const menuCleanupHiddenIds = ['sw-header--interactive-states', 'sw-header--document-dropdown', 'sw-header--hidden-sub-controls', 'sw-header--home-menu-standalone',
|
||||
...['tool-groups', 'custom-content', 'parts'].map(id => `sw-footer--${id}`),
|
||||
// Second pass (same day): SW/Theme hidden as a whole; Viewer settings-panel merged into content-variants.
|
||||
'sw-header--document-open', 'sw-header--composed-controls', ...themeStoryIds, ...['menu-groups', 'states', 'dropdowns', 'variable-size', 'settings-panel'].map(id => `sw-viewer--${id}`)]
|
||||
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW Footer',
|
||||
'sw-footer--responsive': 'Responsive More', 'sw-viewer--content-variants': 'Settings Popup',
|
||||
'sw-viewer--responsive': 'Responsive More' }
|
||||
// Every approved SW Story ID added after the unused-demo removal, as the current index lists them.
|
||||
export const approvedAddedStoryIds = [...integratedStoryIds.filter(id => !retiredToolIds.includes(id)), ...homeMenuSidebarIds,
|
||||
...Object.values(movedHomeMenuIds).filter(id => !retiredHomeMenuCompareIds.includes(id)), ...footerStoryIds, ...viewerStoryIds, ...themeStoryIds]
|
||||
// An earlier index entry with the approved tag / Popup move applied (2b0e1a2; the moved Story has no component path).
|
||||
export const withApprovedEntryChanges = entry => {
|
||||
if (renamedEntries[entry.id]) entry = { ...entry, name: renamedEntries[entry.id] }
|
||||
if (verificationTaggedIds.includes(entry.id)) return { ...entry, tags: [...entry.tags, 'verification'] }
|
||||
if (!movedToPopupIds.includes(entry.id)) return entry
|
||||
const { componentPath, ...rest } = entry
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 2026-10-20 SW text weight request: Bold only on the named texts, with the black text outline where asked.
|
||||
// Header file name (700) · Composed Controls field values (700) vs SWHeader field values (400) · Footer 보조기능 / 명령어
|
||||
// titles and category tabs (700, 전체보기 / Model · Layout / commands unchanged) · Viewer header file name, labels and
|
||||
// Model / Layout tabs (700, More kept) · recent files name / path / preview title (700 + outline). Hover keeps the
|
||||
// Model / Layout tabs (700, More kept; 2026-10-21 logo menu and sample list follow the SWHeader `...` menu / file-name
|
||||
// list rules) · recent files name / path / preview title (700 + outline). Hover keeps the
|
||||
// text in place. Usage: build Storybook first, then node packages/sw/tests/text-weight.browser.mjs [evidence-dir]
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
@@ -109,19 +110,61 @@ check('Footer: ◀ ▶ only when the categories overflow their area', (area.cont
|
||||
await page.screenshot({ path: path.join(out, 'footer.png'), clip: { x: 0, y: 940, width: 1920, height: 160 } })
|
||||
|
||||
// ---------- 3. Viewer header ----------
|
||||
// 2026-10-21 (approved restructure): 파일 열기 moved into the logo menu (the SWHeader `...` menu, SWHeaderMore), 샘플 into
|
||||
// the file-name list (SWHeaderDocumentTitle) and 전체 보기 into More. The header keeps the Bold file name (now the
|
||||
// SWHeader file-name button), 레이어 · 설정 · 속성 and Model / Layout; the two menus follow the SWHeader rules, measured
|
||||
// on SWHeader itself below and compared row by row.
|
||||
const typo = s => ({ weight: s.weight, size: s.size, family: s.family, outline: s.outline })
|
||||
// SWHeader references: the responsive `...` menu rows and the file-name list rows.
|
||||
await goto('sw-header--responsive-more')
|
||||
const refTrigger = page.locator('.hm-sw-header__more-trigger').filter({ visible: true }).first()
|
||||
await refTrigger.click(); await page.waitForTimeout(200)
|
||||
const refMore = await styles('.hm-sw-header__more-menu .hm-sw-header__more-item > span:first-child')
|
||||
check('SWHeader ... menu rows (reference): Regular 400 without outline', refMore.length > 0 && refMore.every(r => r.weight === '400' && r.outline === 'none'), refMore)
|
||||
await page.keyboard.press('Escape')
|
||||
await goto('sw-header--document-dropdown')
|
||||
await page.locator('.hm-sw-header__document-button').first().click(); await page.waitForTimeout(200)
|
||||
const refDocs = await styles('.hm-sw-header__document-menu .hm-sw-header__document-item-name')
|
||||
check('SWHeader file-name list rows (reference): 14px + 1.3px outline, one weight', refDocs.length > 0 && refDocs.every(r => r.size === '14px' && r.outline === OUTLINE_14 && r.weight === refDocs[0].weight), refDocs)
|
||||
// 2026-10-21: the logo menu takes the viewer dropdown type (SWViewerMenu, "상단 메뉴 + Dropdown"); its rows and label are the reference.
|
||||
await goto('sw-viewer--dropdowns')
|
||||
await page.getByRole('button', { name: '도면' }).click(); await page.waitForTimeout(200)
|
||||
const refViewerItem = await styles('.hm-sw-viewer-menu .hm-sw-viewer-menu__text')
|
||||
const refViewerLabel = await styles('.hm-sw-viewer-menu .hm-sw-viewer-menu__label')
|
||||
check('SWViewerMenu rows / label (reference): 14px 400 and 12px 500, no outline', refViewerItem.length > 0 && refViewerItem.every(r => r.size === '14px' && r.weight === '400' && r.outline === 'none')
|
||||
&& refViewerLabel.length > 0 && refViewerLabel.every(r => r.size === '12px' && r.weight === '500' && r.outline === 'none'), { refViewerItem: refViewerItem.slice(0, 2), refViewerLabel })
|
||||
await page.keyboard.press('Escape')
|
||||
report.reference = { more: refMore[0], documents: refDocs[0], viewerItem: refViewerItem[0], viewerLabel: refViewerLabel[0] }
|
||||
|
||||
await goto('sw-viewer--viewer-header')
|
||||
// The 1440px Story frame at full width (as the viewer suites): the header has room for every label.
|
||||
await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.waitForTimeout(200)
|
||||
const vh = page.locator('.hm-sw-viewer-header').first()
|
||||
const vNames = await styles('.hm-sw-viewer-header >> nth=0 >> :is(.hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)')
|
||||
const vNames = await styles('.hm-sw-viewer-header >> nth=0 >> :is(.hm-sw-header__document-name, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)')
|
||||
report.viewer = vNames
|
||||
const want = ['파일 열기', '샘플', '전체 보기', '레이어', '설정', '속성']
|
||||
// 2026-10-21 measured steps: at 1440 the header has room, so 전체 보기 is a header label again (Bold like the others), and
|
||||
// the file open button after the logo shows 파일 열기 on the SWHeader file-name surface (Bold + outline like the file name).
|
||||
const want = ['C0060202-001-평면및종단면도.dwg', '파일 열기', '전체 보기', '레이어', '설정', '속성']
|
||||
const labels = vNames.map(v => v.text)
|
||||
check('Viewer: file name, 파일열기 · 샘플 · 전체보기 · 레이어 · 설정 · 속성, Model · Layout are 14px 700 + 1.3px outline', want.every(w => labels.includes(w)) && labels.some(t => t.startsWith('Model')) && labels.some(t => t.startsWith('Layout')) && vNames.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), vNames)
|
||||
check('Viewer: file name, 파일 열기, 전체 보기 · 레이어 · 설정 · 속성, Model · Layout are 14px 700 + 1.3px outline', want.every(w => labels.includes(w)) && labels.some(t => t.startsWith('Model')) && labels.some(t => t.startsWith('Layout')) && vNames.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), vNames)
|
||||
check('Viewer: 샘플 is no longer a header label', !labels.includes('샘플'), labels)
|
||||
const counts = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-viewer-header__count')
|
||||
check('Viewer: tab counts keep 11px 400', counts.length > 0 && counts.every(c => c.weight === '400' && c.size === '11px'), counts)
|
||||
for (const t of ['파일 열기', '레이어']) {
|
||||
const lab = vh.locator('.hm-sw-viewer-header__label', { hasText: t }).first()
|
||||
await stableOnHover(`Viewer ${t}`, lab.locator('xpath=ancestor::button[1]'), lab)
|
||||
}
|
||||
// File open button (파일 열기, the logo menu is gone): hover keeps its label in place, size and weight.
|
||||
const openButton = vh.locator('[data-document-open]')
|
||||
await stableOnHover('Viewer 파일 열기', openButton, openButton.locator('.hm-sw-viewer-header__open-label'))
|
||||
// File-name list (샘플): rows exactly as the SWHeader file-name list rows.
|
||||
const docButton2 = vh.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
await stableOnHover('Viewer file name', docButton2, docButton2.locator('.hm-sw-header__document-name'))
|
||||
await docButton2.click(); await page.waitForTimeout(150)
|
||||
const sampleRows = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-header__document-item-name')
|
||||
report.viewerSamples = sampleRows
|
||||
check('Viewer sample list: 3 rows = SWHeader file-name list rows (weight / size / family / outline)', sampleRows.length === 3 && sampleRows.every(r => JSON.stringify(typo(r)) === JSON.stringify(typo(refDocs[0])) && !r.cut), { sampleRows, ref: refDocs[0] })
|
||||
const sampleRow = vh.locator('.hm-sw-header__document-item').first()
|
||||
await stableOnHover('Viewer sample row', sampleRow, sampleRow.locator('.hm-sw-header__document-item-name'))
|
||||
await page.keyboard.press('Escape'); await page.waitForTimeout(100)
|
||||
const lab = vh.locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).first()
|
||||
await stableOnHover('Viewer 레이어', lab.locator('xpath=ancestor::button[1]'), lab)
|
||||
const tab = vh.locator('.hm-sw-viewer-header__tab', { hasText: 'Layout' }).first()
|
||||
await stableOnHover('Viewer Layout tab', tab, tab)
|
||||
// More stays: narrow header shows the More button, it opens and lists the moved items.
|
||||
|
||||
@@ -60,32 +60,90 @@ const fonts = (page, selector, exclude = null) => page.evaluate(([selector, excl
|
||||
const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).every(family => family === expected)
|
||||
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
|
||||
|
||||
// More (2026-10-21): the header shows More only when its measured room runs out (full → icons → More). The Story's
|
||||
// 1000px header fits as icons, so the header is narrowed for this check (as text-weight does), then restored.
|
||||
async function openMore(page, area) {
|
||||
const header = page.locator(`${area} .hm-sw-viewer-header`)
|
||||
await header.evaluate(element => { element.style.width = "700px" })
|
||||
await page.locator(`${area} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
}
|
||||
const restoreWidth = (page, area) => page.locator(`${area} .hm-sw-viewer-header`).evaluate(element => { element.style.width = "" })
|
||||
|
||||
// Viewer header dropdown checked here (2026-10-21): the file-name list (.hm-sw-header__document-menu); the logo menu is gone.
|
||||
const DOC_LIST = '.hm-sw-viewer-header .hm-sw-header__document-menu'
|
||||
async function dropdownFonts(page, area, name, expected) {
|
||||
const label = expected === DEFAULT ? 'Noto Sans KR' : 'Dotum'
|
||||
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
|
||||
await page.waitForSelector(`${area} ${DOC_LIST}`)
|
||||
const list = `${area} :is(${DOC_LIST}, .hm-sw-viewer-header .hm-sw-header__document-button)` // file name + file open surfaces
|
||||
check(`${name}: 파일명 버튼 + 샘플 목록 = ${label}`, only(await fonts(page, list), expected), fmt(await fonts(page, list)))
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
// Token → painted property of each dropdown part. 2026-10-21: the logo menu keeps the SWHeader `...` menu colors (object
|
||||
// border, text, row hover tokens; its face #0e3d2f is a fixed value, checked below); the document list keeps its tokens.
|
||||
const COLOR_TOKENS = [
|
||||
['--hm-sw-header-document-menu-background', DOC_LIST, 'backgroundColor'],
|
||||
['--hm-sw-header-document-menu-border-color', DOC_LIST, 'borderTopColor'],
|
||||
['--hm-sw-header-title-color', `${DOC_LIST} .hm-sw-header__document-row:not([data-active]) .hm-sw-header__document-item-name`, 'color'],
|
||||
['--hm-sw-header-document-item-hover-background', `${DOC_LIST} .hm-sw-header__document-row:not([data-active])`, 'backgroundColor', 'hover'],
|
||||
]
|
||||
async function dropdownColors(page, def, themed) {
|
||||
// Paint of every part with its menu open (hovered first when the token is a hover state), next to the token value
|
||||
// resolved in the same scope (a probe painted with var(token), so both are in the computed rgb() form).
|
||||
const paint = async (area, scopeSelector) => {
|
||||
const result = {}
|
||||
for (const [token, part, property, hover] of COLOR_TOKENS) {
|
||||
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
|
||||
const target = page.locator(`${area} ${part}`).first()
|
||||
await target.waitFor()
|
||||
if (hover) { await target.hover(); await page.waitForTimeout(120) }
|
||||
result[token] = await target.evaluate((element, [token, property, scopeSelector]) => {
|
||||
const probe = document.createElement('span'); probe.style.color = `var(${token})`
|
||||
element.closest(scopeSelector).append(probe); const value = getComputedStyle(probe).color; probe.remove()
|
||||
return { painted: getComputedStyle(element)[property], token: value }
|
||||
}, [token, property, scopeSelector])
|
||||
await page.mouse.move(2, 2)
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const reference = await paint(def, '.hm-sw-viewer-header')
|
||||
check('기본: 샘플 목록(SWHeader 파일명 목록 토큰) 배경·글자·Border·Hover = 기본 토큰 값', Object.values(reference).every(item => item.painted === item.token), JSON.stringify(reference))
|
||||
// Themed: distinct colors set once on the outermost scope reach both menus; removed again afterwards.
|
||||
const values = ['rgb(201, 11, 21)', 'rgb(202, 12, 22)', 'rgb(203, 13, 23)', 'rgb(204, 14, 24)']
|
||||
const tokens = COLOR_TOKENS.map(item => item[0])
|
||||
await page.locator(themed).evaluate((element, [tokens, values]) => tokens.forEach((token, i) => element.style.setProperty(token, values[i])), [tokens, values])
|
||||
const themedPaint = await paint(themed, themed)
|
||||
await page.locator(themed).evaluate((element, tokens) => tokens.forEach(token => element.style.removeProperty(token)), tokens)
|
||||
check('뷰어 테마: 바깥 범위 토큰(배경·글자·Border·Hover)이 샘플 목록까지 상속', tokens.every((token, i) => themedPaint[token].painted === values[i]), JSON.stringify(themedPaint))
|
||||
}
|
||||
|
||||
// ---------- 1. Theme inheritance Story ----------
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-theme--theme-inheritance')
|
||||
const scope = name => `[data-theme-case="${name}"]`
|
||||
|
||||
// Default (no theme): header, then its sample dropdown and More.
|
||||
// Default (no theme): header, then its dropdowns and More. 2026-10-21: the header's dropdowns are the logo menu
|
||||
// (파일 열기, SWHeader `...` menu) and the file-name sample list (SWHeader file-name list); the 샘플 menu button is gone.
|
||||
const def = scope('default')
|
||||
check('기본: 단독 SWViewerHeader = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`)))
|
||||
await page.locator(`${def} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||||
await page.waitForSelector(`${def} .hm-sw-viewer-menu`)
|
||||
check('기본: Dropdown = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator(`${def} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await dropdownFonts(page, def, '기본', DEFAULT)
|
||||
await openMore(page, def)
|
||||
await page.waitForSelector(`${def} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
check('기본: More = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await restoreWidth(page, def)
|
||||
|
||||
// Viewer, themed through <SWTheme style>.
|
||||
const viewer = scope('viewer-themed')
|
||||
check('뷰어 테마: SWViewerHeader 전체 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-header`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-header`)))
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu`)
|
||||
check('뷰어 테마: Dropdown = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await dropdownFonts(page, viewer, '뷰어 테마', VERIFY)
|
||||
// Colors / border / hover of the two dropdowns: the SWHeader default token values without a theme, and a token set
|
||||
// through `style` on the outermost SW scope (here the themed <SWTheme>) reaches both menus. The colors are set only
|
||||
// for this check and removed again (the Story itself themes the font only).
|
||||
await dropdownColors(page, def, viewer)
|
||||
await openMore(page, viewer)
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
check('뷰어 테마: More = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
// A submenu of More, if any, is a nested menu inside a nested menu.
|
||||
@@ -96,6 +154,7 @@ const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${fa
|
||||
check('뷰어 테마: More 서브메뉴 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await restoreWidth(page, viewer)
|
||||
const panels = await page.locator(`${viewer} .hm-sw-viewer-panel`).count()
|
||||
check('뷰어 테마: SWViewerPanel(제목·입력·버튼·스위치·힌트) = Dotum', panels === 2 && only(await fonts(page, `${viewer} .hm-sw-viewer-panel`), VERIFY), `${panels} panels · ${fmt(await fonts(page, `${viewer} .hm-sw-viewer-panel`))}`)
|
||||
await page.screenshot({ path: path.join(out, 'viewer-themed-more.png'), clip: await page.locator(viewer).boundingBox() })
|
||||
@@ -210,7 +269,7 @@ for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
|
||||
await page.addStyleTag({ content: legacy })
|
||||
await page.waitForTimeout(150)
|
||||
const viewer = '[data-theme-case="viewer-themed"]'
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await openMore(page, viewer)
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
const more = await fonts(page, `${viewer} .hm-sw-viewer-menu`)
|
||||
const command = await fonts(page, '[data-theme-case="general-themed"] .hm-sw-footer-command')
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// 2026-10-21 Firefox counterpart of viewer-logo-document-menu.browser.mjs: the installed Firefox over WebDriver BiDi (no
|
||||
// Playwright build), real pointer clicks and key presses. Logo menu (SWHeaderMore) and file-name list
|
||||
// (SWHeaderDocumentTitle) of SWViewerHeader: open / select / Escape / outside click / focus return / never together,
|
||||
// title + divider rule, same face as the SWHeader `...` menu, responsive icon toggles and panels.
|
||||
// Usage: node packages/sw/tests/viewer-logo-document-menu-firefox.browser.mjs <evidence-dir> [storybook-url]
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { spawn } from 'node:child_process'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { runtimeRequire } from './playwright-runtime.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/viewer-logo-document-menu-qa/firefox')
|
||||
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
|
||||
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
|
||||
const VIEW = { width: 1920, height: 1100 }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const profile = path.join(os.tmpdir(), `sw-ff-viewer-menu-${process.pid}`)
|
||||
await mkdir(profile, { recursive: true })
|
||||
const port = 9300 + (process.pid % 500)
|
||||
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
|
||||
let ws
|
||||
for (let i = 0; i < 60 && !ws; i++) {
|
||||
await sleep(500)
|
||||
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
|
||||
}
|
||||
if (!ws) throw new Error('Firefox BiDi not reachable')
|
||||
let id = 0; const pending = new Map(), errors = []
|
||||
ws.onmessage = event => {
|
||||
const m = JSON.parse(event.data)
|
||||
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
|
||||
// Storybook's own UI font (sb-common-assets) is served by the dev server only, not by a static build; the Chromium
|
||||
// suites ignore such 404s too.
|
||||
else if (m.method === 'log.entryAdded' && m.params.level === 'error' && !/sb-common-assets/.test(m.params.text)) errors.push(m.params.text)
|
||||
}
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + ' -> ' + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
|
||||
const session = await send('session.new', { capabilities: {} })
|
||||
await send('session.subscribe', { events: ['log.entryAdded'] })
|
||||
// A tab of our own: in current Firefox the first tree entry can be a privileged (chrome) context that BiDi scripts
|
||||
// and viewport calls may not touch without -remote-allow-system-access.
|
||||
const context = (await send('browsingContext.create', { type: 'tab' })).context
|
||||
const viewport = async size => { try { await send('browsingContext.setViewport', { context, viewport: size }) } catch (e) { console.log('INFO setViewport:', e.message) } }
|
||||
await viewport(VIEW)
|
||||
const ev = async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text + ' :: ' + expr.slice(0, 160)); return r.result.value }
|
||||
const goto = async story => {
|
||||
await send('browsingContext.navigate', { context, url: `${base}/iframe.html?id=${story}&viewMode=story`, wait: 'complete' })
|
||||
for (let i = 0; i < 40; i++) { if (await ev(`!!document.querySelector('.hm-sw-viewer-header')`)) break; await sleep(250) }
|
||||
// Lift the Story page envelope like the Chromium suites; scroll instantly (the preview sets smooth scrolling).
|
||||
await ev(`(() => { const s = document.createElement('style'); s.textContent = '.sb-page { --sb-page-max-width: 100vw; } html { scroll-behavior: auto !important; }'; document.head.append(s); return true })()`)
|
||||
await ev('document.fonts.ready.then(() => true)'); await sleep(400)
|
||||
}
|
||||
const frames = () => ev('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))')
|
||||
const pointer = actions => send('input.performActions', { context, actions: [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }] })
|
||||
// Click the center of the first visible match (scrolled into view first), with a real pointer.
|
||||
const click = async selector => {
|
||||
const point = JSON.parse(await ev(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.getClientRects().length)
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
|
||||
await pointer([{ type: 'pointerMove', x: Math.round(point[0]), y: Math.round(point[1]), duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }])
|
||||
await frames()
|
||||
}
|
||||
const clickAt = async (x, y) => { await pointer([{ type: 'pointerMove', x, y, duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }]); await frames() }
|
||||
const KEYS = { ArrowDown: '\uE015', Escape: '\uE00C', Enter: '\uE007' }
|
||||
const press = async key => { await send('input.performActions', { context, actions: [{ type: 'key', id: 'kbd', actions: [{ type: 'keyDown', value: KEYS[key] }, { type: 'keyUp', value: KEYS[key] }] }] }); await frames() }
|
||||
const focus = selector => ev(`(document.querySelector(${JSON.stringify(selector)}).focus(), true)`)
|
||||
const shot = async (file, selector, width, height) => {
|
||||
const clip = JSON.parse(await ev(`(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return JSON.stringify({ x: Math.max(0, r.x - 8), y: Math.max(0, r.y - 8) }) })()`))
|
||||
const result = await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip, width, height } })
|
||||
await writeFile(path.join(out, file), Buffer.from(result.data, 'base64'))
|
||||
}
|
||||
|
||||
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : String(JSON.stringify(d) ?? '').slice(0, 600)) }
|
||||
const state = () => ev(`(() => {
|
||||
const header = document.querySelector('.hm-sw-viewer-header'), logo = header.querySelector('button.hm-sw-viewer-header__brand')
|
||||
const menu = header.querySelector('.hm-sw-header__more-menu'), list = header.querySelector('.hm-sw-header__document-menu')
|
||||
const name = header.querySelector('.hm-sw-viewer-header__document .hm-sw-header__document-button'), active = document.activeElement
|
||||
return JSON.stringify({ logoExpanded: logo?.getAttribute('aria-expanded'), menu: !!menu, list: !!list, nameExpanded: name?.getAttribute('aria-expanded'),
|
||||
name: name?.textContent.trim(), focus: active === logo ? 'logo' : active === name ? 'name' : (active?.textContent ?? '').trim(),
|
||||
log: document.querySelector('[data-callback-log]')?.textContent ?? '' })
|
||||
})()`).then(JSON.parse)
|
||||
// Face of an open menu and its first shown row, split into colors and the other metrics (2026-10-21 style unification:
|
||||
// the logo menu and the document list take the metrics of the viewer dropdown, SWViewerMenu, and keep their colors).
|
||||
const FACE = `(menu, itemSelector) => { const s = getComputedStyle(menu), item = [...menu.querySelectorAll(itemSelector)].find(e => e.offsetParent), i = getComputedStyle(item)
|
||||
const m = menu.getBoundingClientRect(), b = item.getBoundingClientRect()
|
||||
return { colors: { background: s.backgroundColor, border: s.borderTopColor, shadow: s.boxShadow, item: i.color },
|
||||
metrics: { border: s.borderTopWidth + ' ' + s.borderTopStyle, radius: s.borderTopLeftRadius, padding: s.paddingTop + ' ' + s.paddingLeft, minWidth: s.minWidth, font: s.fontSize + '/' + s.lineHeight + ' ' + s.fontWeight,
|
||||
itemHeight: Math.round(b.height), itemInset: Math.round(b.left - m.left), itemPadding: i.paddingLeft + ' ' + i.paddingRight, itemGap: i.columnGap, itemRadius: i.borderTopLeftRadius,
|
||||
itemFont: i.fontFamily.split(',')[0] + ' ' + i.fontSize + '/' + i.lineHeight + ' ' + i.fontWeight } } }`
|
||||
const faceOf = (menuSelector, itemSelector) => ev(`JSON.stringify((${FACE})(document.querySelector(${JSON.stringify(menuSelector)}), ${JSON.stringify(itemSelector)}))`).then(JSON.parse)
|
||||
// Scrolled into view first (the 520 row sits at the bottom of the Story; its list with Open reaches below the fold).
|
||||
const onTop = selector => ev(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ block: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height - 6)) })()`)
|
||||
|
||||
try {
|
||||
console.log('firefox', (await ev('navigator.userAgent')))
|
||||
// ---------- SWHeader `...` menu reference ----------
|
||||
await goto('sw-viewer--compare-with-sw-header')
|
||||
await click('.hm-sw-header .hm-sw-header__more-trigger')
|
||||
const reference = await faceOf('.hm-sw-header .hm-sw-header__more-menu', '.hm-sw-header__more-item')
|
||||
const BOX = `element => { const s = getComputedStyle(element); return [s.borderTopWidth, s.borderTopStyle, s.borderTopColor, s.borderTopLeftRadius, s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor, s.boxShadow].join(' | ') }`
|
||||
const refTrigger = await ev(`(${BOX})(document.querySelector('.hm-sw-header .hm-sw-header__more-trigger'))`)
|
||||
check('SWHeader ... menu opens (reference colors)', !!reference.colors.background, reference)
|
||||
await press('Escape')
|
||||
check('SWHeader ... menu Escape closes, focus back on ...', await ev(`!document.querySelector('.hm-sw-header .hm-sw-header__more-menu') && document.activeElement?.classList.contains('hm-sw-header__more-trigger')`))
|
||||
|
||||
// ---------- SWViewerMenu reference metrics ("상단 메뉴 + Dropdown") ----------
|
||||
await goto('sw-viewer--dropdowns')
|
||||
await click('.hm-sw-viewer-header [aria-haspopup="menu"]')
|
||||
const viewerMenu = await faceOf('.hm-sw-viewer-menu', '.hm-sw-viewer-menu__item')
|
||||
check('SWViewerMenu reference opens (36px rows, 4px padding)', viewerMenu.metrics.itemHeight === 36 && viewerMenu.metrics.padding === '4px 4px', viewerMenu.metrics)
|
||||
await press('Escape')
|
||||
|
||||
// ---------- File open button (the logo menu is gone) ----------
|
||||
await goto('sw-viewer--viewer-header')
|
||||
await click('.hm-sw-viewer-header .hm-sw-viewer-header__brand')
|
||||
let s = await state()
|
||||
check('logo opens no dropdown', !s.menu && !(await ev(`!!document.querySelector('[role=menu]')`)), s)
|
||||
const open = JSON.parse(await ev(`(() => { const b = document.querySelector('.hm-sw-viewer-header [data-document-open]'), w = b.parentElement
|
||||
return JSON.stringify({ surface: b.classList.contains('hm-sw-header__document-button') && w.matches('.hm-sw-header__document[data-tool]'), afterLogo: w.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
|
||||
label: b.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icon: b.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }) })()`))
|
||||
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', open.surface && open.afterLogo && open.label === '파일 열기' && open.icon === 1, open)
|
||||
const iconBox = await ev(`(${BOX})(document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__open-icon'))`)
|
||||
// 2026-10-21 user request: no border, transparent, no shadow.
|
||||
check('file open icon: no border, transparent, no shadow', iconBox.startsWith('0px | none') && iconBox.includes('rgba(0, 0, 0, 0)') && iconBox.endsWith('none'), iconBox)
|
||||
await click('.hm-sw-viewer-header [data-document-open]')
|
||||
s = await state()
|
||||
check('file open button runs the existing file open', s.log.endsWith('파일 선택창 (미연결) → document/open'), s)
|
||||
await shot('firefox-header-open.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 60)
|
||||
|
||||
// ---------- Document list ----------
|
||||
// The logo cap (and its slanted tail) has the same pixels with the list open, as in SWHeader. Left out: the bottom
|
||||
// 2px, which the list's own drop shadow (0 2px 4px: blur 4 − offset 2) reaches above the list (Firefox 2px, Chromium 1px).
|
||||
const capClip = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__brand').getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, width: r.width + 18, height: r.height - 2 }) })()`))
|
||||
const capShot = async () => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...capClip } })).data
|
||||
const n = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
|
||||
await pointer([{ type: 'pointerMove', x: Math.round(n[0]), y: Math.round(n[1]), duration: 0 }]); await frames()
|
||||
const capClosed = await capShot()
|
||||
await click('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
check('open list never covers the logo cap (pixels identical)', capClosed === await capShot())
|
||||
s = await state()
|
||||
const list = JSON.parse(await ev(`(() => { const m = document.querySelector('.hm-sw-viewer-header .hm-sw-header__document-menu'), n = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
const r = m.getBoundingClientRect(), b = n.getBoundingClientRect(), row = m.querySelector('.hm-sw-header__document-row').getBoundingClientRect()
|
||||
return JSON.stringify({ rows: [...m.querySelectorAll('.hm-sw-header__document-item-name')].map(e => e.textContent.trim()), actions: m.querySelectorAll('.hm-sw-header__document-footer, .hm-sw-header__document-action').length,
|
||||
active: m.querySelector('[data-active=true] .hm-sw-header__document-item-name')?.textContent, width: r.width, button: b.width, left: r.left - b.left, right: r.right - b.right, row: Math.round(row.height), gap: Math.round(r.y - b.bottom), background: getComputedStyle(m).backgroundColor }) })()`))
|
||||
check('file name click opens the 3 samples only (no Open / + New), SWHeader face', s.list && s.nameExpanded === 'true' && list.rows.length === 3 && list.actions === 0 && list.row === 32 && Math.abs(list.gap) <= 1
|
||||
&& list.background === 'rgb(16, 49, 39)' && list.active === 'C0060202-001-평면및종단면도.dwg', list)
|
||||
check('document list >= file-name button (outer) and >= 312px, covers it', list.width + 0.5 >= list.button && list.width >= 311.5 && list.left <= 0.5 && list.right >= -0.5, list)
|
||||
check('list painted on top (not cut)', await onTop('.hm-sw-viewer-header .hm-sw-header__document-menu'))
|
||||
await shot('firefox-document-list.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 200)
|
||||
await click('.hm-sw-viewer-header .hm-sw-header__document-row:last-child .hm-sw-header__document-item')
|
||||
s = await state()
|
||||
check('sample select loads it, renames, closes, focus on the file name', s.name === 'simple.dxf' && s.log.includes('samples/sample-dxf') && !s.list && s.focus === 'name', s)
|
||||
await press('ArrowDown')
|
||||
s = await state()
|
||||
check('ArrowDown opens the list on its first row', s.list && s.focus === 'BasicSample.dwg', s)
|
||||
await press('Escape')
|
||||
s = await state()
|
||||
check('Escape closes the list and refocuses the file name', !s.list && s.focus === 'name', s)
|
||||
|
||||
// ---------- Above open panels ----------
|
||||
await goto('sw-theme--theme-inheritance')
|
||||
const themedArea = '[data-theme-case="viewer-themed"] .hm-sw-viewer-header'
|
||||
await click(`${themedArea} .hm-sw-viewer-header__document .hm-sw-header__document-button`)
|
||||
check('document list above an open SWViewerPanel', await onTop(`${themedArea} .hm-sw-header__document-menu`))
|
||||
await press('Escape')
|
||||
|
||||
// ---------- Responsive ----------
|
||||
await goto('sw-viewer--responsive')
|
||||
const report = JSON.parse(await ev(`JSON.stringify([...document.querySelectorAll('[data-width] .hm-sw-viewer-header')].map(h => { const rect = s => h.querySelector(s).getBoundingClientRect()
|
||||
const toggles = [...h.querySelectorAll('.hm-sw-viewer-header__button[data-kind=toggle]')], object = h.querySelector('.hm-sw-viewer-header__object'), open = h.querySelector('[data-document-open]')
|
||||
return { width: Math.round(h.getBoundingClientRect().width), overflow: h.scrollWidth - h.clientWidth, objectOverflow: object.scrollWidth - object.clientWidth,
|
||||
toggles: toggles.map(t => [t.getAttribute('aria-label') ?? t.textContent.trim(), !!t.querySelector('.hm-sw-viewer-header__label'), !!t.title]),
|
||||
fit: [...object.querySelectorAll('button')].some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === '전체 보기'), more: !!h.querySelector('.hm-sw-viewer-header__more'),
|
||||
open: [!!open.querySelector('.hm-sw-viewer-header__open-label'), open.title, open.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect().width],
|
||||
order: [rect('.hm-sw-viewer-header__brand').right, open.getBoundingClientRect().left, open.getBoundingClientRect().right, rect('.hm-sw-viewer-header__document').left, rect('.hm-sw-viewer-header__document').right, rect('.hm-sw-viewer-header__object').left] } }))`))
|
||||
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.length === 4 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow]))
|
||||
check('layer / settings / properties always in the header with tooltips', report.every(r => r.toggles.map(t => t[0]).join() === '레이어,설정,속성' && r.toggles.every(t => t[2])), report.map(r => [r.width, r.toggles]))
|
||||
// 2026-10-21 measured steps: with the file open button this Story fits fully at 1440, as icons at 1100, More at 860 / 520.
|
||||
check('1440 full (파일 열기 + 전체 보기 labels, no More); 1100 icons only, no More; 860 / 520 icons + More', report[0].fit && !report[0].more && report[0].open[0] && report[0].toggles.every(t => t[1])
|
||||
&& !report[1].more && report[1].toggles.every(t => !t[1]) && report.slice(1).every(r => !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34) && report[2].more && report[3].more,
|
||||
report.map(r => [r.width, r.fit, r.more, r.open, r.toggles.map(t => t[1])]))
|
||||
check('logo, file open, file name and toolbar never overlap', report.every(r => r.order[0] <= r.order[1] + 2.5 && r.order[2] <= r.order[3] + 0.5 && r.order[4] <= r.order[5] + 0.5), report.map(r => [r.width, r.order]))
|
||||
await click('[data-width="520"] .hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
check('520: document list shown inside the header width', await onTop('[data-width="520"] .hm-sw-header__document-menu') && await ev(`(() => { const m = document.querySelector('[data-width="520"] .hm-sw-header__document-menu').getBoundingClientRect(), h = document.querySelector('[data-width="520"] .hm-sw-viewer-header').getBoundingClientRect(); return m.x >= h.x - 2 && m.right <= h.right })()`))
|
||||
await press('Escape')
|
||||
await click('[data-width="520"] [data-document-open]')
|
||||
check('520: icon-only file open runs the file open', (await state()).log !== undefined && await ev(`document.querySelector('[data-callback-log]').textContent.endsWith('document/open')`))
|
||||
|
||||
// ---------- Narrow screen: icon toggles open the panels ----------
|
||||
await viewport({ width: 820, height: 900 })
|
||||
await goto('sw-viewer--viewer-header')
|
||||
const panels = []
|
||||
for (const [name, dialog] of [['레이어', '레이어 ('], ['설정', '옵션'], ['속성', '속성']]) {
|
||||
const selector = `.hm-sw-viewer-header .hm-sw-viewer-header__button[aria-label="${name}"]`
|
||||
await click(selector)
|
||||
const after = JSON.parse(await ev(`(() => { const t = document.querySelector(${JSON.stringify(selector)}); return JSON.stringify({ pressed: t.getAttribute('aria-pressed'), color: getComputedStyle(t).color, title: t.title,
|
||||
open: [...document.querySelectorAll('[role=dialog]')].some(d => (d.getAttribute('aria-label') ?? d.textContent).includes(${JSON.stringify(dialog)}) || document.getElementById(d.getAttribute('aria-labelledby'))?.textContent.includes(${JSON.stringify(dialog)})) }) })()`))
|
||||
await click(selector)
|
||||
after.closed = await ev(`document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-pressed') === 'false'`)
|
||||
panels.push({ name, ...after })
|
||||
}
|
||||
const width = await ev(`Math.round(document.querySelector('.hm-sw-viewer-header').getBoundingClientRect().width)`)
|
||||
check(`narrow header (${width}px): icon toggles open / close panels, pressed #FFC600, tooltip`, width < 900 && panels.every(p => p.pressed === 'true' && p.color === 'rgb(255, 198, 0)' && p.title && p.open && p.closed), panels)
|
||||
await shot('firefox-viewer-narrow.png', '.hm-sw-viewer-header', 820, 60)
|
||||
} finally {
|
||||
check('no console errors', errors.length === 0, errors.slice(0, 5))
|
||||
const failed = checks.filter(c => !c.ok).length
|
||||
await writeFile(path.join(out, 'viewer-logo-document-menu-firefox.json'), JSON.stringify({ base, checks, errors }, null, 2))
|
||||
console.log(`${checks.length - failed}/${checks.length} PASS (firefox)`)
|
||||
try { await send('session.end', {}) } catch {}
|
||||
ws.close(); proc.kill()
|
||||
await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {})
|
||||
process.exitCode = failed ? 1 : 0
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// SW viewer header file open button + document list (2026-10-21).
|
||||
// The logo cap opens the SWHeader `...` menu (SWHeaderMore) with Open; the file name opens the SWHeader document list
|
||||
// (SWHeaderDocumentTitle, no + New / Open) with the samples. Never open together. Panel toggles stay in the header
|
||||
// (icon only below 900px), 전체 보기 lives in More only.
|
||||
// Usage: node packages/sw/tests/viewer-logo-document-menu.browser.mjs <storybook-url> <out-dir>
|
||||
// e.g. node packages/sw/tests/viewer-logo-document-menu.browser.mjs http://127.0.0.1:6024 docs/sw/viewer-logo-menu-qa
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { runtimeRequire } from './playwright-runtime.mjs'
|
||||
|
||||
const require = runtimeRequire()
|
||||
const { chromium } = require('playwright')
|
||||
const [storybook, outArg] = process.argv.slice(2)
|
||||
if (!storybook) throw new Error('Pass the Storybook URL')
|
||||
const out = path.resolve(outArg ?? 'docs/sw/viewer-logo-menu-qa')
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const results = []
|
||||
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${typeof detail === 'string' ? detail : JSON.stringify(detail)}` : ''}`) }
|
||||
const errors = []
|
||||
const story = id => `${storybook}/iframe.html?id=${id}&viewMode=story`
|
||||
const newPage = async (width = 1920, height = 1000) => {
|
||||
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
|
||||
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
|
||||
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
|
||||
return page
|
||||
}
|
||||
const open = async (page, id) => {
|
||||
await page.goto(story(id))
|
||||
await page.waitForSelector('.hm-sw-viewer-header')
|
||||
await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
const box = async locator => locator.boundingBox()
|
||||
const log = page => page.locator('[data-callback-log]').first().innerText()
|
||||
// Not cut by a scroller or covered: the element at the center of the popup is the popup itself.
|
||||
const onTop = locator => locator.evaluate(element => { element.scrollIntoView({ block: 'nearest' }); const r = element.getBoundingClientRect(); return element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height - 6)) })
|
||||
const focused = page => page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? document.activeElement?.textContent?.trim())
|
||||
// Menu face of an open menu, split into colors and the other metrics (2026-10-21 style unification: the logo menu and
|
||||
// the document list take the metrics of the viewer dropdown, SWViewerMenu, and keep their own colors).
|
||||
const face = (locator, itemSelector, labelSelector, separatorSelector, iconSelector, textSelector) => locator.evaluate((menu, [itemSelector, labelSelector, separatorSelector, iconSelector, textSelector]) => {
|
||||
// First item actually shown (SWHeader hides the items of wider tiers with CSS).
|
||||
const s = getComputedStyle(menu), item = [...menu.querySelectorAll(itemSelector)].find(entry => entry.offsetParent !== null), i = getComputedStyle(item)
|
||||
const label = labelSelector && menu.querySelector(labelSelector), separator = separatorSelector && menu.querySelector(separatorSelector)
|
||||
const m = menu.getBoundingClientRect(), b = item.getBoundingClientRect()
|
||||
return {
|
||||
colors: { background: s.backgroundColor, border: s.borderTopColor, shadow: s.boxShadow, item: i.color, label: label ? getComputedStyle(label).color : null },
|
||||
metrics: {
|
||||
border: `${s.borderTopWidth} ${s.borderTopStyle}`, radius: s.borderTopLeftRadius, padding: s.padding, minWidth: s.minWidth, font: `${s.fontSize}/${s.lineHeight} ${s.fontWeight}`,
|
||||
itemHeight: Math.round(b.height), itemInset: Math.round(b.left - m.left), itemPadding: `${i.paddingLeft} ${i.paddingRight}`, itemGap: i.columnGap, itemRadius: i.borderTopLeftRadius,
|
||||
itemFont: `${i.fontFamily.split(',')[0]} ${i.fontSize}/${i.lineHeight} ${i.fontWeight}`, itemOutline: i.textShadow,
|
||||
label: label ? (l => `${l.padding} ${l.fontSize}/${l.lineHeight} ${l.fontWeight}`)(getComputedStyle(label)) : null,
|
||||
separator: separator ? (x => `${x.height} ${x.margin}`)(getComputedStyle(separator)) : null,
|
||||
// Icon slot of the first row (2026-10-21 logo menu icon): size and left offset, then where the text starts.
|
||||
icon: iconSelector && item.querySelector(iconSelector) ? (r => `${Math.round(r.width)}x${Math.round(r.height)} @${Math.round(r.left - b.left)}`)(item.querySelector(iconSelector).getBoundingClientRect()) : null,
|
||||
textLeft: textSelector && item.querySelector(textSelector) ? Math.round(item.querySelector(textSelector).getBoundingClientRect().left - b.left) : null,
|
||||
},
|
||||
}
|
||||
}, [itemSelector, labelSelector, separatorSelector, iconSelector, textSelector])
|
||||
const MORE_ITEM = '.hm-sw-header__more-item', VIEWER_ITEM = '.hm-sw-viewer-menu__item'
|
||||
|
||||
// ---------- References: SWViewerMenu metrics ("상단 메뉴 + Dropdown") and the SWHeader `...` menu (colors, unchanged) ----------
|
||||
let reference, viewerMenu, moreMenu, refTrigger
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-viewer--dropdowns')
|
||||
const trigger = page.getByRole('button', { name: '도면' })
|
||||
await trigger.click()
|
||||
const dropdown = page.getByRole('menu', { name: '도면', exact: true })
|
||||
viewerMenu = await face(dropdown, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text')
|
||||
const [d, t] = [await box(dropdown), await box(trigger)]
|
||||
viewerMenu.place = { dy: Math.round((d.y - t.y - t.height) * 10) / 10, dx: Math.round((d.x - t.x) * 10) / 10 }
|
||||
check('reference SWViewerMenu: 36px rows, 4px padding / radius, 14px 400, 4px under its trigger', viewerMenu.metrics.itemHeight === 36 && viewerMenu.metrics.padding === '4px' && viewerMenu.metrics.radius === '4px'
|
||||
&& viewerMenu.metrics.font === '14px/20px 400' && viewerMenu.place.dy === 4 && viewerMenu.place.dx === 0, viewerMenu)
|
||||
await page.close()
|
||||
}
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-viewer--compare-with-sw-header')
|
||||
const header = page.locator('.hm-sw-header').first()
|
||||
const more = header.getByRole('button', { name: '더보기' })
|
||||
await more.click()
|
||||
const menu = header.getByRole('menu', { name: '추가 도구' })
|
||||
reference = await face(menu, MORE_ITEM)
|
||||
refTrigger = await more.evaluate(element => { const s = getComputedStyle(element); return `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}` })
|
||||
const responsive = await newPage(1920, 1500)
|
||||
await open(responsive, 'sw-viewer--responsive')
|
||||
await responsive.locator('[data-width="520"] .hm-sw-viewer-header').getByRole('button', { name: '추가 도구' }).click()
|
||||
const more520 = responsive.getByRole('menu', { name: '추가 도구' })
|
||||
moreMenu = await face(more520, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text')
|
||||
const moreRow = more520.locator(VIEWER_ITEM).first()
|
||||
await moreRow.hover()
|
||||
await responsive.waitForTimeout(120)
|
||||
moreMenu.hover = await moreRow.evaluate(element => `${getComputedStyle(element).backgroundColor} / ${getComputedStyle(element).color}`)
|
||||
// 2026-10-21 final colors: the SWHeader `...` menu (measured above as `reference`, normal SW mode): face, border,
|
||||
// shadow, text; its row hover (white 8%, text unchanged) is checked on the logo menu below.
|
||||
const { label: _refLabel, ...refColors } = reference.colors, { label: moreLabel, ...moreColors } = moreMenu.colors
|
||||
check('More (520px) colors = SWHeader ... menu (face, border, shadow, text); caption = text color', JSON.stringify(moreColors) === JSON.stringify(refColors) && moreLabel === moreColors.item && moreMenu.hover === `rgba(255, 255, 255, 0.08) / ${moreColors.item}`, { more: moreMenu.colors, hover: moreMenu.hover, swHeader: reference.colors })
|
||||
check('header dropdown (상단 메뉴 + Dropdown) = More face and colors', JSON.stringify(viewerMenu.colors) === JSON.stringify(moreMenu.colors), { dropdown: viewerMenu.colors, more: moreMenu.colors })
|
||||
await responsive.close()
|
||||
const [menuBox, triggerBox] = [await box(menu), await box(more)]
|
||||
check('SWHeader ... menu still right-aligned under its trigger (no title, no divider)', Math.abs(menuBox.x + menuBox.width - (triggerBox.x + triggerBox.width)) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height
|
||||
&& (await menu.locator('.hm-sw-header__more-title, .hm-sw-header__more-separator').count()) === 0)
|
||||
await page.keyboard.press('Escape')
|
||||
check('SWHeader ... menu Escape closes and refocuses ...', (await header.getByRole('menu').count()) === 0 && (await focused(page)) === '더보기')
|
||||
// The normal file name keeps + New / Open.
|
||||
await header.locator('.hm-sw-header__document-button').click().catch(() => {})
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Viewer header: file open button + document list ----------
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const logo = header.locator('.hm-sw-viewer-header__brand')
|
||||
const openButton = header.locator('[data-document-open]')
|
||||
const name = header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
// Logo: no dropdown any more (2026-10-21, the file open moved to the header button).
|
||||
await logo.click()
|
||||
check('logo opens no dropdown (no menu trigger)', (await logo.getAttribute('aria-haspopup')) === null && (await page.getByRole('menu').count()) === 0)
|
||||
// File open button: SWHeader documentOpenPlacement="header" surface right after the logo, bordered icon + label.
|
||||
const layout = await header.evaluate(element => {
|
||||
const button = element.querySelector('[data-document-open]'), wrapper = button.parentElement
|
||||
return { surface: button.classList.contains('hm-sw-header__document-button') && wrapper.matches('.hm-sw-header__document[data-tool]'), afterLogo: wrapper.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
|
||||
beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }
|
||||
})
|
||||
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.icons === 1, layout)
|
||||
const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(); return { style: `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}`, size: [Math.round(r.width), Math.round(r.height)] } })
|
||||
// 2026-10-21 user request: the icon has no border and no face (transparent); 34px slot kept.
|
||||
check('file open icon: no border, transparent, no shadow; 34px slot', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '34,34', iconBox)
|
||||
// File-name area (.hm-sw-viewer-header__document): no left padding, right after the file open button; the file-name
|
||||
// button inside keeps its own padding (user decision 2026-10-21).
|
||||
const nameStart = await header.evaluate(element => { const d = element.querySelector('.hm-sw-viewer-header__document'); return [getComputedStyle(d).paddingLeft, Math.round(d.getBoundingClientRect().left - element.querySelector('[data-document-open]').getBoundingClientRect().right)] })
|
||||
check('file name area: no left padding, right after the file open button', nameStart[0] === '0px' && Math.abs(nameStart[1]) <= 2, nameStart)
|
||||
check('no 샘플 header button, no + New', (await header.getByRole('button', { name: '샘플' }).count()) === 0 && (await page.getByText('+ New').count()) === 0)
|
||||
await page.screenshot({ path: path.join(out, 'viewer-1440-idle.png'), clip: { ...(await box(header)), height: 56 } })
|
||||
await openButton.click()
|
||||
check('file open button runs the existing file open (onDocumentOpen + onCommand open/document)', (await log(page)).endsWith('파일 선택창 (미연결) → document/open'), await log(page))
|
||||
|
||||
// ---------- Document list ----------
|
||||
check('file name is the SWHeader document button', (await name.getAttribute('aria-label')) === '도면: C0060202-001-평면및종단면도.dwg' && (await name.getAttribute('aria-expanded')) === 'false')
|
||||
// The logo cap and its slanted tail have the same pixels with the list open, as in SWHeader. The cap's last pixel row
|
||||
// touches the header's bottom border, where the list's own drop shadow (0 2px 4px) starts: that row is left out.
|
||||
const cap = await box(logo), capClip = { x: cap.x, y: cap.y, width: cap.width + 18, height: cap.height - 1 }
|
||||
await name.hover()
|
||||
const capClosed = await page.screenshot({ clip: capClip })
|
||||
await name.click()
|
||||
const list = header.getByRole('group', { name: '문서 목록' })
|
||||
await list.waitFor()
|
||||
check('open list never covers the logo cap (pixels identical)', capClosed.equals(await page.screenshot({ clip: capClip })))
|
||||
const rows = await list.locator('.hm-sw-header__document-item-name').allInnerTexts()
|
||||
check('file name click opens the sample list', await list.isVisible() && await onTop(list) && JSON.stringify(rows) === JSON.stringify(['BasicSample.dwg', 'C0060202-001-평면및종단면도.dwg', 'simple.dxf']), rows)
|
||||
check('no Open and no + New below the samples', (await list.locator('.hm-sw-header__document-footer, .hm-sw-header__document-action').count()) === 0)
|
||||
check('current sample marked active', (await list.locator('[data-active="true"] .hm-sw-header__document-item-name').innerText()) === 'C0060202-001-평면및종단면도.dwg')
|
||||
const listFace = await list.evaluate(element => { const s = getComputedStyle(element); return { background: s.backgroundColor, width: Math.round(element.getBoundingClientRect().width), row: Math.round(element.querySelector('.hm-sw-header__document-row').getBoundingClientRect().height) } })
|
||||
check('SWHeader list face (32px rows, #103127, at least the SWHeader 312px)', listFace.background === 'rgb(16, 49, 39)' && listFace.width >= 312 && listFace.row === 32, listFace)
|
||||
const [listBox, nameBox] = [await box(list), await box(name)]
|
||||
check('list opens directly below the file name', Math.abs(listBox.y - (nameBox.y + nameBox.height)) <= 1, { list: listBox, name: nameBox })
|
||||
await page.screenshot({ path: path.join(out, 'viewer-document-list-open.png'), clip: { x: cap.x - 8, y: cap.y - 8, width: 640, height: 200 } })
|
||||
await list.getByRole('button', { name: 'simple.dxf' }).click()
|
||||
check('sample select loads it and renames the file', (await name.innerText()).includes('simple.dxf') && (await log(page)).includes('samples/sample-dxf'), await log(page))
|
||||
check('list closes after select, focus on the file name', (await list.count()) === 0 && await name.evaluate(element => element === document.activeElement))
|
||||
await page.keyboard.press('ArrowDown')
|
||||
check('ArrowDown opens the list on its first row', await list.isVisible() && (await focused(page)) === 'BasicSample.dwg')
|
||||
await page.keyboard.press('Escape')
|
||||
check('Escape closes the list and refocuses the file name', (await list.count()) === 0 && await name.evaluate(element => element === document.activeElement))
|
||||
await name.click()
|
||||
await page.mouse.click(900, 700)
|
||||
check('outside click closes the list', (await list.count()) === 0)
|
||||
|
||||
// ---------- File open: icon only when the header has no room (measured), never in More ----------
|
||||
const openState = width => header.evaluate(async (element, width) => {
|
||||
element.style.width = `${width}px`
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect()
|
||||
return { width, label: !!button.querySelector('.hm-sw-viewer-header__open-label'), aria: button.getAttribute('aria-label'), title: button.title, icon: [Math.round(icon.width), Math.round(icon.height)], overflow: element.scrollWidth - element.clientWidth }
|
||||
}, width)
|
||||
const sweep = []
|
||||
for (let width = 1440; width >= 400; width -= 20) sweep.push(await openState(width))
|
||||
const back = []
|
||||
for (let width = 400; width <= 1440; width += 20) back.push(await openState(width))
|
||||
const firstIcon = sweep.find(item => !item.label)
|
||||
check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)', sweep[0].label && firstIcon
|
||||
&& sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기' && item.icon.join() === '34,34'), { first: sweep[0], firstIcon })
|
||||
check('label comes back at the same widths when the header grows; no overflow', sweep.every(item => back.find(other => other.width === item.width).label === item.label) && [...sweep, ...back].every(item => item.overflow <= 0))
|
||||
await header.evaluate(element => { element.style.width = '520px' })
|
||||
await page.waitForTimeout(60)
|
||||
const more = header.getByRole('button', { name: '추가 도구' })
|
||||
await more.click()
|
||||
const moreItems = await page.getByRole('menu', { name: '추가 도구' }).locator('[role^=menuitem]').allInnerTexts()
|
||||
check('file open never moves into More', !moreItems.some(item => item.includes('파일 열기') || item === 'Open'), moreItems)
|
||||
await page.keyboard.press('Escape')
|
||||
await openButton.click()
|
||||
check('icon-only button runs the same file open', (await log(page)).endsWith('파일 선택창 (미연결) → document/open'), await log(page))
|
||||
await page.screenshot({ path: path.join(out, 'viewer-520-open-icon.png'), clip: { ...(await box(header)), height: 56 } })
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Above open panels ----------
|
||||
{
|
||||
// A panel at the top-left of the drawing area lies under the document list; the open list must be painted above it
|
||||
// (the header takes the viewer dropdown layer while it is open) and the header has no layer once it is closed.
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-theme--theme-inheritance')
|
||||
const area = page.locator('[data-theme-case="viewer-themed"]')
|
||||
const header = area.locator('.hm-sw-viewer-header')
|
||||
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
|
||||
const list = header.getByRole('group', { name: '문서 목록' })
|
||||
check('document list above an open SWViewerPanel', await onTop(list), await header.evaluate(element => getComputedStyle(element).zIndex))
|
||||
await page.keyboard.press('Escape')
|
||||
check('closed list drops back (no raised layer at rest), cap over the open button and file name', await header.evaluate(element => getComputedStyle(element).zIndex === 'auto'
|
||||
&& getComputedStyle(element.querySelector('.hm-sw-viewer-header__brand')).zIndex === '2' && getComputedStyle(element.querySelector('.hm-sw-header__document')).zIndex === '1'))
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Responsive ----------
|
||||
{
|
||||
// Tall enough that the 520 row and its popups are inside the viewport (the Story page does not scroll them in).
|
||||
const page = await newPage(1920, 1500)
|
||||
await open(page, 'sw-viewer--responsive')
|
||||
const headers = page.locator('[data-width] .hm-sw-viewer-header')
|
||||
const report = []
|
||||
for (const [index, width] of [1440, 1100, 860, 520].entries()) {
|
||||
const header = headers.nth(index)
|
||||
const info = await header.evaluate(element => {
|
||||
const rect = selector => element.querySelector(selector)?.getBoundingClientRect()
|
||||
const toggles = [...element.querySelectorAll('.hm-sw-viewer-header__button[data-kind=toggle]')]
|
||||
return {
|
||||
overflow: element.scrollWidth - element.clientWidth,
|
||||
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
|
||||
toggles: toggles.map(item => ({ name: item.getAttribute('aria-label') ?? item.textContent.trim(), label: !!item.querySelector('.hm-sw-viewer-header__label'), title: item.title })),
|
||||
fitShown: [...element.querySelectorAll('.hm-sw-viewer-header__object button')].some(item => (item.getAttribute('aria-label') ?? item.textContent.trim()) === '전체 보기'),
|
||||
more: !!element.querySelector('.hm-sw-viewer-header__more'),
|
||||
logoRight: rect('.hm-sw-viewer-header__brand')?.right, docLeft: rect('.hm-sw-viewer-header__document')?.left,
|
||||
docRight: rect('.hm-sw-viewer-header__document')?.right, objectLeft: rect('.hm-sw-viewer-header__object')?.left,
|
||||
}
|
||||
})
|
||||
report.push({ width, ...info })
|
||||
const b = await box(header)
|
||||
await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: b.x, y: b.y, width: b.width, height: b.height } })
|
||||
}
|
||||
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), report.map(item => [item.width, item.overflow, item.objectOverflow]))
|
||||
check('layer / settings / properties always in the header', report.every(item => JSON.stringify(item.toggles.map(toggle => toggle.name)) === '["레이어","설정","속성"]'), report.map(item => item.toggles.map(toggle => toggle.name)))
|
||||
// 2026-10-21 measured steps: full → icons only → More, by the measured room (no fixed width). With the file open button
|
||||
// after the logo, this Story's content fits fully at 1440, as icons at 1100, and needs More at 860 / 520.
|
||||
check('1440: full items with labels (전체 보기 shown), no More; 1100: icons only, no More', report.slice(0, 1).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label))
|
||||
&& !report[1].more && report[1].fitShown && report[1].toggles.every(toggle => !toggle.label && toggle.title), report.map(item => [item.width, item.fitShown, item.more]))
|
||||
check('860: icons only (tooltips kept) + More', report[2].more && report[2].toggles.every(toggle => !toggle.label && toggle.title), report[2])
|
||||
check('520: icons only, then More for what still does not fit', report[3].more && report[3].toggles.every(toggle => !toggle.label && toggle.title), report[3])
|
||||
check('logo, file name and toolbar never overlap', report.every(item => item.logoRight <= item.docLeft + 2 + 0.5 && item.docRight <= item.objectLeft + 0.5), report.map(item => [item.width, item.logoRight, item.docLeft, item.docRight, item.objectLeft]))
|
||||
// Every item is either in the header or in More, never both and never lost.
|
||||
const more = headers.nth(3).getByRole('button', { name: '추가 도구' })
|
||||
await more.click()
|
||||
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
|
||||
const moreItems = (await moreMenu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0])
|
||||
const inHeader = await headers.nth(3).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')]
|
||||
.map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, '')))
|
||||
const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성']
|
||||
check('520: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems })
|
||||
await page.keyboard.press('Escape')
|
||||
// Icon toggle at 520 still reports pressed / commands.
|
||||
const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true })
|
||||
await icon.click()
|
||||
check('icon toggle pressed + command at 520', (await icon.getAttribute('aria-pressed')) === 'true' && (await log(page)).includes('layers=true'), await log(page))
|
||||
// Narrow: the document list stays inside the header width.
|
||||
await headers.nth(3).locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
|
||||
const narrowList = headers.nth(3).getByRole('group', { name: '문서 목록' })
|
||||
const [listBox, headerBox] = [await box(narrowList), await box(headers.nth(3))]
|
||||
check('520: document list shown, inside the header width', await onTop(narrowList) && listBox.x >= headerBox.x - 2 && listBox.x + listBox.width <= headerBox.x + headerBox.width, { list: listBox, header: headerBox })
|
||||
await page.keyboard.press('Escape')
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Narrow screen: icon toggles open the panels ----------
|
||||
{
|
||||
const page = await newPage(820, 900)
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const width = Math.round((await box(header)).width)
|
||||
const panels = []
|
||||
for (const [name, dialog] of [['레이어', /레이어 \(/], ['설정', '옵션'], ['속성', '속성']]) {
|
||||
const toggle = header.getByRole('button', { name, exact: true })
|
||||
await toggle.hover()
|
||||
const hover = await toggle.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await toggle.click()
|
||||
const panel = page.getByRole('dialog', { name: dialog })
|
||||
panels.push({ name, iconOnly: (await toggle.locator('.hm-sw-viewer-header__label').count()) === 0, title: await toggle.getAttribute('title'), hover, open: await panel.isVisible(), pressed: await toggle.getAttribute('aria-pressed'), color: await toggle.evaluate(element => getComputedStyle(element).color) })
|
||||
await toggle.click()
|
||||
panels.at(-1).closed = (await panel.count()) === 0 && (await toggle.getAttribute('aria-pressed')) === 'false'
|
||||
}
|
||||
check(`narrow header (${width}px): icon toggles with tooltip, hover, pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgb(38, 102, 81)' && item.open && item.pressed === 'true' && item.color === 'rgb(255, 198, 0)' && item.closed), panels)
|
||||
await page.screenshot({ path: path.join(out, `viewer-narrow-${width}.png`), clip: { ...(await box(header)), height: 56 } })
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Document list never narrower than the file-name button (2026-10-21) ----------
|
||||
{
|
||||
// Outer width of the open list vs the outer width of the file-name button (padding and border included), for a long
|
||||
// and a short file name at several header widths, after a file change and while resizing with the list open. The list
|
||||
// covers the button from its left edge, is at least the SWHeader 312px, and no row is cut (longer rows widen it).
|
||||
const page = await newPage(1920, 900)
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const button = header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button'), list = header.getByRole('group', { name: '문서 목록' })
|
||||
const measure = async label => {
|
||||
const result = await header.evaluate(element => {
|
||||
const b = element.querySelector('.hm-sw-viewer-header__document .hm-sw-header__document-button').getBoundingClientRect(), m = element.querySelector('.hm-sw-header__document-menu').getBoundingClientRect()
|
||||
const cut = [...element.querySelectorAll('.hm-sw-header__document-item-name')].filter(name => name.scrollWidth > name.clientWidth + 0.5).map(name => name.textContent)
|
||||
return { button: Math.round(b.width * 10) / 10, list: Math.round(m.width * 10) / 10, left: Math.round((m.left - b.left) * 10) / 10, right: Math.round((m.right - b.right) * 10) / 10, cut }
|
||||
})
|
||||
return { label, ...result }
|
||||
}
|
||||
const cases = []
|
||||
for (const file of ['long', 'short']) {
|
||||
if (file === 'short') {
|
||||
await header.evaluate(element => { element.style.width = '' })
|
||||
await button.click()
|
||||
await list.getByRole('button', { name: 'simple.dxf' }).click()
|
||||
}
|
||||
for (const width of [1440, 1100, 860, 520, 400]) {
|
||||
await header.evaluate((element, width) => { element.style.width = `${width}px` }, width)
|
||||
await page.waitForTimeout(60)
|
||||
await button.click()
|
||||
cases.push(await measure(`${file}@${width}`))
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
}
|
||||
// Resize with the list open: the minimum follows the button.
|
||||
await header.evaluate(element => { element.style.width = '1440px' })
|
||||
await button.click()
|
||||
for (const width of [1200, 900, 600]) {
|
||||
await header.evaluate((element, width) => { element.style.width = `${width}px` }, width)
|
||||
await page.waitForTimeout(60)
|
||||
cases.push(await measure(`open-resize@${width}`))
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
const bad = cases.filter(item => item.list + 0.5 < item.button || item.left > 0.5 || item.right < -0.5 || item.list < 312 - 0.5 || item.cut.length)
|
||||
check('document list >= file-name button (outer), covers it, >= 312px, no row cut — long / short name, 1440–400, resize while open', bad.length === 0, { bad, cases })
|
||||
console.log('INFO document list widths', JSON.stringify(cases.map(item => [item.label, item.button, item.list])))
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Measured steps over a width sweep (2026-10-21) ----------
|
||||
{
|
||||
// The header's own width is swept (10px steps) down and back up. Stage per width: 0 full, 1 icons only, 2+ More with
|
||||
// n items. Icons always come before More; narrower never has fewer steps; the way back gives the same state at
|
||||
// every width (no flicker, no stuck item); no overflow. A shorter file name moves every boundary to a narrower width.
|
||||
const page = await newPage(1920, 900)
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const state = width => header.evaluate(async (element, width) => {
|
||||
element.style.width = `${width}px`
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const object = element.querySelector('.hm-sw-viewer-header__object')
|
||||
const icons = [...element.querySelectorAll('.hm-sw-viewer-header__button[data-kind]:not(.hm-sw-viewer-header__more)')].filter(button => button.getAttribute('data-icon-only') !== 'always')
|
||||
const shown = [...object.querySelectorAll(':is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), .hm-sw-viewer-header__tabs, .hm-sw-viewer-header__switch-field)')].length
|
||||
const iconOnly = icons.length > 0 && icons.every(button => !button.querySelector('.hm-sw-viewer-header__label'))
|
||||
const anyIcon = icons.some(button => !button.querySelector('.hm-sw-viewer-header__label'))
|
||||
const more = !!element.querySelector('.hm-sw-viewer-header__more')
|
||||
return { width, shown, iconOnly, anyIcon, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) }
|
||||
}, width)
|
||||
const sweep = async () => {
|
||||
const down = [], up = []
|
||||
for (let width = 1440; width >= 400; width -= 10) down.push(await state(width))
|
||||
for (let width = 400; width <= 1440; width += 10) up.push(await state(width))
|
||||
return { down, up }
|
||||
}
|
||||
const boundaries = list => ({ icons: list.find(item => item.stage >= 1)?.width, more: list.find(item => item.stage === 2)?.width })
|
||||
const long = await sweep()
|
||||
check('sweep: no overflow at any width (400–1440)', [...long.down, ...long.up].every(item => item.overflow <= 0), [...long.down, ...long.up].filter(item => item.overflow > 0).map(item => item.width))
|
||||
check('sweep: icons switch together and always before More', [...long.down, ...long.up].every(item => item.anyIcon === item.iconOnly && (!item.more || item.iconOnly)))
|
||||
check('sweep: narrower never shows more (monotonic: full → icons → More, fewer items)', long.down.every((item, i) => i === 0 || (item.stage >= long.down[i - 1].stage && item.shown <= long.down[i - 1].shown)))
|
||||
check('sweep: growing back gives the same state at every width (reverse order, no stuck item)', long.down.every(item => { const back = long.up.find(other => other.width === item.width); return back.stage === item.stage && back.shown === item.shown }))
|
||||
check('sweep: 1440 full; each stage reached by measured room only', long.down[0].stage === 0 && long.down.at(-1).stage === 2, boundaries(long.down))
|
||||
// A short file name: the same content needs less room, so each step happens at a narrower width.
|
||||
await header.evaluate(element => { element.style.width = '' })
|
||||
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
|
||||
await header.getByRole('button', { name: 'simple.dxf' }).click()
|
||||
const short = await sweep()
|
||||
const [a, b] = [boundaries(long.down), boundaries(short.down)]
|
||||
check('sweep: shorter file name moves the steps to narrower widths', b.icons < a.icons && b.more < a.more, { long: a, short: b })
|
||||
await page.close()
|
||||
}
|
||||
|
||||
check('no page errors', errors.length === 0, errors.slice(0, 5).join(' | '))
|
||||
await writeFile(path.join(out, 'viewer-logo-document-menu-results.json'), JSON.stringify({ storybook, results, errors }, null, 2))
|
||||
await browser.close()
|
||||
const failed = results.filter(item => item.result === 'FAIL').length
|
||||
console.log(`${results.length - failed}/${results.length} PASS`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -17,7 +17,7 @@ const browser = await chromium.launch({ headless: true, executablePath: process.
|
||||
const results = []
|
||||
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
|
||||
const errors = []
|
||||
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story`
|
||||
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story${id === 'dropdowns' ? '&verification' : ''}`
|
||||
const newPage = async (width = 1440, height = 900) => {
|
||||
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
|
||||
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
|
||||
@@ -69,7 +69,8 @@ if (reference && reference !== '-') {
|
||||
const origin = await box(frame)
|
||||
comparison.package = await geometry(page, parts.package)
|
||||
await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } })
|
||||
await page.getByRole('button', { name: '샘플' }).click()
|
||||
// 2026-10-21: the samples are the file-name list (SWHeaderDocumentTitle), see viewer-logo-document-menu.browser.mjs.
|
||||
await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').click()
|
||||
await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } })
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('button', { name: '레이어' }).click()
|
||||
@@ -96,32 +97,34 @@ if (comparison.reference) {
|
||||
// Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen).
|
||||
const page = await newPage(1920, 1000)
|
||||
await open(page, 'viewer-header')
|
||||
// Dropdown: click opens, entries listed, Escape closes and returns focus.
|
||||
const samples = page.getByRole('button', { name: '샘플' })
|
||||
// Samples (2026-10-21): the file-name list (SWHeaderDocumentTitle) replaced the 샘플 menu button. Click opens, rows listed,
|
||||
// Escape closes and returns focus; the logo menu (Open) is checked in viewer-logo-document-menu.browser.mjs.
|
||||
const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
await samples.click()
|
||||
const menu = page.getByRole('menu', { name: '샘플' })
|
||||
check('menu opens on click', await menu.isVisible())
|
||||
check('menu has 3 entries', (await menu.getByRole('menuitem').count()) === 3)
|
||||
const menu = page.getByRole('group', { name: '문서 목록' })
|
||||
check('document list opens on click', await menu.isVisible())
|
||||
check('document list has the 3 samples', (await menu.locator('.hm-sw-header__document-item').count()) === 3)
|
||||
const menuBox = await box(menu), triggerBox = await box(samples)
|
||||
check('menu placed under its button', Math.abs(menuBox.x - triggerBox.x) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height, `menu ${Math.round(menuBox.x)},${Math.round(menuBox.y)} button ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`)
|
||||
check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-state')) === 'open')
|
||||
check('list placed under the file name', menuBox.y >= triggerBox.y + triggerBox.height - 1, `list ${Math.round(menuBox.x)},${Math.round(menuBox.y)} name ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`)
|
||||
check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-selected')) === 'true')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
check('ArrowDown moves focus in menu', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
|
||||
check('ArrowDown moves focus in the list', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
|
||||
await page.keyboard.press('Escape')
|
||||
check('Escape closes menu', !(await menu.isVisible().catch(() => false)))
|
||||
check('Escape returns focus to trigger', await samples.evaluate(element => element === document.activeElement))
|
||||
// Keyboard open + Enter select → file name changes, command logged.
|
||||
await samples.press('Enter')
|
||||
check('Enter opens with first entry focused', (await page.evaluate(() => document.activeElement?.getAttribute('role'))) === 'menuitem')
|
||||
await page.keyboard.press('End')
|
||||
check('Escape closes the list', !(await menu.isVisible().catch(() => false)))
|
||||
check('Escape returns focus to the file name', await samples.evaluate(element => element === document.activeElement))
|
||||
// Keyboard open + Enter select → file name changes, selection logged by the host.
|
||||
await samples.press('ArrowDown')
|
||||
check('ArrowDown opens with the first row focused', (await page.evaluate(() => document.activeElement?.classList.contains('hm-sw-header__document-item'))))
|
||||
// 2026-10-21: no Open below the rows (file open is the header button); ArrowUp wraps to the last sample.
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await page.keyboard.press('Enter')
|
||||
check('Enter selects entry (document changes)', (await page.locator('.hm-sw-viewer-header__document-name').innerText()) === 'simple.dxf')
|
||||
check('onCommand reports menu entry', (await log(page)).includes('samples/sample-dxf'), await log(page))
|
||||
check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').innerText()) === 'simple.dxf')
|
||||
check('onDocumentSelect reported', (await log(page)).includes('samples/sample-dxf'), await log(page))
|
||||
// Outside click closes.
|
||||
await samples.click()
|
||||
await page.mouse.click(700, 600)
|
||||
check('outside click closes menu', (await page.getByRole('menu').count()) === 0)
|
||||
check('outside click closes the list', (await page.getByRole('group', { name: '문서 목록' }).count()) === 0)
|
||||
// Tabs: arrow keys select.
|
||||
const model = page.getByRole('radio', { name: /Model/ })
|
||||
await model.focus()
|
||||
@@ -131,10 +134,10 @@ if (comparison.reference) {
|
||||
const depth = page.getByRole('switch', { name: '2.5D' })
|
||||
await depth.click()
|
||||
check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true'))
|
||||
// Disabled fit button is enabled once a document is open; hover face.
|
||||
const fit = page.getByRole('button', { name: '전체 보기' })
|
||||
await fit.hover()
|
||||
check('hover face #266651', (await fit.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
|
||||
// Hover face (2026-10-21: 전체 보기 left the header row for More, so the panel toggle is hovered).
|
||||
const hovered = page.getByRole('button', { name: '레이어', exact: true })
|
||||
await hovered.hover()
|
||||
check('hover face #266651', (await hovered.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
|
||||
// Toggle opens the panel; Escape inside closes it and focus goes back.
|
||||
const layers = page.getByRole('button', { name: '레이어', exact: true })
|
||||
await layers.click()
|
||||
@@ -272,7 +275,7 @@ if (comparison.reference) {
|
||||
await page.close()
|
||||
}
|
||||
{
|
||||
// Responsive: More collects the collapsed items, no horizontal overflow, icon-only below 560.
|
||||
// Responsive: measured steps (full → icons only → More), no horizontal overflow; More collects the collapsed items.
|
||||
const page = await newPage(1920, 1200)
|
||||
await open(page, 'responsive')
|
||||
const headers = page.locator('[data-width] .hm-sw-viewer-header')
|
||||
@@ -290,18 +293,24 @@ if (comparison.reference) {
|
||||
await page.screenshot({ path: path.join(out, `package-responsive-${width}.png`), clip: { x: box2.x, y: box2.y, width: box2.width, height: box2.height } })
|
||||
}
|
||||
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), JSON.stringify(report.map(item => [item.width, item.overflow, item.objectOverflow])))
|
||||
check('1440: no More', !report[0].more)
|
||||
check('1100: More, samples/tabs/switches moved', report[1].more && !report[1].labels.includes('샘플') && !report[1].labels.includes('2.5D') && report[1].labels.includes('레이어'))
|
||||
check('860: panel toggles moved too', report[2].more && !report[2].labels.includes('레이어'))
|
||||
check('520: 파일 열기 / 전체 보기 icon only', report[3].labels.includes('파일 열기') && !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '파일 열기' }).count()))
|
||||
const more = headers.nth(2).getByRole('button', { name: '추가 도구' })
|
||||
// 2026-10-21: 파일 열기 / 샘플 left the header (logo menu, file-name list). Measured steps instead of fixed widths:
|
||||
// full items while they fit, then icons only, then More; the panel toggles never move into More. With this Story's
|
||||
// content: 1440 / 1100 full, 860 icons only, 520 icons + More.
|
||||
// 2026-10-21: with the file open button after the logo, 1100 fits as icons (still no More) and 860 needs More.
|
||||
check('1440 / 1100: every item in the header (전체 보기 included), no More', [report[0], report[1]].every(item => !item.more && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => item.labels.includes(name))))
|
||||
check('860: icons only + More', report[2].more && !(await headers.nth(2).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
|
||||
check('520: icons only + More; panel toggles kept as icons (label → aria-label)', report[3].more && ['레이어', '설정', '속성'].every(name => report[3].labels.includes(name))
|
||||
&& !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
|
||||
check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플')))
|
||||
const more = headers.nth(3).getByRole('button', { name: '추가 도구' })
|
||||
await more.click()
|
||||
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
|
||||
const items = await moreMenu.locator('.hm-sw-viewer-menu__label, [role^=menuitem]').allInnerTexts()
|
||||
check('More sections like the reference', ['샘플 도면', '도면 공간', '화면 보기', '패널'].every(label => items.includes(label)), items.join(' | '))
|
||||
check('More face beige 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(245, 240, 229) 260')
|
||||
await moreMenu.getByRole('menuitemcheckbox', { name: '레이어' }).click()
|
||||
check('More checkbox reports command', (await log(page)).includes('layers=true'), await log(page))
|
||||
check('More sections: 도면 공간, 화면 보기 (with 전체 보기); no 패널', ['도면 공간', '화면 보기', '전체 보기'].every(label => items.includes(label)) && !items.includes('패널'), items.join(' | '))
|
||||
// 2026-10-21: the header dropdowns (More included) take the SWHeader `...` menu face (#0e3d2f); width unchanged.
|
||||
check('More face (SWHeader ... menu #0e3d2f) 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(14, 61, 47) 260')
|
||||
await moreMenu.getByRole('menuitemcheckbox', { name: '2.5D' }).click()
|
||||
check('More checkbox reports command', (await log(page)).includes('2.5d=true'), await log(page))
|
||||
await more.click()
|
||||
await page.getByRole('menuitemradio', { name: /Layout/ }).click()
|
||||
check('More radio selects tab value', (await log(page)).includes('space=layout1'))
|
||||
@@ -358,11 +367,13 @@ if (comparison.reference) {
|
||||
const header = document.querySelector('[data-logo-case] .hm-sw-viewer-header')
|
||||
const style = selector => getComputedStyle(header.querySelector(selector))
|
||||
return {
|
||||
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
|
||||
// 2026-10-21: the file name is the SWHeader document button when `documents` is given (same outline token).
|
||||
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name, .hm-sw-header__document-name').textShadow,
|
||||
tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow,
|
||||
count: style('.hm-sw-viewer-header__count').textShadow,
|
||||
icon: (() => {
|
||||
const icon = header.querySelector('.hm-sw-viewer-header__icon')
|
||||
// A lucide (stroke) icon: since 2026-10-21 the first header icon is the More dots (fill), so take the 레이어 toggle.
|
||||
const icon = header.querySelector('.hm-sw-viewer-header__button[data-kind="toggle"] .hm-sw-viewer-header__icon')
|
||||
const copy = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__outline svg *')), glyph = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__glyph svg *'))
|
||||
// lucide 24-unit icon drawn at 16px: 1px each side = 1.5 units, so the copy is 2 + 3 units.
|
||||
return { filter: getComputedStyle(icon).filter, copyStroke: copy.stroke, copyWidth: parseFloat(copy.strokeWidth), glyphStroke: glyph.stroke, glyphWidth: parseFloat(glyph.strokeWidth) }
|
||||
|
||||
@@ -78,10 +78,10 @@ function Workspace({ width = "100%" }: { width?: number | string }) {
|
||||
}
|
||||
|
||||
export const FullFooter: Story = {
|
||||
name: "전체 Footer · 실제 조합",
|
||||
name: "SW Footer",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="전체 Footer · 실제 Footer 조합" description="사용자 제공 실제 화면(2026-10-20) 구성: SWHeader + 도면 영역 + SWFooter(좌표 status, 바 안의 명령창 command, 오른쪽 명령 칸, Layout 탭). 모든 상태는 호스트(Story)가 Props로 소유하고, 컴포넌트는 Callback만 보냅니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Footer"description="사용자 제공 실제 화면(2026-10-20) 구성: SWHeader + 도면 영역 + SWFooter(좌표 status, 바 안의 명령창 command, 오른쪽 명령 칸, Layout 탭). 모든 상태는 호스트(Story)가 Props로 소유하고, 컴포넌트는 Callback만 보냅니다." />
|
||||
<StorySection title="실제 Footer 조합 (동작)" description="명령 클릭 = 선택(다시 클릭 해제)·명령 기록 추가, 분류 탭(←/→, 바꿔도 칸 영역 고정), 빈 칸 + 클릭·Hover 가이드(영상 대신 Figma 6프레임 순환), 보조기능 토글(켜짐 = 주황 테두리·글자), 명령어 전체보기·▲ = 명령창 위로 펼치기, Layout 탭 선택·+·×·더블클릭/F2 이름 변경·Delete, 명령창 Enter 실행. 좁은 폭의 ▲ 확장 목록은 「화면 폭 변화」, Hover · Active · Selected · Disabled는 이 화면의 각 부품에서 동작합니다.">
|
||||
<Workspace width={1920} />
|
||||
</StorySection>
|
||||
@@ -93,6 +93,8 @@ export const FullFooter: Story = {
|
||||
|
||||
export const ToolGroups: Story = {
|
||||
name: "도구 그룹별 표시",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="도구 그룹별 표시" description="분류(categoryId)마다 다른 명령. 명령 뒤의 칸은 빈 칸(+)이고, 마지막 명령 뒤부터 bg_vacant(어두운 면)입니다. 1440px." />
|
||||
@@ -350,6 +352,8 @@ function CustomFooter() {
|
||||
|
||||
export const CustomContent: Story = {
|
||||
name: "도구 및 메뉴 변경 예제",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="도구 및 메뉴 변경" description="공통 디자인(크기·배경·상태)은 그대로, 내용만 데이터로 변경: 분류·명령 텍스트·아이콘(lucide), slotCount 10, 빈 칸 클릭으로 명령 추가, 분류 추가, 보조기능 4개(columns 3 → 2행), 측면 버튼 문구, trailing 좌표, Layout 이름·잠금·Model 문구." />
|
||||
@@ -373,18 +377,17 @@ function ReferenceFooter() {
|
||||
}
|
||||
|
||||
export const Responsive: Story = {
|
||||
name: "화면 폭 변화",
|
||||
name: "Responsive More",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="화면 폭 변화" description="칸 영역은 바 폭으로만 정해지고 분류의 명령 수와 무관합니다(분류를 바꿔도 위치 고정). Figma에는 좁은 폭 정의가 없어 사용자 제공 실제 화면(2026-10-20)을 따릅니다: 빈 칸(+)이 먼저 줄고, 그래도 넘치는 명령은 마지막 칸의 ▲(ico_arrow)로 칸 위 패널에 펼침(▼ = 열림, Escape·바깥 클릭 닫힘). 분류 탭이 넘치면 ◀ ▶로 스크롤. 바 폭 1279px 이하에서 보조기능은 아이콘만(45 × 33). 바 안 명령창은 최소 280px." />
|
||||
<StorySection title="실제 조합 (바 안 명령창, command)" description="남는 폭 × 0.57 = 칸 영역, 나머지 = 명령창(최소 280). 1920 → 12칸(명령 10 + 빈 칸 2), 1440 → 명령 7 + ▲, 1280 → 5 + ▲, 950 → 4 + ▲·보조기능 아이콘만, 860 · 760 → ▲ 패널 여러 줄(왼쪽부터 채움). 각 폭에서 분류 탭을 눌러도 영역이 그대로입니다.">
|
||||
{[1920, 1440, 1280, 950, 860, 760].map(width => (
|
||||
<div key={width} className="sw-footer-story-width">
|
||||
<p className="sw-story-log">{width}px</p>
|
||||
<StoryPageHeader kicker={KICKER} title="Responsive More" description="칸 영역은 바 폭으로만 정해지고 분류의 명령 수와 무관합니다(분류를 바꿔도 위치 고정). Figma에는 좁은 폭 정의가 없어 사용자 제공 실제 화면(2026-10-20)을 따릅니다: 빈 칸(+)이 먼저 줄고, 그래도 넘치는 명령은 마지막 칸의 ▲(ico_arrow)로 칸 위 패널에 펼침(▼ = 열림, Escape·바깥 클릭 닫힘). 분류 탭이 넘치면 ◀ ▶로 스크롤. 바 폭 1279px 이하에서 보조기능은 아이콘만(45 × 33). 바 안 명령창은 최소 280px." />
|
||||
{[1920, 1440, 1280, 950, 860, 760].map(width => (
|
||||
<div key={width} className="sw-footer-story-width">
|
||||
<StorySection title={`${width}px`}>
|
||||
<Screen width={width} canvas={90}><ReferenceFooter /></Screen>
|
||||
</div>
|
||||
))}
|
||||
</StorySection>
|
||||
</StorySection>
|
||||
</div>
|
||||
))}
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -393,6 +396,8 @@ export const Responsive: Story = {
|
||||
|
||||
export const Parts: Story = {
|
||||
name: "개별 부품 (조합용)",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="개별 부품" description="SWFooter 없이 부품만 쓸 때는 <SWTheme> 안에 둡니다(SWFooter·SWFooterCommandLine은 루트에 hm-sw-theme이 있어 필요 없음)." />
|
||||
|
||||
@@ -12,5 +12,7 @@ export default meta
|
||||
|
||||
export const ComposedControls: StoryObj<typeof meta> = {
|
||||
name: "Dropdown",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => <ComposedDemo />,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react"
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
// Public API only, like a consuming project.
|
||||
import {
|
||||
@@ -10,14 +10,14 @@ import "@hm-design/sw/styles.css"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import "./sw-home-menu.css"
|
||||
import {
|
||||
figmaHomeMenuItemIds, figmaHomeMenuSections, figmaRecentFiles, figmaTileNodes, figmaTitleNodes, withRecentFiles,
|
||||
figmaHomeMenuItemIds, figmaHomeMenuSections, figmaRecentFiles, withRecentFiles,
|
||||
} from "./sw-home-menu-fixture"
|
||||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||||
import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png"
|
||||
|
||||
// Owner: docs/sw/figma-additional/analysis-report.md (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 / 662:67267).
|
||||
// 2026-10-19: the former SW/Home Menu Stories live under SW/Header (SW normal mode only; SW/Viewer has no logo menu).
|
||||
// "Home Menu" and "Home Menu · 단독 사용 · 조합형" are the sidebar Stories; the other Stories keep their checks reachable
|
||||
// "Home Menu" is the sidebar Story (단독 사용 hidden 2026-10-21); the other Stories keep their checks reachable
|
||||
// by URL (verification tag). 2026-10-20: Figma screenshot comparisons that no test or document used were removed;
|
||||
// the Figma originals stay in docs/sw/figma-additional/screenshots/.
|
||||
const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
@@ -85,42 +85,6 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Header width set by a range input (Storybook control only): the header measures its own width. The range ends at
|
||||
* the room the page gives (no page scroll), so the browser width and the slider both drive it.
|
||||
*/
|
||||
function ResizableHeaderWithMenu() {
|
||||
const [width, setWidth] = useState(1280)
|
||||
const [room, setRoom] = useState(1920)
|
||||
const observed = useRef<ResizeObserver | null>(null)
|
||||
const measureRef = useCallback((element: HTMLDivElement | null) => {
|
||||
observed.current?.disconnect()
|
||||
if (!element) return
|
||||
observed.current = new ResizeObserver(() => setRoom(Math.floor(element.clientWidth)))
|
||||
observed.current.observe(element)
|
||||
}, [])
|
||||
const shown = Math.min(width, room)
|
||||
return (
|
||||
<>
|
||||
<label className="sw-home-menu-story__width">
|
||||
헤더 폭 <input type="range" min={Math.min(390, room)} max={room} step={1} value={shown} onChange={event => setWidth(Number(event.target.value))} aria-label="헤더 폭" />
|
||||
<output>{shown}px</output>
|
||||
</label>
|
||||
<div ref={measureRef}><HeaderWithMenu width={shown}>{log => log}</HeaderWithMenu></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tileTable = (
|
||||
<table className="sw-home-menu-story__table">
|
||||
<thead><tr><th>섹션</th><th>제목 노드</th><th>타일 (노드)</th></tr></thead>
|
||||
<tbody>{figmaHomeMenuSections.map(section => (
|
||||
<tr key={section.id}><td>{section.title}</td><td>{figmaTitleNodes[section.id]}</td>
|
||||
<td>{section.items.map(item => `${item.label} (${figmaTileNodes[item.id]})`).join(" · ")}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
)
|
||||
|
||||
/** The sidebar Story: the logo menu of SWHeader (SW normal mode) with its Figma states, sub panel and Escape stack. */
|
||||
export const HomeMenu: Story = {
|
||||
name: "Home Menu",
|
||||
@@ -131,16 +95,12 @@ export const HomeMenu: Story = {
|
||||
<StorySection title="로고 클릭 · 열기 / 닫기 (현재 폭)" description="5개 섹션(파일 · 손상된 파일 · Display · 설정 · 궁금하면) 15개 타일. 마우스를 올리면 Hover(Variant2), 누르는 동안 Pressed(Variant3)이며 클릭 후 ON 상태는 남지 않습니다. 최근파일 타일만 하위 패널을 열고, 목록이 열린 동안 Variant3를 유지합니다.">
|
||||
<HeaderWithMenu>{log => log}</HeaderWithMenu>
|
||||
</StorySection>
|
||||
<StorySection title="반응형 · 헤더 폭 조절 · 더보기(⋯)" description="메뉴 위치와 폭은 고정 x가 아니라 Header를 측정해 정합니다(객체 수정 영역 −2px ~ 창 영역). 폭이 부족한 Header에서는 기존 더보기(…) 규칙이 그대로 동작하고, 메뉴는 가로 스크롤 없이 들어가는 섹션만 앞에서부터 보여 주며 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다. 더보기를 누르면 옮긴 섹션이 텍스트 메뉴(섹션 제목 + 기능 목록, 그룹 사이 구분선)로 아래에 열리고(Hover·Active·클릭은 타일과 같음, 최근파일은 기존 목록을 엶), 폭이 넓어지면 원래 자리로 돌아갑니다. 브라우저 폭이나 슬라이더로 확인합니다(슬라이더 최대 = 현재 페이지 폭).">
|
||||
<ResizableHeaderWithMenu />
|
||||
</StorySection>
|
||||
<StorySection title="열림 (현재 폭)" description="열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 26:10591 / 662:67267).">
|
||||
<HeaderWithMenu defaultOpen autoFocus={false} />
|
||||
</StorySection>
|
||||
<StorySection title="Default / Variant2 · Hover / Variant3 · Active" description="Figma 버튼 세트 15개(26:11355 … 26:11631)는 모두 같은 SWHomeMenuItem입니다. Variant2(Hover)는 마우스를 올려 확인하며 Figma에서 Variant3와 시각 속성이 같습니다. Variant3는 activeIds로 지정합니다.">
|
||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Default" /></SWTheme>
|
||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} activeIds={figmaHomeMenuItemIds} closeOnBackgroundClick={false} aria-label="Active" /></SWTheme>
|
||||
{tileTable}
|
||||
</StorySection>
|
||||
<StorySection title="최근파일 하위 패널 (662:67267)" description="최근파일 타일을 누르면(Variant3) 메뉴 하단 강조가 전체 2px 외곽선으로 바뀌고 그 아래에 최근파일 목록이 열립니다. 행에 마우스를 올리면 2뎁스 미리보기가 옆에 나타납니다. 다시 누르거나 Escape로 닫습니다. 최근파일이 더보기 안에 있으면 목록은 더보기 타일 아래에 열리고 더보기 타일이 Variant3가 됩니다. 목록과 미리보기 이미지는 Story 데이터입니다.">
|
||||
<div className="sw-home-menu-story__tall"><HeaderWithMenu defaultOpen autoFocus={false} defaultActiveIds={["recent"]}>{log => log}</HeaderWithMenu></div>
|
||||
@@ -254,6 +214,8 @@ function StandaloneDemo() {
|
||||
|
||||
export const HomeMenuStandalone: Story = {
|
||||
name: "Home Menu · 단독 사용 · 조합형",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SWTheme 안에서 단독 사용"
|
||||
|
||||
@@ -116,7 +116,7 @@ function DocumentOpenPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export const DocumentOpen: Story = { name: "Document Open", render: () => <DocumentOpenPage /> }
|
||||
export const DocumentOpen: Story = { name: "Document Open", tags: ["verification"], render: () => <DocumentOpenPage /> }
|
||||
|
||||
function HiddenSubControlsPage() {
|
||||
const [log, record] = useLog()
|
||||
@@ -137,4 +137,4 @@ function HiddenSubControlsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export const HiddenSubControls: Story = { name: "Hidden Sub Controls", render: () => <HiddenSubControlsPage /> }
|
||||
export const HiddenSubControls: Story = { name: "Hidden Sub Controls", tags: ["verification"], render: () => <HiddenSubControlsPage /> }
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type SWHomeMenuSectionData,
|
||||
} from "@hm-design/sw"
|
||||
import { StoryPage, StoryPageHeader } from "@/components/story-page"
|
||||
import { showVerificationControls } from "./sw-story-flags"
|
||||
|
||||
type Selection = "none" | "all" | "circle" | "expanded"
|
||||
interface OverviewProps {
|
||||
@@ -128,7 +129,7 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
|
||||
|
||||
return <StoryPage className="sw-story-overview">
|
||||
<StoryPageHeader kicker="HM DESIGN SYSTEM / SW" title="SW Header" />
|
||||
<div className="sw-overview-toolbar" aria-label="Storybook 전용 미리보기 설정">
|
||||
{showVerificationControls() && <div className="sw-overview-toolbar" aria-label="Storybook 전용 미리보기 설정">
|
||||
<label htmlFor={`${controlId}-selection`}>선택 상태</label>
|
||||
<select id={`${controlId}-selection`} value={example} onChange={event => changeExample(event.target.value as Selection)}>
|
||||
<option value="none">선택 없음</option><option value="all">전체 객체</option><option value="circle">원 선택</option><option value="expanded">전체 펼침</option>
|
||||
@@ -144,7 +145,7 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
|
||||
<label><input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} />속성 Disabled</label>
|
||||
<label><input type="checkbox" checked={emptyShape} onChange={event => setEmptyShape(event.target.checked)} />형상 Slot 비우기</label>
|
||||
</div></details>
|
||||
</div>
|
||||
</div>}
|
||||
<div ref={slotRef} className="sw-overview-slot" data-preview-slot="">
|
||||
<div className="sw-overview-stage" data-preview-stage="" role="region" tabIndex={0} aria-label="SW Header 16:9 미리보기, 작은 화면에서는 내부 스크롤"
|
||||
style={{ ...size, "--sw-preview-panel-left": `${panelLeft}px`, "--sw-preview-panel-top": barOpen ? "77px" : "49px" } as CSSProperties}>
|
||||
@@ -196,9 +197,7 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sw-overview-status">
|
||||
<span data-preview-size="">{Math.round(size.width)} × {Math.round(size.height)} · 16:9 · 내부 스크롤</span>
|
||||
<output data-preview-callback="" aria-live="polite" title={callbackLog.join(" → ")}>{callbackLog.join(" → ") || "Callback 없음"}</output>
|
||||
</div>
|
||||
{/* Callback record for the browser tests only; not shown on the page. */}
|
||||
<output hidden data-preview-callback="">{callbackLog.join(" → ")}</output>
|
||||
</StoryPage>
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
|
||||
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
|
||||
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent,
|
||||
SWRecentFiles, SWToolButton,
|
||||
SWRecentFiles,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
|
||||
@@ -185,6 +185,7 @@ function InteractiveHeader(props: Partial<SWHeaderProps>) {
|
||||
const OVERVIEW_HOME_MENU = withRecentFiles(figmaHomeMenuSections, <SWRecentFiles files={figmaRecentFiles} />)
|
||||
|
||||
export const Overview: Story = {
|
||||
name: "SW Header",
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => <SWHeaderOverview initial={{ ...figmaArgs, ...callbacks }}
|
||||
fixtures={{ documents: SAMPLE_DOCUMENTS, layers: PROPERTY_LAYERS, colors: FIGMA_COLORS, userColors: FIGMA_USER_COLORS,
|
||||
@@ -195,6 +196,8 @@ export const Overview: Story = {
|
||||
|
||||
export const InteractiveStates: Story = {
|
||||
name: "Interactive States",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
@@ -554,6 +557,8 @@ function DocumentDemo({ width, ...props }: DocumentHostProps & { width: number }
|
||||
|
||||
export const DocumentDropdown: Story = {
|
||||
name: "Document Dropdown",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
@@ -771,79 +776,14 @@ function StyleHeader({ onLog, showState, ...props }: StyleHostProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Interactive host with a visible callback log (checks that every action calls one callback once). */
|
||||
function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
return (
|
||||
<>
|
||||
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
|
||||
<MenuFrame width={width}><StyleHeader showState {...props} onLog={line => setLog(lines => [...lines.slice(-11), line])} /></MenuFrame>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
|
||||
|
||||
/**
|
||||
* Measured More (2026-10-19): the header width comes from a range input and the host adds content the breakpoints
|
||||
* do not know about (buttons next to the logo, extra tools). Parts move into More only while the real room is
|
||||
* missing and return to their own place, in their own order, once it is back. Range / checkboxes are Storybook controls.
|
||||
*/
|
||||
function MeasuredMoreDemo() {
|
||||
const [width, setWidth] = useState(1920)
|
||||
const [leading, setLeading] = useState(true)
|
||||
const [tools, setTools] = useState(true)
|
||||
const [homeMenu, setHomeMenu] = useState(true)
|
||||
const [barOpen, setBarOpen] = useState(false)
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||
return (
|
||||
<>
|
||||
<div className="sw-story-measured-controls" role="group" aria-label="실측 More 조건">
|
||||
<label>헤더 폭 <input type="range" min={390} max={1920} step={1} value={width} onChange={event => setWidth(Number(event.target.value))} aria-label="헤더 폭" /> <output data-measured-width="">{width}px</output></label>
|
||||
<label><input type="checkbox" checked={leading} onChange={event => setLeading(event.target.checked)} />로고 오른쪽 버튼(leading)</label>
|
||||
<label><input type="checkbox" checked={tools} onChange={event => setTools(event.target.checked)} />추가 도구(tools)</label>
|
||||
<label><input type="checkbox" checked={homeMenu} onChange={event => setHomeMenu(event.target.checked)} />홈 메뉴(homeMenu)</label>
|
||||
</div>
|
||||
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
|
||||
<MenuFrame width={width}>
|
||||
<StyleHeader onLog={record}
|
||||
leading={leading ? <><SWToolButton appearance="document" onClick={() => record("leading: 저장")}>저장</SWToolButton><SWToolButton appearance="document" onClick={() => record("leading: 내보내기")}>내보내기</SWToolButton></> : undefined}
|
||||
tools={tools ? <SWToolButton onClick={() => record("tools: 측정")}>측정</SWToolButton> : undefined}
|
||||
homeMenu={homeMenu ? { sections: OVERVIEW_HOME_MENU, onItemSelect: event => record(`onItemSelect(${event.item.id})`) } : undefined}
|
||||
onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)}
|
||||
onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
||||
propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }}
|
||||
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }}
|
||||
propertyPanel={{ selection: { id: "none", title: "선택된 객체 없음", kind: "none" } }} />
|
||||
</MenuFrame>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const MORE_WIDTHS = [1440, 768, 390]
|
||||
|
||||
export const ResponsiveMore: Story = {
|
||||
name: "Responsive More",
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={KICKER}
|
||||
title="Responsive More — 대표 필드 전환"
|
||||
description="좁은 헤더에서 더보기(…)의 필드 항목을 고르면, 그 필드가 객체 수정 영역의 첫 칸(레이어 필드 자리, 대표 슬롯)에 표시되고 Dropdown이 바로 아래에 열립니다. 레이어는 더보기로 이동해 다시 고를 수 있습니다. 메뉴 전환은 UI 상태일 뿐이며 데이터 Callback은 호출하지 않습니다(필드 클릭과 같은 on*Open 1회만). 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)는 그대로이고, 그 위에 실제 남은 공간을 측정해 부족한 메뉴만 더보기로 옮깁니다."
|
||||
/>
|
||||
<StorySection title="실제 사용 가능한 공간 기준 (폭 조절)" description="고정 폭만 보지 않고 객체 수정 영역에 실제로 남은 공간을 측정합니다. 로고 오른쪽 버튼·추가 도구처럼 Breakpoint가 모르는 요소가 공간을 쓰면, 표시할 수 없는 메뉴가 선·문자·치수스타일 → 그리기모드 → 속성Bar·속성창 → 색상 순(같은 단계는 오른쪽부터)으로 더보기에 들어갑니다. 공간이 다시 생기면 원래 자리·순서로 돌아옵니다. 로고 오른쪽 Open 버튼은 가장 먼저 옮겨지고 가장 늦게 돌아옵니다. Dropdown·홈 메뉴·더보기를 열고 닫아도 Header 요소의 위치와 너비는 바뀌지 않습니다.">
|
||||
<MeasuredMoreDemo />
|
||||
</StorySection>
|
||||
<StorySection title="전환 · Callback 기록 (1440)" description="… → 선스타일: 대표 칸이 선스타일(미리보기·이름·가중치·스케일)로 바뀌고 선스타일 Dropdown이 열립니다. … → 문자스타일: 선스타일 Dropdown이 닫히고 대표 칸이 문자스타일로. … → 레이어: 원래 배치로 복귀. 값은 모두 유지됩니다.">
|
||||
<StyleDemo width={1440} />
|
||||
</StorySection>
|
||||
<StorySection title="전환 · Callback 기록 (768)" description="색상도 더보기에 있는 폭입니다. … → 색상: 색상 필드와 목록.">
|
||||
<StyleDemo width={768} />
|
||||
</StorySection>
|
||||
<StorySection title="전환 · Callback 기록 (390)" description="대표 필드는 남는 폭에 맞춰 값이 말줄임되고, 목록은 헤더 안쪽으로 이동합니다.">
|
||||
<StyleDemo width={390} />
|
||||
</StorySection>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Header — 화면 너비" />
|
||||
{MORE_WIDTHS.map(width => (
|
||||
<StorySection key={width} title={`${width}px`}>
|
||||
<MenuFrame width={width}><StyleHeader /></MenuFrame>
|
||||
@@ -953,11 +893,11 @@ const BAR_FIXTURE: SWHeaderProperty[] = [
|
||||
{ id: "area", name: "면적", value: "51.5962", type: "number", groupId: "shape", min: 0 },
|
||||
]
|
||||
/* Property panel: the four Figma states with the same component and host data. */
|
||||
const PROPERTY_STATES: { example: PropertyExample; title: string; node: string; size: string }[] = [
|
||||
{ example: "none", title: "선택된 객체 없음", node: "26:11886", size: "314 × 256" },
|
||||
{ example: "all", title: "전체 객체 선택", node: "26:11823", size: "314 × 361" },
|
||||
{ example: "circle", title: "원 1개 선택", node: "26:11932", size: "314 × 658" },
|
||||
{ example: "expanded", title: "모든 항목 펼침", node: "26:12035", size: "314 × 754" },
|
||||
const PROPERTY_STATES: { example: PropertyExample; title: string }[] = [
|
||||
{ example: "none", title: "선택된 객체 없음" },
|
||||
{ example: "all", title: "전체 객체 선택" },
|
||||
{ example: "circle", title: "원 1개 선택" },
|
||||
{ example: "expanded", title: "모든 항목 펼침" },
|
||||
]
|
||||
function PropertyState({ example, shapeContent, onLog }: { example: PropertyExample; shapeContent?: SWHeaderPropertyShapeContent; onLog?: (line: string) => void }) {
|
||||
const [properties, setProperties] = useState(() => propertyFixture(example))
|
||||
@@ -971,25 +911,13 @@ function PropertyState({ example, shapeContent, onLog }: { example: PropertyExam
|
||||
/** Design check only: a real header shows one panel for the current selection.
|
||||
2 columns × 2 rows at the panels' own size; the Inner scrolls instead of shrinking or wrapping them. */
|
||||
function PropertyCompare() {
|
||||
const [sizes, setSizes] = useState<Record<string, string>>({})
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const measure = () => setSizes(Object.fromEntries(Array.from(ref.current?.querySelectorAll<HTMLElement>("[data-compare]") ?? []).map(cell => {
|
||||
const panel = cell.querySelector<HTMLElement>(".hm-sw-property")!.getBoundingClientRect()
|
||||
return [cell.dataset.compare!, `${Math.round(panel.width)} × ${Math.round(panel.height)}`]
|
||||
})))
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
ref.current?.querySelectorAll(".hm-sw-property").forEach(panel => observer.observe(panel))
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return <>
|
||||
<p className="sw-story-log" data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
|
||||
<p hidden data-property-log="">{log.join(" → ")}</p>
|
||||
<div className="sw-story-compare-scroll" data-compare-scroll="">
|
||||
<div ref={ref} className="sw-story-compare sw-story-compare--grid">
|
||||
<div className="sw-story-compare sw-story-compare--grid">
|
||||
{PROPERTY_STATES.map(state => <figure key={state.example} className="sw-story-compare__cell" data-compare={state.example}>
|
||||
<figcaption><strong>{state.title}</strong><span>Figma {state.node} · {state.size}</span><span data-measured="">측정 {sizes[state.example] ?? "-"}</span></figcaption>
|
||||
<figcaption><strong>{state.title}</strong></figcaption>
|
||||
<PropertyState example={state.example} onLog={line => setLog(lines => [...lines.slice(-7), `${state.example}: ${line}`])} />
|
||||
</figure>)}
|
||||
</div>
|
||||
@@ -998,5 +926,5 @@ function PropertyCompare() {
|
||||
}
|
||||
/** Rendered by SW/Popup › Property Panel · 4개 비교 (sw-property-panel.stories.tsx, ID sw-header--property-panel-compare). */
|
||||
export function PropertyPanelComparePage() {
|
||||
return <StoryPage className="sw-story-compare-page"><StoryPageHeader kicker={KICKER} title="속성창 4개 상태 비교" description="네 패널은 같은 SWHeaderPropertyPanel입니다. 실제 Header에서는 현재 선택 객체의 패널 하나만 표시합니다." /><PropertyCompare /></StoryPage>
|
||||
return <StoryPage className="sw-story-compare-page"><StoryPageHeader kicker={KICKER} title="속성창 4개 상태 비교" /><PropertyCompare /></StoryPage>
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
.sw-home-menu-story__toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.sw-home-menu-story__toolbar > button { padding: 4px 8px; border: 1px solid #999; border-radius: 4px; background: #fff; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.sw-home-menu-story__log { margin: 12px 0 0; padding-left: 20px; font-family: monospace; font-size: 12px; }
|
||||
.sw-home-menu-story__table { border-collapse: collapse; font-size: 13px; }
|
||||
.sw-home-menu-story__table :is(th, td) { padding: 4px 8px; border: 1px solid #ccc; text-align: left; }
|
||||
.sw-home-menu-story__tall .sw-home-menu-story__frame { height: 860px; }
|
||||
.sw-home-menu-story__width { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; }
|
||||
.sw-home-menu-story__width input { width: min(480px, 60vw); }
|
||||
|
||||
/* 2026-10-21 Storybook simplification (SW pages): no event log or long descriptions on screen; the log stays in the DOM
|
||||
for the browser tests. Same rule as sw-stories.css (this page does not load it). */
|
||||
.sb-page:has([class*="hm-sw-"]) :is(.sw-home-menu-story__log, [data-callback-log], .sb-page-description, .sb-page-section-description) { display: none; }
|
||||
@@ -191,7 +191,6 @@ function StackPage() {
|
||||
<StoryPageHeader kicker={KICKER} title="여러 Popup · Escape · 스크롤"
|
||||
description="Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한 Popup 하나만 닫습니다. Dropdown·편집 중인 Input이 처리한 Escape로는 닫히지 않습니다. 긴 콘텐츠는 본문이 스크롤됩니다." />
|
||||
{log}
|
||||
<button type="button" data-reopen="" onClick={() => setOpen(["a", "b", "c"])}>다시 열기</button>
|
||||
<SWTheme className="sw-story-popups" data-stack="">
|
||||
{open.includes("a") && <SWPopup title="Popup A" onClose={() => close("a")} data-popup="a"><SWPopupSection title="A"><SWPopupRow label="값"><SWHeaderInput value="A" aria-label="A 값" onCommit={next => record(`a=${next}`)} /></SWPopupRow></SWPopupSection></SWPopup>}
|
||||
{open.includes("b") && <SWPopup title="Popup B" onClose={() => close("b")} data-popup="b"><SWPopupSection title="B">
|
||||
|
||||
@@ -68,9 +68,12 @@
|
||||
.sw-overview-stage .hm-sw-property { position: absolute !important; left: var(--sw-preview-panel-left) !important; top: var(--sw-preview-panel-top) !important; max-height: none !important; }
|
||||
/* The document wrapper otherwise stacks below the independently positioned property Bar. */
|
||||
.sw-overview-stage .hm-sw-header__document:has(.hm-sw-header__document-menu) { z-index: 3; }
|
||||
.sw-overview-status { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; min-width: 0; }
|
||||
.sw-overview-status output { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Responsive More measured section: Storybook controls (width range, host content toggles) above the frame. */
|
||||
.sw-story-measured-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-bottom: 8px; font-size: 13px; }
|
||||
.sw-story-measured-controls label { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.sw-story-measured-controls input[type="range"] { width: min(480px, 60vw); }
|
||||
|
||||
/* 2026-10-21 Storybook simplification (SW pages): the components only. Callback logs, state dumps and long page /
|
||||
section descriptions are not shown; the log and state elements stay in the DOM because the browser tests read
|
||||
their text. Scoped to pages that render SW components, so other Story pages are unaffected. */
|
||||
.sb-page:has([class*="hm-sw-"]) :is([data-callback-log], [data-property-log], [data-style-state], .sb-page-description, .sb-page-section-description) { display: none; }
|
||||
@@ -0,0 +1,3 @@
|
||||
// Storybook demo controls the browser tests still drive (selection, list data, the viewer context menu) render only
|
||||
// when the preview URL carries `&verification`; the sidebar Stories show the real components without them.
|
||||
export const showVerificationControls = () => typeof window !== "undefined" && new URLSearchParams(window.location.search).has("verification")
|
||||
@@ -42,7 +42,7 @@ function ViewerArea({ width, panels, ...rest }: { width: number; panels?: boolea
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: panels ? ["layers", "options"] : [] })
|
||||
return (
|
||||
<Frame width={width} height={panels ? 600 : 220} {...rest}
|
||||
header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
{panels && <>
|
||||
<LayerPanel demo={demo} strategy="absolute" defaultPosition="top-start" />
|
||||
<OptionsPanel demo={demo} strategy="absolute" />
|
||||
@@ -74,6 +74,8 @@ function PortalDemo() {
|
||||
|
||||
export const ThemeInheritance: Story = {
|
||||
name: "Theme Inheritance",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW — 테마 토큰 상속"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
SWViewerBadge, SWViewerButton, SWViewerHeaderButton, SWViewerStatus, SWViewerField, SWViewerHint, SWViewerInput, SWViewerPanel, SWViewerSection, SWViewerSlider, SWViewerSwitch,
|
||||
type SWViewerCommand, type SWViewerHeaderGroup, type SWViewerPanelProps,
|
||||
type SWHeaderDocument, type SWViewerCommand, type SWViewerHeaderGroup, type SWViewerHeaderProps, type SWViewerPanelProps,
|
||||
} from "@hm-design/sw"
|
||||
import { Eye, EyeOff, FolderOpen, Info, Layers, List, MousePointerClick, RotateCcw, Scan, SlidersHorizontal, ZoomIn } from "lucide-react"
|
||||
import { Eye, EyeOff, Info, Layers, List, MousePointerClick, RotateCcw, Scan, SlidersHorizontal, ZoomIn } from "lucide-react"
|
||||
|
||||
export const SAMPLES = [
|
||||
{ id: "sample-basic", label: "기본 도면 · DWG", name: "BasicSample.dwg" },
|
||||
@@ -50,16 +50,19 @@ export function useViewerDemo({ initialDocument = null as string | null, initial
|
||||
const toggle = (id: ViewerPanelId, next = !panels[id]) => setPanels(current => ({ ...current, [id]: next }))
|
||||
const loaded = documentName !== null
|
||||
|
||||
// 2026-10-21: file open is the header button after the logo (SWHeader documentOpenPlacement="header"); the samples
|
||||
// are the file-name list (no Open / + New there).
|
||||
const openFile = () => record("파일 선택창 (미연결)")
|
||||
const documents = SAMPLES.map(sample => ({ id: sample.id, name: sample.name, title: sample.label }))
|
||||
const fileProps = {
|
||||
documents,
|
||||
activeDocumentId: SAMPLES.find(sample => sample.name === documentName)?.id,
|
||||
onDocumentSelect: (document: SWHeaderDocument) => { setDocumentName(document.name); record(`samples/${document.id}`) },
|
||||
onDocumentOpen: openFile,
|
||||
documentOpenLabel: "파일 열기",
|
||||
} satisfies Partial<SWViewerHeaderProps>
|
||||
|
||||
const groups = useMemo<SWViewerHeaderGroup[]>(() => [
|
||||
{
|
||||
id: "file", label: "파일", items: [
|
||||
{ type: "button", id: "open", label: "파일 열기", icon: <FolderOpen />, title: "파일 열기", iconOnlyBelow: 560, onSelect: () => record("파일 선택창 (미연결)") },
|
||||
{
|
||||
type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180,
|
||||
entries: SAMPLES.map(sample => ({ type: "item" as const, id: sample.id, label: sample.label, onSelect: () => setDocumentName(sample.name) })),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "space", label: "도면 공간", surface: "field", hidden: !loaded, items: [
|
||||
{ type: "tabs", id: "space", label: "모델과 배치 선택", moreLabel: "도면 공간", collapseBelow: 1180, value: space, options: SPACES, onValueChange: setSpace },
|
||||
@@ -67,16 +70,17 @@ export function useViewerDemo({ initialDocument = null as string | null, initial
|
||||
},
|
||||
{
|
||||
id: "view", label: "화면 보기", surface: "field", items: [
|
||||
{ type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, disabled: !loaded },
|
||||
// 2026-10-21: shown while the header has room, icon only next, then into More (measured, see SWViewerHeader).
|
||||
{ type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, collapseBelow: 1180, disabled: !loaded },
|
||||
{ type: "switch", id: "2.5d", label: "2.5D", checked: is25D, collapseBelow: 1180, onCheckedChange: set25D },
|
||||
{ type: "switch", id: "dark", label: "어두운 배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "panels", label: "패널", placement: "end", items: [
|
||||
{ type: "toggle", id: "layers", label: "레이어", icon: <Layers />, title: "레이어 패널 열기 / 닫기", pressed: panels.layers, collapseBelow: 900, onPressedChange: next => toggle("layers", next) },
|
||||
{ type: "toggle", id: "options", label: "설정", icon: <SlidersHorizontal />, title: "옵션 패널 열기 / 닫기", pressed: panels.options, collapseBelow: 900, onPressedChange: next => toggle("options", next) },
|
||||
{ type: "toggle", id: "properties", label: "속성", icon: <List />, title: "속성 패널 열기 / 닫기", pressed: panels.properties, collapseBelow: 900, onPressedChange: next => toggle("properties", next) },
|
||||
{ type: "toggle", id: "layers", label: "레이어", icon: <Layers />, title: "레이어 패널 열기 / 닫기", pressed: panels.layers, iconOnlyBelow: 900, onPressedChange: next => toggle("layers", next) },
|
||||
{ type: "toggle", id: "options", label: "설정", icon: <SlidersHorizontal />, title: "옵션 패널 열기 / 닫기", pressed: panels.options, iconOnlyBelow: 900, onPressedChange: next => toggle("options", next) },
|
||||
{ type: "toggle", id: "properties", label: "속성", icon: <List />, title: "속성 패널 열기 / 닫기", pressed: panels.properties, iconOnlyBelow: 900, onPressedChange: next => toggle("properties", next) },
|
||||
],
|
||||
},
|
||||
], [loaded, space, is25D, dark, panels])
|
||||
@@ -93,7 +97,7 @@ export function useViewerDemo({ initialDocument = null as string | null, initial
|
||||
|
||||
return {
|
||||
documentName, setDocumentName, space, is25D, set25D, dark, setDark, panels, toggle, options, setOptions, hidden, setHidden,
|
||||
log, record, groups, trailing, onCommand, loaded, aboutOpen, setAboutOpen,
|
||||
log, record, groups, trailing, onCommand, loaded, aboutOpen, setAboutOpen, fileProps,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,3 +27,5 @@
|
||||
.sw-viewer-story-reopen { position: absolute; left: 712px; top: 16px; padding: 6px 10px; border-radius: 4px; background: #f5f0e5; font-size: 12px; }
|
||||
.sw-viewer-story-context { display: flex; align-items: center; justify-content: center; height: 160px; border: 1px dashed #9a9284; border-radius: 4px; background: #f5f0e5; color: #3e3523; font-size: 13px; }
|
||||
.sw-viewer-story-mode { display: flex; gap: 16px; margin-bottom: 8px; font-size: 13px; }
|
||||
/* Room below a single header row so its logo menu / document list are not cut by the scroller. */
|
||||
.sw-viewer-story-menu-row { padding-bottom: 120px; }
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { Copy, Download, FileText, FolderOpen, Layers, Printer, Ruler, Scan, Search } from "lucide-react"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { showVerificationControls } from "./sw-story-flags"
|
||||
import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture"
|
||||
import "./sw-stories.css"
|
||||
import "./sw-viewer-stories.css"
|
||||
@@ -23,8 +24,9 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Command record for the browser tests only (2026-10-21 Storybook simplification: not shown on the page).
|
||||
function Log({ lines }: { lines: string[] }) {
|
||||
return <p className="sw-story-log" aria-live="polite" data-callback-log="">Command: {lines.length ? lines.join(" → ") : "없음"}</p>
|
||||
return <p hidden data-callback-log="">Command: {lines.length ? lines.join(" → ") : "없음"}</p>
|
||||
}
|
||||
|
||||
/** A viewer screen: header on top, the drawing area below it (panels are placed inside this frame). */
|
||||
@@ -61,7 +63,7 @@ function ViewerScreen({ width = 1440, initialDocument = SAMPLES[1].name as strin
|
||||
return (
|
||||
<>
|
||||
<Log lines={demo.log} />
|
||||
<ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
<ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
<LayerPanel demo={demo} strategy="absolute" />
|
||||
<OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} />
|
||||
<PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} />
|
||||
@@ -75,9 +77,8 @@ export const ViewerHeader: Story = {
|
||||
name: "Viewer Header",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — SWViewerHeader"
|
||||
description="참고 DWG/DXF 뷰어의 상단 메뉴를 패키지 컴포넌트로 옮긴 뷰어 모드 전용 Header입니다. 로고 면 → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 패널·상태·정보 영역이며, 메뉴는 groups 데이터로 구성합니다. 레이어·설정·속성 토글은 팝업을 열고, 정보 버튼은 모달 팝업을 엽니다." />
|
||||
<StorySection title="기본형 (1440px, 도면 열림)" description="샘플▾ Dropdown, Model/Layout 탭, 2.5D·어두운 배경 스위치, 패널 토글을 직접 눌러 보세요. Command 줄에 onCommand 결과가 표시됩니다.">
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — SWViewerHeader" />
|
||||
<StorySection title="기본형 (1440px, 도면 열림)">
|
||||
<ViewerScreen />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
@@ -86,6 +87,8 @@ export const ViewerHeader: Story = {
|
||||
|
||||
export const MenuGroups: Story = {
|
||||
name: "Menu Groups",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||
const pick = (ids: string[]): SWViewerHeaderGroup[] => demo.groups.map(group => ({ ...group, hidden: group.hidden || !ids.includes(group.id) }))
|
||||
@@ -98,12 +101,12 @@ export const MenuGroups: Story = {
|
||||
]
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 메뉴 그룹" description="그룹은 groups 배열의 순서대로 놓입니다. surface="field"는 사용자 확정 속성 입력 면(어두운 inset), placement="end"는 오른쪽 끝 영역입니다. hidden으로 그룹·항목을 제거합니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 메뉴 그룹" />
|
||||
<Log lines={demo.log} />
|
||||
<div className="sw-story-stack">
|
||||
{rows.map(row => (
|
||||
<StorySection key={row.title} title={row.title}>
|
||||
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWViewerHeader documentTitle={demo.documentName} groups={pick(row.ids)} trailing={row.ids.includes("panels") ? demo.trailing : undefined} onCommand={demo.onCommand} /></div></div>
|
||||
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={pick(row.ids)} trailing={row.ids.includes("panels") ? demo.trailing : undefined} onCommand={demo.onCommand} /></div></div>
|
||||
</StorySection>
|
||||
))}
|
||||
</div>
|
||||
@@ -114,6 +117,8 @@ export const MenuGroups: Story = {
|
||||
|
||||
export const States: Story = {
|
||||
name: "Item States",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => {
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||
@@ -139,8 +144,7 @@ export const States: Story = {
|
||||
]
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — Active / Hover / Disabled"
|
||||
description="Hover: #266651 면, Active·열림·선택 탭: 글자와 아이콘 #FFC600(선택 박스 없음), Disabled: 60% 불투명, Focus: 2px #FFC600 외곽선(키보드). Hover와 Focus는 마우스·Tab으로 확인합니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — Active / Hover / Disabled" />
|
||||
<Log lines={log} />
|
||||
<div className="sw-story-scroller"><div style={{ width: 1440 }}>
|
||||
<SWViewerHeader documentTitle="상태 확인용.dwg" groups={groups} onCommand={command => record(`${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`)} />
|
||||
@@ -174,6 +178,8 @@ const MENU_ENTRIES: SWViewerMenuEntry[] = [
|
||||
|
||||
export const Dropdowns: Story = {
|
||||
name: "Header + Dropdown",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => {
|
||||
const [entries, setEntries] = useState(MENU_ENTRIES)
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
@@ -192,16 +198,15 @@ export const Dropdowns: Story = {
|
||||
]
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 상단 메뉴 + Dropdown"
|
||||
description="type="menu" 항목의 entries가 Dropdown이 됩니다. 일반 항목·체크·라디오·라벨·구분선·다단계 서브메뉴(submenu)를 지원합니다. 키보드: Enter/↓ 열기, ↑↓ 이동, → 서브메뉴, ← 닫기, Esc 닫고 버튼으로 복귀, 바깥 클릭 닫기." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 상단 메뉴 + Dropdown" />
|
||||
<Log lines={log} />
|
||||
<div className="sw-story-scroller sw-viewer-story-room"><div style={{ width: 1440 }}>
|
||||
<SWViewerHeader documentTitle="C0060202-001-평면및종단면도.dwg" groups={groups}
|
||||
onCommand={command => { const entry = findEntry(entries, command.id); if (entry) apply(entry); record(`${command.itemId}/${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`) }} />
|
||||
</div></div>
|
||||
<StorySection title="SWViewerMenu 단독 (Context Menu)" description="anchorPoint로 지정 위치에 엽니다. 아래 영역을 마우스 오른쪽 버튼으로 클릭하세요.">
|
||||
{showVerificationControls() && <StorySection title="SWViewerMenu 단독 (Context Menu)">
|
||||
<ContextMenuDemo onSelect={line => record(line)} />
|
||||
</StorySection>
|
||||
</StorySection>}
|
||||
</StoryPage>
|
||||
)
|
||||
},
|
||||
@@ -235,14 +240,15 @@ function ContextMenuDemo({ onSelect }: { onSelect: (line: string) => void }) {
|
||||
|
||||
export const SettingsPanel: Story = {
|
||||
name: "Settings Popup",
|
||||
// 2026-10-21: merged into the Settings Popup Story (content-variants); hidden, ID kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["options"] })
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)"
|
||||
description="설정 토글로 여는 옵션 팝업입니다. 제목 막대를 끌어 이동, 오른쪽 아래 모서리로 크기 조절, 제목 더블클릭·접기 버튼으로 접기, 닫기·Esc(팝업 안 Focus)로 닫습니다. 하단 버튼 영역(footer)을 포함한 예시입니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)" />
|
||||
<Log lines={demo.log} />
|
||||
<ViewerFrame header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
<ViewerFrame header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
<OptionsPanel demo={demo} strategy="absolute" footer />
|
||||
</ViewerFrame>
|
||||
</StoryPage>
|
||||
@@ -251,18 +257,18 @@ export const SettingsPanel: Story = {
|
||||
}
|
||||
|
||||
export const ContentVariants: Story = {
|
||||
name: "Popup Content Variants",
|
||||
// 2026-10-21: the one settings-popup Story (former 설정창 and 같은 프레임, 다른 내부 콘텐츠 pages); ID kept.
|
||||
name: "Settings Popup",
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties"] })
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties", "options"] })
|
||||
const [inputOpen, setInputOpen] = useState(true)
|
||||
const [scale, setScale] = useState("1:500")
|
||||
const [snap, setSnap] = useState(true)
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 같은 프레임, 다른 내부 콘텐츠"
|
||||
description="SWViewerPanel의 외형(제목 막대·닫기·테두리·스크롤 본문)은 같고 children / footer만 다릅니다: 검색 + 목록(레이어), 읽기 전용 정의 목록(속성), 입력 폼 + 하단 버튼(입력 팝업)." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)" />
|
||||
<Log lines={demo.log} />
|
||||
<ViewerFrame height={640} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
<ViewerFrame height={870} header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
|
||||
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, top: 16 }} />
|
||||
<SWViewerPanel strategy="absolute" defaultPosition={{ left: 712, top: 16 }} title="축척 지정" open={inputOpen} onClose={() => setInputOpen(false)} width={300} height="auto" resizable={false}
|
||||
@@ -273,6 +279,7 @@ export const ContentVariants: Story = {
|
||||
<SWViewerField label="단위">{id => <SWSelect id={id} defaultValue="mm" options={[{ value: "mm" }, { value: "m" }]} onValueChange={value => demo.record(`unit=${value}`)} data-sw-select="unit" />}</SWViewerField>
|
||||
</SWViewerSection>
|
||||
</SWViewerPanel>
|
||||
<OptionsPanel demo={demo} strategy="absolute" defaultPosition={{ left: 712, top: 290 }} footer />
|
||||
{!inputOpen && <button type="button" className="sw-viewer-story-reopen" onClick={() => setInputOpen(true)}>축척 지정 다시 열기</button>}
|
||||
</ViewerFrame>
|
||||
</StoryPage>
|
||||
@@ -282,6 +289,8 @@ export const ContentVariants: Story = {
|
||||
|
||||
export const VariableSize: Story = {
|
||||
name: "Variable Size Popups",
|
||||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||||
tags: ["verification"],
|
||||
render: () => {
|
||||
const sizes: { id: string; title: string; width: number; height: number | "auto"; left: number; note: string }[] = [
|
||||
{ id: "s", title: "250 × auto (최소 폭)", width: 250, height: "auto", left: 16, note: "내용 높이" },
|
||||
@@ -292,8 +301,7 @@ export const VariableSize: Story = {
|
||||
const lines = Array.from({ length: 24 }, (_, index) => `행 ${index + 1}`)
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 가변 크기 팝업"
|
||||
description="width / height("auto" 가능) / minWidth / minHeight로 메뉴별 크기를 정합니다. 최대 90vw × 90vh, 오른쪽 아래 모서리로 크기 조절, 화면(또는 strategy="absolute"의 부모) 경계 안에서만 이동합니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 가변 크기 팝업" />
|
||||
<ViewerFrame height={520} header={<SWViewerHeader documentTitle="크기 비교.dwg" groups={[]} />}>
|
||||
{sizes.map(size => (
|
||||
<SWViewerPanel key={size.id} strategy="absolute" defaultPosition={{ left: size.left, top: 16 }} title={size.title} open={open[size.id]} onClose={() => setOpen({ ...open, [size.id]: false })} width={size.width} height={size.height}>
|
||||
@@ -308,18 +316,17 @@ export const VariableSize: Story = {
|
||||
}
|
||||
|
||||
export const Responsive: Story = {
|
||||
name: "Responsive Widths",
|
||||
name: "Responsive More",
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비"
|
||||
description="참고 뷰어와 같은 기준입니다. Header 폭 1180px 미만: 샘플·도면 공간·2.5D·어두운 배경이 더보기(⋯)로 이동, 900px 미만: 패널 토글도 이동, 560px 미만: 파일 열기·전체 보기는 아이콘만, 상태 글자 숨김. 가로 스크롤은 만들지 않습니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비" />
|
||||
<Log lines={demo.log} />
|
||||
<div className="sw-story-stack">
|
||||
{[1440, 1100, 860, 520].map(width => (
|
||||
<StorySection key={width} title={`${width}px`}>
|
||||
<div className="sw-story-scroller"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
|
||||
<div className="sw-story-scroller sw-viewer-story-menu-row"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
|
||||
</StorySection>
|
||||
))}
|
||||
</div>
|
||||
@@ -342,7 +349,7 @@ function ModeSwitchDemo() {
|
||||
<Log lines={demo.log} />
|
||||
<ViewerFrame height={480} header={mode === "normal"
|
||||
? <SWHeader ref={headerRef} documentTitle={demo.documentName ?? ""} logoSrc={placeholderLogo} logoAlt="임시 로고 (뷰어 모드로)" onHomeClick={() => setMode("viewer")} />
|
||||
: <SWViewerHeader ref={headerRef} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} logoSrc={placeholderLogo} logoAlt="임시 로고 (일반 모드로)" onHomeClick={() => setMode("normal")} />}>
|
||||
: <SWViewerHeader ref={headerRef} documentTitle={demo.documentName} documents={demo.fileProps.documents} activeDocumentId={demo.fileProps.activeDocumentId} onDocumentSelect={demo.fileProps.onDocumentSelect} onDocumentOpen={demo.fileProps.onDocumentOpen} documentOpenLabel={demo.fileProps.documentOpenLabel} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} logoSrc={placeholderLogo} logoAlt="임시 로고 (일반 모드로)" onHomeClick={() => setMode("normal")} />}>
|
||||
{mode === "viewer" && <><LayerPanel demo={demo} strategy="absolute" /><OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} /></>}
|
||||
</ViewerFrame>
|
||||
</>
|
||||
@@ -353,15 +360,14 @@ export const CompareWithSWHeader: Story = {
|
||||
name: "SWHeader vs Viewer Header",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 일반 SW Header와 비교"
|
||||
description="일반 SW 모드는 기존 SWHeader(49px, Figma 26:8944)를 그대로 쓰고, 뷰어 모드만 SWViewerHeader(52px, 참고 뷰어)를 씁니다. 두 Header는 파일명·객체 수정·속성 면 토큰을 공유합니다. 아래 전환 예시는 화면 상태(파일명·패널 상태)를 유지한 채 Header만 바꿉니다. 예시에서는 SWHeader 로고(임시)를 누르면 뷰어 모드, 뷰어 로고(임시)를 누르면 일반 모드로 돌아갑니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 일반 SW Header와 비교" />
|
||||
<StorySection title="나란히 비교 (1440px)">
|
||||
<div className="sw-story-stack">
|
||||
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWHeader documentTitle="C0060202-001-평면및종단면도" /></div></div>
|
||||
<div className="sw-story-scroller"><div style={{ width: 1440 }}><ViewerOnly /></div></div>
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="일반 ↔ 뷰어 모드 전환" description="Header 교체만 보여 주는 UI 예시입니다. 실제 모드 진입·종료와 데이터 처리는 SW 프로그램 담당입니다.">
|
||||
<StorySection title="일반 ↔ 뷰어 모드 전환">
|
||||
<ModeSwitchDemo />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
@@ -372,22 +378,23 @@ export const Logo: Story = {
|
||||
name: "Logo",
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||
const rows: { title: string; description: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
|
||||
{ title: "로고 없음 (기본)", description: "logo / logoSrc를 넘기지 않으면 아무것도 그리지 않습니다(HM 문자·기본 아이콘 없음). 로고 면은 사선 끝(24px)만 남아 넓은 빈 공간이 생기지 않습니다.", props: {} },
|
||||
{ title: "logoSrc (이미지 URL)", description: "SWHeader와 같은 Prop. 로고 면 72px(로고가 더 넓으면 그만큼), 높이 최대 24px.", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } },
|
||||
{ title: "logo (요소)", description: "logo가 logoSrc보다 우선합니다.", props: { logo: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
|
||||
const rows: { title: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
|
||||
{ title: "로고 없음 (기본)", props: {} },
|
||||
{ title: "logoSrc (이미지 URL)", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } },
|
||||
{ title: "logo (요소)", props: { logo: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
|
||||
]
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 로고" description="로고는 각 프로그램이 정합니다. 제공된 경우에만 표시하고, Header 높이(52px)와 메뉴 정렬은 그대로입니다. SWHeader의 로고 동작은 바뀌지 않습니다." />
|
||||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 로고" />
|
||||
<div className="sw-story-stack">
|
||||
{rows.map(row => (
|
||||
<StorySection key={row.title} title={row.title} description={row.description}>
|
||||
<StorySection key={row.title} title={row.title}>
|
||||
<div className="sw-story-scroller"><div style={{ width: 1440 }} data-logo-case={row.title}>
|
||||
<SWViewerHeader {...row.props} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||||
<SWViewerHeader {...row.props} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||||
</div></div>
|
||||
</StorySection>
|
||||
))}
|
||||
<Log lines={demo.log} />
|
||||
</div>
|
||||
</StoryPage>
|
||||
)
|
||||
@@ -396,5 +403,5 @@ export const Logo: Story = {
|
||||
|
||||
function ViewerOnly() {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||
return <SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||||
return <SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||||
}
|
||||