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.
This commit is contained in:
1 parent
46119eacb5
commit
23d522c136
57 files changed
+2623
-333
No files matched your search
@@ -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) }
|
||||
|
||||
Reference in new issue
Block a user