feat(sw): viewer mode header, dropdown and popup panel

- SWViewerHeader: data-driven viewer menu (button / toggle / menu / tabs / switch / custom), More by
  collapseBelow, trailing slot, onCommand; SWHeader logo props with no default logo
- SWViewerMenu: dropdown, check / radio, multi-level submenu, context menu at a point
- SWViewerPanel: viewer popup frame with free children / footer, drag, corner resize, collapse, modal
- Panel controls: SWViewerSection / Field / Hint / Switch / Slider / Input / Button / Badge / Status
- SWHeader black text outline tokens on viewer text, matching icon outline (drop-shadow)
- Storybook SW/Viewer stories; remove SW/Tool story (SW/Popup kept)
- AI guide ai/VIEWER.md, example H, verification scripts and evidence
This commit is contained in:
shkim4 committed 2026-10-19 16:09:26 +09:00
1 parent e8409eff44
commit 7f42923ccf
50 files changed
+3838 -92

No files matched your search

+2 -1
View File
@@ -7,7 +7,8 @@
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 |
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 파일 열기 + 도구 막대 |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증), H = 뷰어 모드 |
| [VIEWER.md](./VIEWER.md) | **뷰어 모드 전용**: `SWViewerHeader`(데이터 메뉴·More·외곽선·로고 규칙), `SWViewerMenu`(Dropdown·서브메뉴·우클릭), `SWViewerPanel`(팝업 프레임 + 자유 콘텐츠). 일반 SW 모드는 `SWHeader`·`SWPopup`을 그대로 씁니다 |
## 1. 작업 순서 (필수)
+104
View File
@@ -0,0 +1,104 @@
# @hm-design/sw — 뷰어 모드 (Viewer Header · Popup) 사용 지침
기준: 2026-10-19, 참고 DWG/DXF 뷰어(`dwg-dxf-viewer-hm`) 상단 메뉴·팝업을 같은 해상도(1440 × 900)에서 실측해 패키지 컴포넌트로 옮긴 결과입니다. 아래 이름은 모두 `from "@hm-design/sw"` 공개 Export이고 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 예제: [examples/ExampleH-ViewerMode.tsx](./examples/ExampleH-ViewerMode.tsx).
## 1. 언제 무엇을 쓰나
| 화면 | 사용 | 쓰지 않음 |
|---|---|---|
| SW 일반 모드 Header | `SWHeader` (49px, Figma 26:8944) | `SWViewerHeader` |
| SW 뷰어 모드 Header | `SWViewerHeader` (52px, 참고 뷰어) | `SWHeader`에 뷰어 메뉴를 억지로 넣기, 직접 만든 툴바 |
| 뷰어 메뉴 Dropdown·서브메뉴·우클릭 메뉴 | `type: "menu"` 항목의 `entries`, 단독은 `SWViewerMenu` | `@hm-design/ui` DropdownMenu를 Header 안에 직접 배치 |
| 뷰어 팝업·설정창·도구 패널·입력 팝업 | `SWViewerPanel` (+ `modal`) | 새 프레임 작성, `SWPopup`(일반 SW 속성창 프레임)을 뷰어 디자인으로 재스타일 |
| 일반 SW 팝업·속성창 | `SWPopup`, `SWHeaderPropertyPanel` (변경 없음) | `SWViewerPanel` |
- 한 화면에는 Header 하나만 둡니다. 모드 전환은 프로그램이 Header 컴포넌트를 바꾸는 방식입니다(Storybook `SW/Viewer › SWHeader vs Viewer Header`). 파일·패널 상태는 프로그램 상태라 Header를 바꿔도 유지됩니다.
- 뷰어 모드 진입·종료, 파일 처리, DWG/DXF 렌더링, 메뉴 명령 실행은 **프로그램 담당**입니다. 패키지는 UI와 공통 인터랙션(열기/닫기, Focus, 키보드, 위치·크기)만 담당합니다.
- 기능이 없는 메뉴는 만들지 않습니다. 연결할 명령이 없으면 항목을 빼거나 `disabled`로 두고 “미연결”로 보고합니다.
## 2. `SWViewerHeader`
로고 면(사선) → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 끝 영역(`placement: "end"` 그룹 + `trailing`).
| Props | 설명 |
|---|---|
| `groups: SWViewerHeaderGroup[]` | 메뉴 데이터(순서대로 배치). 그룹: `id`, `label`(접근성 이름·More 구획 제목), `moreLabel?`, `surface?: "plain" \| "field"`(field = 사용자 확정 속성 입력 면), `placement?: "start" \| "end"`, `hidden?`, `items` |
| 항목 `SWViewerHeaderItem` | 공통: `id`, `label`, `icon?`(16px, 프로그램 제공), `title?`, `disabled?`, `hidden?`, `collapseBelow?`(Header 폭 미만이면 More로 이동), `iconOnlyBelow?`(미만이면 아이콘만), `iconOnly?`, `moreLabel?` |
| `type: "button"` | `onSelect` |
| `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) |
| `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` |
| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout) |
| `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) |
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 |
| `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). 가로 스크롤은 만들지 않습니다.
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
- **상태 표현:** 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), 아이콘 `--hm-sw-viewer-icon-outline`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke + #061A14 glow를 `drop-shadow`로 재현, 아이콘 윤곽을 따름). 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
## 3. `SWViewerMenu` / `SWViewerMenuEntry`
`entries`: `item`(`icon?`, `shortcut?`, `onSelect`), `checkbox`(`checked`, `onCheckedChange`), `radio`(`checked`, `onSelect`), `submenu`(`entries` — 다단계), `label`, `separator`. 공통 `disabled?`, `hidden?`, `keepOpen?`.
- Header의 `type: "menu"`가 이 Dropdown을 엽니다. 다른 위치(도면 우클릭 등)는 `SWViewerMenu`를 `open` / `onOpenChange` + `anchorRef`(버튼 아래 4px) 또는 `anchorPoint`(포인터 위치)로 씁니다. `surface="beige"`는 More 면, `initialFocus`는 `first`(키보드 열기) / `menu`(클릭 열기).
- 키보드: Enter·Space·ArrowDown 열기, ↑↓ Home End 이동, → 서브메뉴 열기, ← 한 단계 닫기, Escape 닫고 Trigger로 Focus 복귀, Tab·바깥 클릭 닫기. 화면 끝에서는 위/왼쪽으로 뒤집혀 화면 안에 남습니다.
## 4. `SWViewerPanel` — 팝업 프레임 + 자유 콘텐츠
외형(베이지 #E0DACE 본문, 2px #897445 테두리, #221D13 제목 막대, #FFC600 제목, 접기, 곡선 녹색 닫기 → Hover #E40000)과 공통 동작은 패키지가, 내용은 `children`·`footer`가 담당합니다.
| Props | 설명 |
|---|---|
| `title`, `icon?`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose` |
| `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 |
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"` |
| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (기본값 복원, 선택 확대 등) |
| `width?`(320), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh |
| `defaultPosition?` | `{ left, top }` 또는 `"top-end"`(기본) / `"top-start"` / `"center"`(modal 기본), `inset?: { top?, side? }`(기본 56 / 24 = 52px Header 아래) |
| `strategy?` | `fixed`(기본, 화면) / `absolute`(가장 가까운 positioned 조상 안 — 미리보기·임베드) |
| `draggable?`, `resizable?`, `collapsible?`, `defaultCollapsed?`, `collapsed?`, `onCollapsedChange?`, `onPositionChange?`, `onSizeChange?` | 제목 막대 드래그, 오른쪽 아래 모서리 크기 조절, 접기(버튼·제목 더블클릭). 이동·크기는 경계 10px 안 |
| `modal?` | 10% 검정 배경, Focus Trap, 가운데 배치(입력·확인 팝업) |
| `autoFocus?` | 열 때 패널로 Focus, 닫을 때 원래 위치로 복귀(기본 true) |
- Escape는 Focus가 패널 안에 있을 때 그 패널만 닫습니다. 비모달 패널은 바깥 클릭으로 닫히지 않습니다(참고 뷰어와 동일, 도구 창). 마지막으로 누른 패널이 맨 앞으로 옵니다.
- **콘텐츠 교체:** 같은 프레임에 다른 `children`을 넣습니다(레이어 목록 / 옵션 / 속성 / 입력 폼 — Storybook `Popup Content Variants`). 프레임을 복사하거나 `hm-sw-viewer-*` class로 새 팝업을 만들지 않습니다.
## 5. 올바른 사용
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
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 }] },
]
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand} />
<SWViewerPanel title="옵션" open={open} onClose={() => setOpen(false)}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
```
## 6. 잘못된 사용
| 하지 말 것 | 이유 / 대신 |
|---|---|
| `<SWViewerHeader logo="HM" />`처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 `style`로 조정(기본값 유지 권장) |
| 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 |
| 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다 |
| 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 |
## 7. 디자인 변경 시 주의
- 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면, 72px 로고 면)와 색은 참고 뷰어 실측값입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다.
- Light/Dark 분기는 없습니다. 참고 뷰어와의 알려진 차이: 도면 공간(tabs) field 면 높이 42px(참고 41px — 참고 Tabs 루트가 39px인 구현 부산물), 크기 조절은 네이티브 `resize` 대신 같은 위치의 모서리 Grip(모든 브라우저 동작), 패널 Escape 닫기 추가, 로고 기본 표시 없음(사용자 결정).
@@ -0,0 +1,85 @@
// Example H — viewer mode: SWViewerHeader (data-driven menu) + SWViewerPanel popups with free content.
// Only public components. The program owns the state and the commands; the package owns the look and the
// open / close / focus / keyboard / drag behavior. Icons are the program's own (16px, currentColor).
import { useState } from "react"
import {
SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHeaderButton, SWViewerHint, SWViewerInput, SWViewerPanel,
SWViewerSection, SWViewerSlider, SWViewerStatus, SWViewerSwitch,
type SWViewerHeaderGroup,
} from "@hm-design/sw"
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" />,
info: <Icon d="M12 8h.01M11 12h1v5h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z" />,
}
export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => void }) {
const [fileName, setFileName] = useState<string | null>(null)
const [space, setSpace] = useState("model")
const [depth, setDepth] = useState(false)
const [panels, setPanels] = useState({ layers: false, options: false })
const [grid, setGrid] = useState(true)
const [zoomSpeed, setZoomSpeed] = useState(1)
const [about, setAbout] = useState(false)
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 },
{ 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 }) },
] },
]
return (
<>
{/* 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}
onCommand={command => onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)}
trailing={<>
<SWViewerStatus state={fileName ? "ready" : "idle"} />
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
</>} />
<SWViewerPanel title="레이어" icon={icons.layers} open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
<SWViewerHint>프로그램의 레이어 목록을 여기에 둡니다.</SWViewerHint>
</SWViewerSection>
</SWViewerPanel>
<SWViewerPanel title="옵션" icon={icons.options} open={panels.options} onClose={() => setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }}
footer={<SWViewerButton variant="primary" onClick={() => { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용</SWViewerButton>}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
<SWViewerField label="줌 속도" layout="stacked" below={<SWViewerSlider aria-label="줌 속도" value={zoomSpeed} min={0.2} max={3} step={0.1} onValueChange={setZoomSpeed} />}>
{id => <SWViewerInput id={id} type="number" min={0.2} max={3} step={0.1} value={zoomSpeed} onChange={event => setZoomSpeed(Number(event.currentTarget.value))} />}
</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
<SWViewerPanel modal title="정보" open={about} onClose={() => setAbout(false)} width={360} height="auto" resizable={false} collapsible={false}
footer={<SWViewerButton variant="primary" onClick={() => setAbout(false)}>확인</SWViewerButton>}>
<SWViewerHint>모듈 구성과 라이선스 고지는 프로그램이 제공합니다.</SWViewerHint>
</SWViewerPanel>
</>
)
}
@@ -0,0 +1,167 @@
import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
import "../../theme/sw-viewer.css"
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
export interface SWViewerSwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean
onCheckedChange?: (checked: boolean) => void
}
/**
* 36 x 20 switch. Its face follows where it is placed: dark header face (off #0A221D, on #FFC600) inside
* SWViewerHeader, beige panel face (off #9A9284, on #266651) inside SWViewerPanel.
*/
export const SWViewerSwitch = forwardRef<HTMLButtonElement, SWViewerSwitchProps>(function SWViewerSwitch(
{ checked, onCheckedChange, className, onClick, ...rest }, ref,
) {
return (
<button ref={ref} type="button" role="switch" aria-checked={checked} data-state={checked ? "checked" : "unchecked"}
className={cx("hm-sw-viewer-switch", className)}
onClick={event => { onClick?.(event); if (!event.defaultPrevented) onCheckedChange?.(!checked) }} {...rest}>
<span className="hm-sw-viewer-switch__thumb" aria-hidden="true" />
</button>
)
})
export type SWViewerStatusState = "idle" | "loading" | "ready" | "error"
export interface SWViewerStatusProps extends HTMLAttributes<HTMLDivElement> {
state: SWViewerStatusState
/** Short visible text. Default per state: 준비 / 불러오는 중 / 도면 열림 / 불러오기 실패. */
label?: string
/** Longer detail for screen readers and the tooltip. */
detail?: string
}
const statusLabel: Record<SWViewerStatusState, string> = { idle: "준비", loading: "불러오는 중", ready: "도면 열림", error: "불러오기 실패" }
const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idle" ? null : (
<svg className={state === "loading" ? "hm-sw-viewer-status__spin" : undefined} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
{state === "loading" && <path d="M14 8a6 6 0 1 1-6-6" />}
{state === "ready" && <path d="M3.5 8.5l3 3 6-7" />}
{state === "error" && <><circle cx="8" cy="8" r="6" /><path d="M8 5v3.5M8 11h.01" /></>}
</svg>
)
/** Load state of the viewer header (reference status: muted 11px text, icon; text hides below 560px). */
export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) {
return (
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
<StatusIcon state={state} />
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
</div>
)
}
export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Panel buttons. `outline` (default): reference xs outline button (24px, white, #123328 border).
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
*/
variant?: "outline" | "primary" | "ghost"
icon?: ReactNode
}
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
{ variant = "outline", icon, children, className, type = "button", ...rest }, ref,
) {
return (
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} {...rest}>
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
{children}
</button>
)
})
export type SWViewerInputProps = InputHTMLAttributes<HTMLInputElement>
/** 28px panel input (#F5F0E5 face, #9A9284 border, 12px). Any native input type. */
export const SWViewerInput = forwardRef<HTMLInputElement, SWViewerInputProps>(function SWViewerInput({ className, ...rest }, ref) {
return <input ref={ref} className={cx("hm-sw-viewer-input", className)} {...rest} />
})
export interface SWViewerSliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
value: number
min: number
max: number
step?: number
onValueChange?: (value: number) => void
}
/** Panel slider (6px #E0DACE track, #266651 range, 18px white thumb with #266651 ring). Native range input. */
export const SWViewerSlider = forwardRef<HTMLInputElement, SWViewerSliderProps>(function SWViewerSlider(
{ value, min, max, step = 1, onValueChange, className, style, ...rest }, ref,
) {
const percent = max > min ? ((Math.min(max, Math.max(min, value)) - min) / (max - min)) * 100 : 0
return (
<input ref={ref} type="range" className={cx("hm-sw-viewer-slider", className)} value={value} min={min} max={max} step={step}
style={{ ...style, ["--hm-sw-viewer-slider-percent" as string]: `${percent}%` }}
onChange={event => onValueChange?.(Number(event.currentTarget.value))} {...rest} />
)
})
export interface SWViewerFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
label: string
/** Control id for the label. Generated when omitted and `children` is a function. */
htmlFor?: string
/**
* `inline` (default): label left, control right (switch rows). `stacked`: label + control row, then `below`
* (slider rows: number input beside the label, slider underneath).
*/
layout?: "inline" | "stacked"
/** Unit after the control. */
unit?: string
/** Stacked layout: content under the label row (a slider). */
below?: ReactNode
disabled?: boolean
children: ReactNode | ((id: string) => ReactNode)
}
/** Label + control row of the viewer panel (reference option rows). */
export function SWViewerField({ label, htmlFor, layout = "inline", unit, below, disabled, children, className, ...rest }: SWViewerFieldProps) {
const generated = useId()
const id = htmlFor ?? generated
return (
<div className={cx("hm-sw-viewer-field", className)} data-layout={layout} data-disabled={disabled || undefined} {...rest}>
<div className="hm-sw-viewer-field__row">
<label className="hm-sw-viewer-field__label" htmlFor={id}>{label}</label>
<div className="hm-sw-viewer-field__control">
{typeof children === "function" ? children(id) : children}
{unit && <span className="hm-sw-viewer-field__unit">{unit}</span>}
</div>
</div>
{below}
</div>
)
}
export interface SWViewerBadgeProps extends HTMLAttributes<HTMLSpanElement> { children: ReactNode }
/** Count badge of panel lists (12px 500, #F4F1EA face). */
export function SWViewerBadge({ className, ...rest }: SWViewerBadgeProps) {
return <span className={cx("hm-sw-viewer-badge", className)} {...rest} />
}
export interface SWViewerSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/** 12px 700 heading (reference panel h4). Also the section's accessible name. */
title?: string
/** `card` (default): the inset #F5F0E5 card. `plain`: no face (lists). */
surface?: "card" | "plain"
children?: ReactNode
}
/** A content group of SWViewerPanel. Children are free (fields, lists, any component). */
export function SWViewerSection({ title, surface = "card", children, className, ...rest }: SWViewerSectionProps) {
return (
<section className={cx("hm-sw-viewer-section", className)} data-surface={surface} aria-label={rest["aria-label"] ?? title} {...rest}>
{title && <h4 className="hm-sw-viewer-section__title">{title}</h4>}
{children}
</section>
)
}
/** Muted 12px help text of a panel section. */
export function SWViewerHint({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cx("hm-sw-viewer-hint", className)} {...rest} />
}
@@ -0,0 +1,287 @@
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { SWViewerMenu } from "./SWViewerMenu"
import { SWViewerSwitch } from "./SWViewerControls"
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
import "../../theme/sw-viewer.css"
export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/**
* Logo image URL of the program (same prop as SWHeader), max 24px high on the slanted green cap.
* The logo belongs to each program: nothing is rendered when omitted (no default text or icon) and the cap
* shrinks to its slanted edge.
*/
logoSrc?: string
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc` (same prop as SWHeader). */
logo?: ReactNode
/** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is "<logoAlt> 홈". */
logoAlt?: string
/** Makes the logo cap a button (e.g. back to the normal SW mode). Same prop as SWHeader. */
onHomeClick?: () => void
/** Open file name. `null` / empty shows `documentPlaceholder`. */
documentTitle?: string | null
/** Default `열린 도면 없음`. */
documentPlaceholder?: string
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
documentTooltip?: string
/** Replaces the file-name text (the dark document well stays). */
document?: ReactNode
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
groups: SWViewerHeaderGroup[]
/** Free content after the trailing groups (status, info button, ...). */
trailing?: ReactNode
/** Every command of the header and its dropdowns (after the item's own callback). */
onCommand?: (command: SWViewerCommand) => void
/** Accessible name of the More button. Default `추가 도구`. */
moreLabel?: string
/** Accessible name of the object surface. Default `도면 작업 도구`. */
toolbarLabel?: string
}
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 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)
/** 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[] {
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)
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) {
if (item.type === "custom") { if (item.menuEntries?.length) { flush(); own(item.menuEntries, item.id); sections.push(item.menuEntries) } continue }
if (item.type === "menu") {
flush()
own(item.entries, item.id)
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label },
...item.entries.map(entry => (entry.type === "label" || entry.type === "separator" ? entry : { ...entry, disabled: entry.disabled || item.disabled }))])
} else if (item.type === "tabs") {
flush()
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, ...item.options.map(option => ({
type: "radio" as const, id: `${item.id}:${option.value}`, checked: option.value === item.value, disabled: option.disabled || item.disabled,
label: option.count !== undefined && option.count > 0 ? `${option.label} · ${option.count.toLocaleString()}개` : option.label,
onSelect: () => { item.onValueChange?.(option.value); command({ id: item.id, itemId: item.id, value: option.value }) },
}))])
} else if (item.type === "button") {
plain.push({ type: "item", id: item.id, label: item.label, icon: item.icon, disabled: item.disabled, onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) } })
} else {
const checked = item.type === "toggle" ? item.pressed : item.checked
plain.push({ type: "checkbox", id: item.id, label: item.label, checked, disabled: item.disabled, onCheckedChange: next => {
if (item.type === "toggle") item.onPressedChange?.(next); else item.onCheckedChange?.(next)
command({ id: item.id, itemId: item.id, value: next })
} })
}
}
flush()
}
return sections.flatMap((section, index) => (index ? [{ type: "separator" as const, id: `more-sep-${index}` }, ...section] : section))
}
function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "menu" }>; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) {
const ref = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const [focus, setFocus] = useState<"first" | "menu">("menu")
const menuId = useId()
const show = (how: "first" | "menu") => { setFocus(how); setOpen(true) }
return (
<>
<button ref={ref} type="button" className="hm-sw-viewer-header__button" data-kind="menu" data-icon-only={iconOnly === "always" ? "always" : iconOnly ? "" : undefined} data-state={open ? "open" : undefined}
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
onClick={() => (open ? setOpen(false) : show("menu"))}
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{item.icon}</span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
<span className="hm-sw-viewer-header__chevron"><ChevronDown /></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}
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
</>
)
}
function Tabs({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "tabs" }>; onCommand: (c: SWViewerCommand) => void }) {
const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) return
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)'))
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length
buttons[next].focus()
buttons[next].click()
}
const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value
return (
<div role="radiogroup" aria-label={item.label} className="hm-sw-viewer-header__tabs" onKeyDown={onKeyDown}>
{item.options.map(option => {
const selected = option.value === item.value
return (
<button key={option.value} type="button" role="radio" aria-checked={selected} tabIndex={option.value === current ? 0 : -1}
className="hm-sw-viewer-header__tab" data-state={selected ? "active" : undefined} title={option.title} disabled={item.disabled || option.disabled}
onClick={() => { if (!selected) select(option.value) }}>
{option.label}
{option.count !== undefined && option.count > 0 && <span className="hm-sw-viewer-header__count">{option.count.toLocaleString()}</span>}
</button>
)
})}
</div>
)
}
function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "switch" }>; onCommand: (c: SWViewerCommand) => void }) {
const id = useId()
return (
<div className="hm-sw-viewer-header__switch-field" title={item.title}>
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label>
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
</div>
)
}
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
if (item.type === "custom") return <>{item.render()}</>
const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
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}
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 }) }
}} />
)
}
export interface SWViewerHeaderButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** Visible text, or the accessible name when `iconOnly`. */
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/**
* `true`: always icon only, the 32 x 40 icon button (info, More). `"compact"`: the 34 x 34 command button shown
* without its label (what `iconOnlyBelow` does). Default false.
*/
iconOnly?: boolean | "compact"
/** Toggle state (aria-pressed, #FFC600 when on). Leave undefined for a command button. */
pressed?: boolean
}
/**
* The viewer header button for the `trailing` slot or a `custom` item (e.g. the info button that opens a modal
* SWViewerPanel). Same look as the data-driven items: transparent, #266651 hover, #FFC600 when pressed.
*/
export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeaderButtonProps>(function SWViewerHeaderButton(
{ label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref,
) {
const kind = pressed === undefined ? "button" : "toggle"
return (
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{icon}</span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
</button>
)
})
/**
* 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
* 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,
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
},
forwardedRef,
) {
const rootRef = useRef<HTMLElement | null>(null)
const [width, setWidth] = useState(1920)
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
const update = () => setWidth(root.getBoundingClientRect().width)
update()
const observer = new ResizeObserver(update)
observer.observe(root)
return () => observer.disconnect()
}, [])
const command = (value: SWViewerCommand) => onCommand?.(value)
const owners = new Map<string, string>()
const more = moreEntries(groups, width, command, owners)
const moreRef = useRef<HTMLButtonElement>(null)
const [moreOpen, setMoreOpen] = useState(false)
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
const renderGroup = (group: SWViewerHeaderGroup) => {
const items = group.items.filter(item => isShown(item, width))
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} />)}
</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 = (
<>
<svg className="hm-sw-viewer-header__brand-shape" viewBox="0 0 88 52" preserveAspectRatio="xMaxYMid slice" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--hm-sw-viewer-brand-top)" />
<stop offset="45%" stopColor="var(--hm-sw-viewer-brand-middle)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-brand-bottom)" />
</linearGradient>
</defs>
<path d="M 0 0 H 88 L 73 44 Q 70 52 62 52 H 0 Z" fill={`url(#${gradientId})`} />
<path d="M 88 0 L 73 44 Q 70 52 62 52 H 0" fill="none" stroke="var(--hm-sw-viewer-brand-middle)" vectorEffect="non-scaling-stroke" />
</svg>
{mark && <span className="hm-sw-viewer-header__brand-mark" aria-hidden="true">{mark}</span>}
</>
)
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>}
</div>
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
{start.map(renderGroup)}
{more.length > 0 && <>
<button ref={moreRef} type="button" className="hm-sw-viewer-header__button hm-sw-viewer-header__more" data-icon-only="always" data-state={moreOpen ? "open" : undefined}
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
<span className="hm-sw-viewer-header__icon"><MoreIcon /></span>
</button>
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
onEntrySelect={entry => {
const owner = owners.get(entry.id)
if (owner) command({ id: entry.id, itemId: owner, value: entry.type === "checkbox" ? !entry.checked : undefined })
}} />
</>}
{(end.length > 0 || trailing) && <div className="hm-sw-viewer-header__actions">{end.map(renderGroup)}{trailing}</div>}
</div>
</header>
)
})
@@ -0,0 +1,226 @@
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
import type { SWViewerMenuEntry } from "./types"
import "../../theme/sw-viewer.css"
export interface SWViewerMenuProps {
entries: SWViewerMenuEntry[]
open: boolean
onOpenChange: (open: boolean) => void
/** Element the menu opens below (a header button). Focus returns to it on close. */
anchorRef?: RefObject<HTMLElement | null>
/** Opens at a point instead (context menu: the pointer position). */
anchorPoint?: { x: number; y: number }
/** Edge aligned to the anchor. Default `start`. */
align?: "start" | "end"
/** Width in px. Default: its content (min 160). */
width?: number
/** `light` (default): the white dropdown face. `beige`: the More face of the viewer header. */
surface?: "light" | "beige"
/** Accessible name of the menu. */
label?: string
/** Called for every activated entry (after its own callback). */
onEntrySelect?: (entry: SWViewerMenuEntry) => void
/** `first` (default): focus the first entry, as after Enter / ArrowDown. `menu`: focus the list, as after a click. */
initialFocus?: "first" | "menu"
/** Id of the menu element (for the trigger's aria-controls). */
id?: string
className?: string
}
const ITEM_SELECTOR = ':scope > [role="none"] > [role^="menuitem"]:not([aria-disabled="true"])'
function visible(entries: SWViewerMenuEntry[]) {
const shown = entries.filter(entry => !entry.hidden)
// No leading, trailing or doubled separators once hidden entries are gone.
return shown.filter((entry, index) => entry.type !== "separator"
|| (index > 0 && index < shown.length - 1 && shown[index - 1].type !== "separator"))
}
const CheckIcon = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
const ChevronRight = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
interface ListProps {
entries: SWViewerMenuEntry[]
surface: "light" | "beige"
width?: number
label?: string
/** Fixed position computer: receives the list size, returns left/top. */
place: (width: number, height: number) => { left: number; top: number }
/** Activate: run the entry and close everything (unless keepOpen). */
activate: (entry: SWViewerMenuEntry) => void
/** Escape / Tab: close everything. */
closeAll: (returnFocus: boolean) => void
/** ArrowLeft inside a submenu: close only this level. */
closeLevel?: () => void
/** `first`: focus the first entry (keyboard open). `menu`: focus the list itself (pointer open). */
autoFocus: "first" | "menu" | false
id?: string
className?: string
}
function MenuList({ entries, surface, width, label, place, activate, closeAll, closeLevel, autoFocus, id, className }: ListProps) {
const ref = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [openSub, setOpenSub] = useState<string | null>(null)
const [focusSub, setFocusSub] = useState(false)
const placeRef = useRef(place)
placeRef.current = place
useLayoutEffect(() => {
const update = () => {
const list = ref.current
if (!list) return
const next = placeRef.current(list.offsetWidth, list.offsetHeight)
setPosition(current => (current && current.left === next.left && current.top === next.top ? current : next))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true) }
}, [])
useEffect(() => {
if (!autoFocus || !position) return
const target = autoFocus === "first" ? ref.current?.querySelector<HTMLElement>(ITEM_SELECTOR) : ref.current
target?.focus({ preventScroll: true })
}, [autoFocus, position !== null])
const items = () => Array.from(ref.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Only this level's own keys; a submenu inside handles (and stops) its own.
if (event.nativeEvent.isComposing) return
const list = items()
const index = list.indexOf(document.activeElement as HTMLElement)
const move = (next: number) => { event.preventDefault(); list[(next + list.length) % list.length]?.focus() }
switch (event.key) {
case "ArrowDown": move(index + 1); break
case "ArrowUp": move(index < 0 ? list.length - 1 : index - 1); break
case "Home": move(0); break
case "End": move(list.length - 1); break
case "ArrowLeft":
if (closeLevel) { event.preventDefault(); event.stopPropagation(); closeLevel() }
break
case "Escape": event.preventDefault(); event.stopPropagation(); closeAll(true); break
case "Tab": closeAll(false); break
default: return
}
event.stopPropagation()
}
const rows: ReactNode[] = visible(entries).map(entry => {
if (entry.type === "separator") return <div key={entry.id} role="separator" className="hm-sw-viewer-menu__separator" />
if (entry.type === "label") return <div key={entry.id} role="presentation" className="hm-sw-viewer-menu__label">{entry.label}</div>
const disabled = !!entry.disabled
const common = {
tabIndex: -1,
"aria-disabled": disabled || undefined,
"data-entry": entry.id,
onPointerMove: (event: { currentTarget: HTMLElement }) => {
if (disabled) return
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true })
if (entry.type !== "submenu" && openSub) setOpenSub(null)
},
}
if (entry.type === "submenu") {
const expanded = openSub === entry.id
const openThis = (focus: boolean) => { if (disabled) return; setFocusSub(focus); setOpenSub(entry.id) }
return (
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
<button type="button" role="menuitem" className="hm-sw-viewer-menu__item" aria-haspopup="menu" aria-expanded={expanded} data-state={expanded ? "open" : undefined} {...common}
onPointerEnter={() => openThis(false)}
onClick={() => openThis(true)}
onKeyDown={event => { if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); openThis(true) } }}>
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">{entry.icon}</span>
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
<span className="hm-sw-viewer-menu__chevron"><ChevronRight /></span>
</button>
{expanded && <MenuList entries={entry.entries} surface={surface} label={entry.label} autoFocus={focusSub ? "first" : false}activate={activate} closeAll={closeAll}
closeLevel={() => { setOpenSub(null); ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.focus() }}
place={(w, h) => {
const row = ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.getBoundingClientRect()
const vw = document.documentElement.clientWidth, vh = window.innerHeight
if (!row) return { left: 0, top: 0 }
// Beside the parent list (its outer edge), first entry level with the row; flips left at the screen edge.
const list = ref.current!.getBoundingClientRect()
const left = list.right + w <= vw - 4 ? list.right : Math.max(4, list.left - w)
return { left, top: Math.max(4, Math.min(row.top - 5, vh - h - 4)) }
}} />}
</div>
)
}
const role = entry.type === "checkbox" ? "menuitemcheckbox" : entry.type === "radio" ? "menuitemradio" : "menuitem"
const checked = entry.type === "item" ? undefined : entry.checked
return (
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
<button type="button" role={role} className="hm-sw-viewer-menu__item" aria-checked={checked} data-kind={entry.type} {...common}
onClick={() => { if (!disabled) activate(entry) }}>
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">
{entry.type === "item" ? entry.icon : checked ? entry.type === "radio" ? <span className="hm-sw-viewer-menu__dot" /> : <CheckIcon /> : null}
</span>
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
{entry.type === "item" && entry.shortcut && <span className="hm-sw-viewer-menu__shortcut">{entry.shortcut}</span>}
</button>
</div>
)
})
return (
<div ref={ref} id={id} role="menu" tabIndex={-1} aria-label={label} aria-orientation="vertical" className={`hm-sw-theme hm-sw-viewer-menu${className ? ` ${className}` : ""}`} data-surface={surface}
style={{ left: position?.left ?? 0, top: position?.top ?? 0, width, visibility: position ? undefined : "hidden" }}
onKeyDown={onKeyDown}>
{rows.length ? rows : <div role="presentation" className="hm-sw-viewer-menu__label">항목 없음</div>}
</div>
)
}
/**
* SW viewer dropdown (reference DWG/DXF viewer menu): fixed overlay below an anchor (or at a point), kept inside
* the viewport. Items, check / radio items, labels, separators and multi-level submenus come from `entries`.
* Keyboard: ArrowUp / ArrowDown / Home / End move, Enter / Space activate, ArrowRight / ArrowLeft open / close a
* submenu, Escape closes and returns focus to the anchor, Tab closes. A click outside closes.
*/
export function SWViewerMenu({ entries, open, onOpenChange, anchorRef, anchorPoint, align = "start", width, surface = "light", label, onEntrySelect, initialFocus = "first", id, className }: SWViewerMenuProps) {
const rootRef = useRef<HTMLDivElement>(null)
const changeRef = useRef(onOpenChange)
changeRef.current = onOpenChange
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target) || anchorRef?.current?.contains(target)) return
changeRef.current(false)
}
document.addEventListener("pointerdown", onPointerDown, true)
return () => document.removeEventListener("pointerdown", onPointerDown, true)
}, [open, anchorRef])
if (!open) return null
const closeAll = (returnFocus: boolean) => {
onOpenChange(false)
if (returnFocus) anchorRef?.current?.focus()
}
const activate = (entry: SWViewerMenuEntry) => {
if (entry.type === "item" || entry.type === "radio") entry.onSelect?.()
else if (entry.type === "checkbox") entry.onCheckedChange?.(!entry.checked)
onEntrySelect?.(entry)
if (!("keepOpen" in entry && entry.keepOpen)) closeAll(true)
}
const place = (w: number, h: number) => {
const vw = document.documentElement.clientWidth, vh = window.innerHeight
const box = anchorPoint ? { left: anchorPoint.x, right: anchorPoint.x, top: anchorPoint.y, bottom: anchorPoint.y }
: anchorRef?.current?.getBoundingClientRect() ?? { left: 0, right: 0, top: 0, bottom: 0 }
const start = align === "end" ? box.right - w : box.left
const left = Math.max(4, Math.min(start, vw - w - 4))
// 4px under a button; exactly at the pointer for a context menu.
const gap = anchorPoint ? 0 : 4
const below = box.bottom + gap
const top = below + h > vh - 4 && box.top - h - gap >= 4 ? box.top - h - gap : Math.max(4, Math.min(below, vh - h - 4))
return { left, top }
}
return (
<div ref={rootRef} className="hm-sw-viewer-menu-root">
<MenuList entries={entries} surface={surface} width={width} label={label} place={place} activate={activate} closeAll={closeAll} autoFocus={initialFocus} id={id} className={className} />
</div>
)
}
@@ -0,0 +1,236 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import "../../theme/sw-viewer.css"
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title (14px 700 #FFC600, ellipsis). Also the accessible name. */
title: string
/** 16px title icon supplied by the program. */
icon?: ReactNode
/** Shown when true. Unmounted otherwise (no hidden DOM). */
open: boolean
/** Close button, and Escape while focus is inside the panel. */
onClose: () => void
closeLabel?: string
/** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */
headerActions?: ReactNode
/** Free body content: SWViewerSection / SWViewerField / lists / any component. The body scrolls. */
children?: ReactNode
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
footer?: ReactNode
/** Initial size. Default 320 x 420 (reference panel). `height: "auto"` follows the content. */
width?: number
height?: number | "auto"
minWidth?: number
minHeight?: number
/** Initial position: a point, or a placement inside the bounds. Default `top-end`. */
defaultPosition?: { left: number; top: number } | SWViewerPanelPlacement
/** Placement insets in px. Default `{ top: 56, side: 24 }` (below the 52px viewer header). */
inset?: { top?: number; side?: number }
/** `fixed` (default): the viewport. `absolute`: the nearest positioned ancestor (embedded preview). */
strategy?: "fixed" | "absolute"
/** Drag by the title bar. Default true. */
draggable?: boolean
/** Corner grip resize (min / max size, kept inside the bounds). Default true. */
resizable?: boolean
/** Collapse button and title double click. Default true. */
collapsible?: boolean
defaultCollapsed?: boolean
collapsed?: boolean
onCollapsedChange?: (collapsed: boolean) => void
/** Dim backdrop, focus trap and centered default (input / confirm popups). Default false. */
modal?: boolean
/** Focus the panel when it opens; focus returns to the previous element when it closes. Default true. */
autoFocus?: boolean
onPositionChange?: (position: { left: number; top: number }) => void
/** After a corner resize. */
onSizeChange?: (size: { width: number; height: number }) => void
}
let topLayer = 30
const EDGE = 10
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
const Chevron = ({ down }: { down: boolean }) => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d={down ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4"} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
function bounds(element: HTMLElement | null, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null
return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight }
}
function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed" | "absolute") {
const box = bounds(element, strategy)
return {
left: Math.round(Math.min(Math.max(left, EDGE), Math.max(EDGE, box.width - element.offsetWidth - EDGE))),
top: Math.round(Math.min(Math.max(top, EDGE), Math.max(EDGE, box.height - element.offsetHeight - EDGE))),
}
}
/**
* SW viewer popup / settings window (reference DWG/DXF viewer floating panel): #E0DACE body, 2px #897445 border,
* #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by
* default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the
* last touched panel comes to the front. The frame is fixed; `children` / `footer` are free.
*/
export function SWViewerPanel({
title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange,
modal = false, autoFocus = true, onPositionChange, onSizeChange, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, ...rest
}: SWViewerPanelProps) {
const ref = useRef<HTMLDivElement>(null)
const titleId = useId()
const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}`
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [layer, setLayer] = useState(() => ++topLayer)
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
const collapsed = collapsible && (collapsedProp ?? internalCollapsed)
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
// Size after a corner resize (null: the width / height props).
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
const resize = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
const positionRef = useRef(position)
positionRef.current = position
const placement = defaultPosition ?? (modal ? "center" : "top-end")
// Initial position, measured once per opening; then kept inside the bounds when they change.
useLayoutEffect(() => {
if (!open) { setPosition(null); setSize(null); return }
const panel = ref.current
if (!panel) return
const box = bounds(panel, strategy)
const top = inset?.top ?? 56, side = inset?.side ?? 24
const start = typeof placement === "object" ? placement
: placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 }
: { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top }
setPosition(clamp(start.left, start.top, panel, strategy))
const keepInside = () => { const current = positionRef.current; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, strategy)) }
window.addEventListener("resize", keepInside)
const parent = strategy === "absolute" ? panel.offsetParent : null
const observer = parent ? new ResizeObserver(keepInside) : null
if (parent) observer?.observe(parent)
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
}, [open])
// Focus in once positioned (a hidden panel cannot take focus), back to the opener on close.
const placed = position !== null
useEffect(() => {
if (!open || !autoFocus) return
const previous = document.activeElement as HTMLElement | null
return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) }
}, [open, autoFocus])
useEffect(() => {
if (open && autoFocus && placed && !ref.current?.contains(document.activeElement)) ref.current?.focus({ preventScroll: true })
}, [open, autoFocus, placed])
if (!open) return null
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) }
const onTitlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return
drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top }
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.dataset.dragging = ""
}
const onTitlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = drag.current
if (!start || !ref.current) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy))
}
const onTitlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!drag.current) return
drag.current = null
delete event.currentTarget.dataset.dragging
if (positionRef.current) onPositionChange?.(positionRef.current)
}
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !ref.current) return
event.preventDefault()
resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight }
event.currentTarget.setPointerCapture(event.pointerId)
}
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = resize.current, panel = ref.current
if (!start || !panel || !position) return
const box = bounds(panel, strategy)
// Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner.
const maxWidth = Math.min(box.width * 0.9, box.width - position.left - EDGE), maxHeight = Math.min(box.height * 0.9, box.height - position.top - EDGE)
setSize({
width: Math.round(Math.max(minWidth, Math.min(maxWidth, start.width + event.clientX - start.x))),
height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))),
})
}
const onGripPointerUp = () => {
if (!resize.current) return
resize.current = null
if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight })
}
const onPanelKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || event.nativeEvent.isComposing) return
if (event.key === "Escape") { event.stopPropagation(); onClose(); return }
if (modal && event.key === "Tab" && ref.current) {
const focusables = Array.from(ref.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
}
const frameStyle: CSSProperties = {
position: strategy,
left: position?.left ?? 0,
top: position?.top ?? 0,
zIndex: layer,
width: size?.width ?? width,
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
minWidth,
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
visibility: position ? undefined : "hidden",
...style,
}
const panel = (
<div ref={ref} role="dialog" aria-modal={modal} aria-labelledby={titleId} tabIndex={-1}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} data-collapsed={collapsed || undefined} data-modal={modal || undefined}
style={frameStyle} {...rest}
onKeyDown={onPanelKeyDown}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}>
<div className="hm-sw-viewer-panel__titlebar" data-draggable={draggable || undefined}
onPointerDown={onTitlePointerDown} onPointerMove={onTitlePointerMove} onPointerUp={onTitlePointerUp} onPointerCancel={onTitlePointerUp}
onDoubleClick={event => { if (collapsible && !(event.target as HTMLElement).closest(INTERACTIVE)) setCollapsed(!collapsed) }}>
<h2 id={titleId} className="hm-sw-viewer-panel__title" title={title}>
{icon && <span className="hm-sw-viewer-panel__title-icon" aria-hidden="true">{icon}</span>}
<span className="hm-sw-viewer-panel__title-text">{title}</span>
</h2>
<div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
<button type="button" className="hm-sw-viewer-panel__close" aria-label={closeLabel} title={closeLabel} onClick={onClose}>
<svg className="hm-sw-viewer-panel__close-shape" viewBox="0 0 52 34" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<radialGradient id={closeGradient} cx="55%" cy="30%" r="85%">
<stop offset="0%" stopColor="var(--hm-sw-viewer-close-glow)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-close-edge)" />
</radialGradient>
</defs>
<path d="M 0 0 C 12 0 14 34 26 34 H 52 V 0 Z" fill={`url(#${closeGradient})`} />
<path className="hm-sw-viewer-panel__close-curve" d="M 0 0 C 12 0 14 34 26 34" fill="none" vectorEffect="non-scaling-stroke" />
</svg>
<svg className="hm-sw-viewer-panel__close-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
</button>
</div>
</div>
{!collapsed && <div className="hm-sw-viewer-panel__body">{children}</div>}
{!collapsed && footer && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</div>
)
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
}
@@ -0,0 +1,9 @@
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
export {
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
type SWViewerSectionProps, type SWViewerFieldProps, type SWViewerSwitchProps, type SWViewerSliderProps, type SWViewerInputProps,
type SWViewerButtonProps, type SWViewerBadgeProps, type SWViewerStatusProps, type SWViewerStatusState,
} from "./SWViewerControls"
export type { SWViewerHeaderItem, SWViewerHeaderGroup, SWViewerMenuEntry, SWViewerCommand } from "./types"
@@ -0,0 +1,131 @@
import type { ReactNode } from "react"
/** One entry of an SW viewer dropdown (header menu, More, context menu). Data only: the host runs the command. */
export type SWViewerMenuEntry =
| {
type: "item"
id: string
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/** Right-aligned hint, e.g. `F`. */
shortcut?: string
disabled?: boolean
hidden?: boolean
/** Keep the menu open after selecting. Default false. */
keepOpen?: boolean
onSelect?: () => void
}
| {
type: "checkbox"
id: string
label: string
checked: boolean
disabled?: boolean
hidden?: boolean
keepOpen?: boolean
onCheckedChange?: (checked: boolean) => void
}
| {
type: "radio"
id: string
label: string
checked: boolean
disabled?: boolean
hidden?: boolean
keepOpen?: boolean
onSelect?: () => void
}
| {
/** Multi-level menu: opens to the side (hover, Enter / Space / ArrowRight; ArrowLeft closes). */
type: "submenu"
id: string
label: string
icon?: ReactNode
disabled?: boolean
hidden?: boolean
entries: SWViewerMenuEntry[]
}
| { type: "label"; id: string; label: string; hidden?: boolean }
| { type: "separator"; id: string; hidden?: boolean }
/** Command event of SWViewerHeader / SWViewerMenu: the one place a program wires every menu action. */
export interface SWViewerCommand {
/** Header item id, or menu entry id for dropdown entries. */
id: string
/** Header item that owns the command (equals `id` for header buttons, toggles, tabs, switches). */
itemId: string
/** New state: `pressed` / `checked` (boolean) or the selected tab value (string). */
value?: boolean | string
}
interface SWViewerHeaderItemBase {
id: string
/** Visible text; also the accessible name (icon-only buttons keep it as aria-label). */
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/** Tooltip (title attribute). */
title?: string
disabled?: boolean
/** Removed from the header and from More. */
hidden?: boolean
/** Header width (px) below which the item moves into the More dropdown. */
collapseBelow?: number
/** Header width (px) below which only the icon is shown (label kept as the accessible name). */
iconOnlyBelow?: number
/** Always icon only (e.g. the info button). Needs `icon`. */
iconOnly?: boolean
/** Section title of this item inside More (menu and tabs items only). Default `label`. */
moreLabel?: string
}
export type SWViewerHeaderItem =
| (SWViewerHeaderItemBase & { type: "button"; onSelect?: () => void })
| (SWViewerHeaderItemBase & { type: "toggle"; pressed: boolean; onPressedChange?: (pressed: boolean) => void })
| (SWViewerHeaderItemBase & {
/** Button with a dropdown (chevron). */
type: "menu"
entries: SWViewerMenuEntry[]
/** Dropdown width in px. Default: its content (min 160). */
menuWidth?: number
/** Dropdown edge aligned to the button. Default `start`. */
menuAlign?: "start" | "end"
})
| (SWViewerHeaderItemBase & {
/** Single selection such as Model / Layout (radiogroup, arrow keys move). */
type: "tabs"
value: string | null
options: { value: string; label: string; count?: number; title?: string; disabled?: boolean }[]
onValueChange?: (value: string) => void
})
| (SWViewerHeaderItemBase & {
/** Caption above a switch, e.g. 2.5D. */
type: "switch"
checked: boolean
onCheckedChange?: (checked: boolean) => void
})
| {
/** Free content in the header (program-specific control). */
type: "custom"
id: string
hidden?: boolean
collapseBelow?: number
render: () => ReactNode
/** How the item appears in More once collapsed. Default: not shown there. */
menuEntries?: SWViewerMenuEntry[]
}
export interface SWViewerHeaderGroup {
id: string
/** Accessible group name. Also the More section title of its buttons, toggles and switches. */
label: string
/** More section title when it differs from `label`. */
moreLabel?: string
/** `plain` (default): no face. `field`: the inset dark property surface (user-confirmed values). */
surface?: "plain" | "field"
/** `end`: the trailing action area at the right end of the object surface. Default `start`. */
placement?: "start" | "end"
hidden?: boolean
items: SWViewerHeaderItem[]
}
+3
View File
@@ -10,3 +10,6 @@ export {
SWPopup, SWPopupSection, SWPopupRow,
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
} from "./components/sw-popup/SWPopup"
// SW viewer mode (2026-10-19): viewer header, dropdown, popup panel and panel controls.
export * from "./components/sw-viewer"
+549
View File
@@ -0,0 +1,549 @@
/* @hm-design/sw — SW viewer mode (reference: DWG/DXF viewer top menu and floating panels, measured 2026-10-19).
Viewer-only tokens. The three header surfaces reuse the user-confirmed SW tokens of sw.css
(--hm-sw-header-document-* / object-* / property-*). Never touches global selectors or the SWHeader. */
.hm-sw-header,
.hm-sw-theme {
/* Header */
--hm-sw-viewer-height: 52px;
--hm-sw-viewer-background: linear-gradient(180deg, #266651 0%, #123328 45%, #0a221d 100%);
--hm-sw-viewer-border-color: #071e18;
--hm-sw-viewer-brand-width: 72px;
/* No logo: no text or icon is drawn; the cap keeps only its slanted edge (no wide empty space). */
--hm-sw-viewer-brand-empty-width: 24px;
--hm-sw-viewer-brand-top: #123328;
--hm-sw-viewer-brand-middle: #205241;
--hm-sw-viewer-brand-bottom: #071e18;
--hm-sw-viewer-text: #fff;
--hm-sw-viewer-muted: #bddfd4;
--hm-sw-viewer-accent: #ffc600;
--hm-sw-viewer-hover: #266651;
--hm-sw-viewer-gap: 4px;
--hm-sw-viewer-radius: 4px;
--hm-sw-viewer-control-height: 40px;
--hm-sw-viewer-button-height: 34px;
--hm-sw-viewer-font-size: 14px;
--hm-sw-viewer-label-size: 11px;
--hm-sw-viewer-icon-size: 16px;
--hm-sw-viewer-disabled-opacity: 0.6;
--hm-sw-viewer-switch-off: #0a221d;
--hm-sw-viewer-switch-bevel: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 1px rgba(0, 0, 0, 0.25);
/* Icon stroke: the SWHeader icon assets (print / K guide / layer icons) are a fill + a 1px black OUTSIDE stroke
(Figma mask "outside" path) + a 0-offset glow (stdDeviation 1.615, #061A14). Program icons are free SVGs, so the
same stroke is a drop-shadow chain that follows the icon's own contour: the four 1px shadows dilate the shape
by 1px (each one also shadows the previous ones, so corners close), the last one is the glow. */
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
--hm-sw-viewer-icon-glow: #061a14;
--hm-sw-viewer-icon-outline:
drop-shadow(1px 0 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(-1px 0 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 1px 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(0 -1px 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 0 1.615px var(--hm-sw-viewer-icon-glow));
/* Dropdown (light) and More (beige) */
--hm-sw-viewer-menu-background: #fff;
--hm-sw-viewer-menu-border-color: #dedbd2;
--hm-sw-viewer-menu-text: #0a221d;
--hm-sw-viewer-menu-muted: #6e6759;
--hm-sw-viewer-menu-highlight: rgba(189, 223, 212, 0.45);
--hm-sw-viewer-menu-highlight-text: #123328;
--hm-sw-viewer-menu-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
--hm-sw-viewer-menu-item-height: 36px;
--hm-sw-viewer-more-background: #f5f0e5;
--hm-sw-viewer-more-border-color: #897445;
/* Panel */
--hm-sw-viewer-panel-background: #e0dace;
--hm-sw-viewer-panel-field-background: #f5f0e5;
--hm-sw-viewer-panel-border: #897445;
--hm-sw-viewer-panel-inner-border: #9a9284;
--hm-sw-viewer-panel-title-background: #221d13;
--hm-sw-viewer-panel-title-highlight: #a98d53;
--hm-sw-viewer-panel-title-color: #ffc600;
--hm-sw-viewer-panel-title-button-hover: #604f32;
--hm-sw-viewer-panel-text: #1d1810;
--hm-sw-viewer-panel-heading: #3e3523;
--hm-sw-viewer-panel-label: #6e6759;
--hm-sw-viewer-panel-spacing: 12px;
--hm-sw-viewer-panel-title-height: 34px;
--hm-sw-viewer-panel-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
--hm-sw-viewer-panel-primary: #123328;
--hm-sw-viewer-panel-switch-off: #9a9284;
--hm-sw-viewer-panel-switch-on: #266651;
--hm-sw-viewer-panel-badge-background: #f4f1ea;
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
/* Close: green curve, #E40000 on hover (user-supplied reference value). */
--hm-sw-viewer-close-glow: #123328;
--hm-sw-viewer-close-edge: #020a07;
--hm-sw-viewer-close-hover: #e40000;
--hm-sw-viewer-close-icon: #999;
}
.hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---------- Header ---------- */
.hm-sw-viewer-header {
container: hm-sw-viewer / inline-size;
position: relative;
box-sizing: border-box;
display: flex;
align-items: flex-start;
gap: 2px;
width: 100%;
min-width: 0;
height: var(--hm-sw-viewer-height);
border: 1px solid var(--hm-sw-viewer-border-color);
background: var(--hm-sw-viewer-background);
color: var(--hm-sw-viewer-text);
font-size: var(--hm-sw-viewer-font-size);
font-weight: 500;
line-height: 20px;
}
.hm-sw-viewer-header__brand {
position: relative;
z-index: 2;
display: flex;
align-items: center;
flex: 0 0 auto;
width: var(--hm-sw-viewer-brand-empty-width);
height: calc(var(--hm-sw-viewer-height) - 2px);
margin-right: -2px;
font-weight: 700;
}
/* With a logo: the reference 72px cap (logo at 24px), wider only for a wider logo. Without: the slanted edge only. */
.hm-sw-viewer-header__brand[data-logo] { width: auto; min-width: var(--hm-sw-viewer-brand-width); padding: 0 25px 0 24px; }
button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header__brand-shape { position: absolute; inset: 0 auto 0 0; width: calc(100% + 16px); height: 100%; pointer-events: none; }
.hm-sw-viewer-header__brand-mark { position: relative; display: inline-flex; align-items: center; }
.hm-sw-viewer-header__brand-mark > :is(img, svg) { display: block; max-height: 24px; width: auto; }
.hm-sw-viewer-header__document {
display: flex;
align-items: center;
flex: 0 1 clamp(140px, 25cqw, 360px);
width: clamp(220px, 25cqw, 360px);
min-width: 56px;
height: calc(var(--hm-sw-viewer-height) - 2px);
padding: 0 16px 0 24px;
border: 1px solid var(--hm-sw-header-document-border-color);
border-left: 0;
background: var(--hm-sw-header-document-background);
}
.hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.hm-sw-viewer-header__object {
display: flex;
align-items: center;
flex: 1 0 auto;
min-width: 0;
height: calc(var(--hm-sw-viewer-height) - 2px);
gap: var(--hm-sw-viewer-gap);
padding-inline: var(--hm-sw-viewer-gap);
border: 1px solid var(--hm-sw-header-object-border-color);
border-radius: var(--hm-sw-header-object-radius);
background: var(--hm-sw-header-object-background);
}
.hm-sw-viewer-header__group {
display: flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
min-height: var(--hm-sw-viewer-control-height);
padding-inline: var(--hm-sw-viewer-gap);
}
.hm-sw-viewer-header__group[data-surface="field"] {
border: 1px solid var(--hm-sw-header-property-border-color);
border-radius: var(--hm-sw-header-property-radius);
background: var(--hm-sw-header-property-background);
box-shadow: var(--hm-sw-header-property-shadow);
}
.hm-sw-viewer-header__actions {
display: flex;
align-items: center;
align-self: stretch;
gap: var(--hm-sw-viewer-gap);
margin-left: auto;
padding-left: 6px;
}
/* Buttons, menu buttons and toggles: transparent, #266651 hover, #FFC600 pressed / open / on (no selected box). */
.hm-sw-viewer-header .hm-sw-viewer-header__button {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 8px;
height: var(--hm-sw-viewer-button-height);
padding: 6px 14px;
border: 1px solid transparent;
border-radius: var(--hm-sw-viewer-radius);
color: var(--hm-sw-viewer-text);
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-kind="toggle"] { height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only] { min-width: var(--hm-sw-viewer-button-height); padding: 0 8px; }
/* Always-icon buttons (info, More): the reference 32 x 40 icon button. */
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only="always"] { min-width: 32px; width: 32px; height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
.hm-sw-viewer-header .hm-sw-viewer-header__button:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
.hm-sw-viewer-header .hm-sw-viewer-header__button:active:not(:disabled),
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]) { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]):not(:hover) { background: transparent; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab):disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-header__icon > svg, .hm-sw-viewer-header__chevron > svg { width: 100%; height: 100%; }
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. */
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
display: inline-flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
height: var(--hm-sw-viewer-control-height);
padding: 0 8px;
border: 1px solid transparent;
border-radius: var(--hm-sw-viewer-radius);
color: var(--hm-sw-viewer-text);
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.hm-sw-viewer-header .hm-sw-viewer-header__tab:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-header__count { font-size: var(--hm-sw-viewer-label-size); font-weight: 400; font-variant-numeric: tabular-nums; }
/* Switch fields (2.5D, 어두운 배경): 11px muted caption above the switch. */
.hm-sw-viewer-header__switch-field {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--hm-sw-viewer-gap);
min-height: var(--hm-sw-viewer-control-height);
padding-inline: 8px;
}
.hm-sw-viewer-header__switch-label { color: var(--hm-sw-viewer-muted); font-size: var(--hm-sw-viewer-label-size); font-weight: 400; line-height: 1; white-space: nowrap; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible {
outline: 2px solid var(--hm-sw-viewer-accent);
outline-offset: 2px;
}
/* Text and icon stroke (2026-10-19 request): the SWHeader black outline on the viewer header, same tokens and
method — an outside outline of the glyph / icon shape, never a box border.
Text: 14px = --hm-sw-header-text-outline (1.3px black), 11px captions / counts / status = --hm-sw-header-label-outline
(1px black), as SWHeader's 12px labels. Icons: --hm-sw-viewer-icon-outline (see the token). Fill color, size,
weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
.hm-sw-viewer-header :is(
.hm-sw-viewer-header__brand-mark, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab
) {
text-shadow: var(--hm-sw-header-text-outline);
}
.hm-sw-viewer-header :is(.hm-sw-viewer-header__count, .hm-sw-viewer-header__switch-label, .hm-sw-viewer-status__label) {
text-shadow: var(--hm-sw-header-label-outline);
}
.hm-sw-viewer-header :is(.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron, .hm-sw-viewer-status > svg) {
filter: var(--hm-sw-viewer-icon-outline);
}
/* Clipped (ellipsis) file name: the outline may paint 2px outside the box instead of being cut. */
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
}
/* Status */
.hm-sw-viewer-status {
display: flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
padding-inline: 8px;
color: var(--hm-sw-viewer-muted);
font-size: var(--hm-sw-viewer-label-size);
font-weight: 400;
white-space: nowrap;
}
.hm-sw-viewer-status > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-status[data-state="error"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-status__spin { animation: none; } }
/* Narrow header (reference breakpoints, measured on the header itself). */
@container hm-sw-viewer (max-width: 1179.98px) {
.hm-sw-viewer-header__object { gap: 2px; padding-inline: 2px; }
.hm-sw-viewer-header__group { gap: 2px; padding-inline: 2px; }
.hm-sw-viewer-header__actions { padding-left: 2px; gap: 2px; }
.hm-sw-viewer-status { padding-inline: 2px; }
}
@container hm-sw-viewer (max-width: 559.98px) {
.hm-sw-viewer-header__document { padding-right: 8px; }
.hm-sw-viewer-status__label { display: none; }
}
/* ---------- Switch (header / panel faces) ---------- */
.hm-sw-theme .hm-sw-viewer-switch {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: 36px;
height: 20px;
padding: 2px;
border-radius: 9999px;
background: var(--hm-sw-viewer-switch-off);
box-shadow: var(--hm-sw-viewer-switch-bevel);
transition: background-color 0.15s;
}
.hm-sw-viewer-switch__thumb { display: block; width: 16px; height: 16px; border-radius: 9999px; background: var(--hm-sw-viewer-text); transition: transform 0.15s; }
.hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-switch[data-state="checked"] .hm-sw-viewer-switch__thumb { transform: translateX(16px); background: var(--hm-sw-viewer-switch-off); }
.hm-sw-theme .hm-sw-viewer-switch:disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
.hm-sw-viewer-panel .hm-sw-viewer-switch { background: var(--hm-sw-viewer-panel-switch-off); box-shadow: none; }
.hm-sw-viewer-panel .hm-sw-viewer-switch .hm-sw-viewer-switch__thumb { background: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-panel-switch-on); }
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-switch, .hm-sw-viewer-switch__thumb { transition: none; } }
/* ---------- Dropdown ---------- */
.hm-sw-viewer-menu {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 160px;
max-width: calc(100vw - 8px);
max-height: min(640px, calc(100dvh - 8px));
overflow-y: auto;
padding: 4px;
border: 1px solid var(--hm-sw-viewer-menu-border-color);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-menu-background);
box-shadow: var(--hm-sw-viewer-menu-shadow);
color: var(--hm-sw-viewer-menu-text);
font-size: 14px;
font-weight: 400;
line-height: 20px;
outline: none;
}
.hm-sw-viewer-menu[data-surface="beige"] { background: var(--hm-sw-viewer-more-background); border-color: var(--hm-sw-viewer-more-border-color); }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: var(--hm-sw-viewer-menu-item-height);
padding: 0 12px;
border-radius: 2px;
color: inherit;
text-align: left;
white-space: nowrap;
outline: none;
}
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) { background: var(--hm-sw-viewer-menu-highlight); color: var(--hm-sw-viewer-menu-highlight-text); }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item[aria-disabled] { opacity: 0.5; cursor: not-allowed; }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:focus-visible { outline: none; }
.hm-sw-viewer-menu__icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-viewer-menu__icon:empty { display: none; }
.hm-sw-viewer-menu__item[role="menuitemcheckbox"] .hm-sw-viewer-menu__icon,
.hm-sw-viewer-menu__item[role="menuitemradio"] .hm-sw-viewer-menu__icon { display: inline-flex; }
.hm-sw-viewer-menu__icon > svg { width: 16px; height: 16px; }
.hm-sw-viewer-menu__dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; }
.hm-sw-viewer-menu__text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.hm-sw-viewer-menu__shortcut { margin-left: auto; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; letter-spacing: 0.05em; }
.hm-sw-viewer-menu__chevron { display: inline-flex; margin-left: auto; color: var(--hm-sw-viewer-menu-muted); }
.hm-sw-viewer-menu__label { padding: 6px 12px; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; font-weight: 500; line-height: 18px; }
.hm-sw-viewer-menu__separator { flex: 0 0 1px; height: 1px; margin: 4px -4px; background: var(--hm-sw-viewer-menu-border-color); }
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
/* ---------- Panel ---------- */
.hm-sw-viewer-panel {
display: flex;
flex-direction: column;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
border: 2px solid var(--hm-sw-viewer-panel-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-background);
box-shadow: var(--hm-sw-viewer-panel-shadow), inset 0 0 0 1px var(--hm-sw-viewer-panel-field-background);
color: var(--hm-sw-viewer-panel-text);
font-size: 14px;
font-weight: 400;
line-height: 20px;
outline: none;
}
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
.hm-sw-viewer-panel__titlebar {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
min-height: var(--hm-sw-viewer-panel-title-height);
padding-left: var(--hm-sw-viewer-panel-spacing);
border-bottom: 1px solid var(--hm-sw-viewer-panel-border);
background: var(--hm-sw-viewer-panel-title-background);
box-shadow: inset 0 1px 0 var(--hm-sw-viewer-panel-title-highlight);
user-select: none;
}
.hm-sw-viewer-panel__titlebar[data-draggable] { cursor: grab; touch-action: none; }
.hm-sw-viewer-panel__titlebar[data-dragging] { cursor: grabbing; }
.hm-sw-viewer-panel__title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
margin: 0;
color: var(--hm-sw-viewer-panel-title-color);
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
.hm-sw-viewer-panel__title-icon { display: inline-flex; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-viewer-panel__title-icon > svg { width: 100%; height: 100%; }
.hm-sw-viewer-panel__title-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.hm-sw-viewer-panel__actions { display: flex; flex-shrink: 0; align-items: center; }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--hm-sw-viewer-panel-title-height);
height: var(--hm-sw-viewer-panel-title-height);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--hm-sw-viewer-muted);
}
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: var(--hm-sw-viewer-panel-title-button-hover); color: var(--hm-sw-viewer-panel-title-color); }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"], .hm-sw-viewer-panel__close):focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-title-color); outline-offset: -3px; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: calc(var(--hm-sw-viewer-panel-title-height) * 1.53);
height: var(--hm-sw-viewer-panel-title-height);
padding: 0 0 0 calc(var(--hm-sw-viewer-panel-title-height) * 0.7);
overflow: hidden;
color: var(--hm-sw-viewer-close-icon);
}
.hm-sw-viewer-panel__close-shape { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.hm-sw-viewer-panel__close-curve { stroke: var(--hm-sw-viewer-panel-border); stroke-width: 1; }
.hm-sw-viewer-panel__close-icon { position: relative; width: 16px; height: 16px; pointer-events: none; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:focus-visible { --hm-sw-viewer-close-glow: #266651; color: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:is(:hover, :active) { --hm-sw-viewer-close-glow: var(--hm-sw-viewer-close-hover); --hm-sw-viewer-close-edge: var(--hm-sw-viewer-close-hover); color: #fff; }
.hm-sw-viewer-panel__body {
display: flex;
flex-direction: column;
gap: 20px;
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: var(--hm-sw-viewer-panel-spacing);
scrollbar-width: thin;
scrollbar-color: var(--hm-sw-viewer-panel-inner-border) var(--hm-sw-viewer-panel-background);
}
.hm-sw-viewer-panel__footer {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
padding: 8px var(--hm-sw-viewer-panel-spacing);
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
background: var(--hm-sw-viewer-panel-background);
}
/* Panel content */
.hm-sw-viewer-section { display: flex; flex-direction: column; gap: 12px; }
.hm-sw-viewer-section[data-surface="card"] {
padding: var(--hm-sw-viewer-panel-spacing);
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-field-background);
box-shadow: inset 0 1px 0 #fff;
}
.hm-sw-viewer-section__title { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; font-weight: 700; line-height: 16px; letter-spacing: 0.025em; text-transform: uppercase; }
.hm-sw-viewer-hint { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; line-height: 16px; }
.hm-sw-viewer-field { display: flex; flex-direction: column; gap: 6px; }
.hm-sw-viewer-field[data-disabled] { opacity: 0.5; pointer-events: none; }
.hm-sw-viewer-field__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 20px; }
.hm-sw-viewer-field__label { min-width: 0; color: var(--hm-sw-viewer-panel-label); font-size: 14px; font-weight: 500; line-height: 20px; }
.hm-sw-viewer-field__control { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.hm-sw-viewer-field__unit { color: var(--hm-sw-viewer-panel-label); font-size: 12px; }
.hm-sw-theme .hm-sw-viewer-input {
box-sizing: border-box;
width: 80px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-field-background);
color: var(--hm-sw-viewer-panel-text);
font: inherit;
font-size: 12px;
line-height: 18px;
}
.hm-sw-theme .hm-sw-viewer-input:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: -1px; }
.hm-sw-theme .hm-sw-viewer-input:disabled { opacity: 0.5; }
.hm-sw-theme .hm-sw-viewer-slider {
--hm-sw-viewer-slider-percent: 0%;
width: 100%;
height: 18px;
margin: 0;
background: transparent;
appearance: none;
cursor: pointer;
}
.hm-sw-viewer-slider::-webkit-slider-runnable-track { height: 6px; border-radius: 9999px; background: linear-gradient(90deg, var(--hm-sw-viewer-panel-switch-on) var(--hm-sw-viewer-slider-percent), var(--hm-sw-viewer-panel-background) var(--hm-sw-viewer-slider-percent)); }
.hm-sw-viewer-slider::-moz-range-track { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-background); }
.hm-sw-viewer-slider::-moz-range-progress { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-switch-on); }
.hm-sw-viewer-slider::-webkit-slider-thumb { width: 18px; height: 18px; margin-top: -6px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; appearance: none; }
.hm-sw-viewer-slider::-moz-range-thumb { box-sizing: border-box; width: 18px; height: 18px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; }
.hm-sw-theme .hm-sw-viewer-slider:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 2px; }
.hm-sw-theme .hm-sw-viewer-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--hm-sw-viewer-panel-primary);
border-radius: var(--hm-sw-viewer-radius);
background: #fff;
color: var(--hm-sw-viewer-panel-primary);
font-size: 12px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
}
.hm-sw-theme .hm-sw-viewer-button[data-variant="outline"]:hover:not(:disabled) { background: var(--hm-sw-viewer-panel-field-background); }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"] { background: var(--hm-sw-viewer-panel-primary); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"]:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); border-color: var(--hm-sw-viewer-hover); }
.hm-sw-theme .hm-sw-viewer-button:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 1px; }
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }
.hm-sw-viewer-button__icon { display: inline-flex; width: 16px; height: 16px; }
.hm-sw-viewer-button__icon > svg { width: 100%; height: 100%; }
.hm-sw-viewer-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--hm-sw-viewer-radius); background: var(--hm-sw-viewer-panel-badge-background); color: var(--hm-sw-viewer-panel-label); font-size: 12px; font-weight: 500; line-height: 18px; font-variant-numeric: tabular-nums; }
/* Corner resize grip (replaces native resize: same corner and glyph idea, works in every browser and with
pointer capture; the size stays within min / 90% of the bounds / the bounds edge). */
.hm-sw-viewer-panel__resize {
position: absolute;
right: 0;
bottom: 0;
z-index: 1;
width: 14px;
height: 14px;
cursor: nwse-resize;
touch-action: none;
background:
linear-gradient(135deg, transparent 0 55%, var(--hm-sw-viewer-panel-inner-border) 55% 62%, transparent 62% 72%, var(--hm-sw-viewer-panel-inner-border) 72% 79%, transparent 79%);
}
@@ -14,6 +14,9 @@ export const inventorySignature = entries => entries.map(entry => [entry.id, ent
// SW/Header next to its 10 retained Stories, the rest under SW/Tool and SW/Popup), and the only package files it changed.
// The two Header Stories sit directly under SW/Header (no separate Options menu), in src/sw/sw-header-open.stories.tsx.
export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidden-sub-controls', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
// 2026-10-19 (viewer worktree, user request): the SW/Tool Story menu was removed; SW/Popup (the common SWPopup
// examples) stays. SWToolButton / SWToolGroup stay public; integrated-improvement.browser.mjs opening this id is historical evidence only.
export const retiredToolIds = ["sw-tool--tool-buttons"]
export const integratedChangedFiles = [
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
@@ -0,0 +1,47 @@
// Regression for the SW viewer work (2026-10-19): the existing SW/Header Stories must render pixel-identical
// before (base Storybook) and after (this branch) at several widths.
// Usage: node packages/sw/tests/viewer-regression.browser.mjs <base-storybook-url> <new-storybook-url> <out-dir>
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const [base, next, outArg] = process.argv.slice(2)
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression')
await mkdir(out, { recursive: true })
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons']
const widths = [1920, 1180, 768]
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const shot = async (url, width) => {
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
await page.goto(url)
await page.waitForSelector('#storybook-root > *')
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(400)
const buffer = await page.screenshot({ fullPage: true, animations: 'disabled' })
await page.close()
return PNG.sync.read(buffer)
}
const results = []
for (const id of ids) for (const width of widths) {
const url = root => `${root}/iframe.html?id=sw-header--${id}&viewMode=story`
const [a, b] = [await shot(url(base), width), await shot(url(next), width)]
const same = a.width === b.width && a.height === b.height
let diff = same ? 0 : -1
if (same) {
const image = new PNG({ width: a.width, height: a.height })
diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
if (diff) await writeFile(path.join(out, `${id}-${width}-diff.png`), PNG.sync.write(image))
}
results.push({ id, width, size: `${a.width}x${a.height} / ${b.width}x${b.height}`, diffPixels: diff, result: diff === 0 ? 'PASS' : 'FAIL' })
console.log(`${diff === 0 ? 'PASS' : 'FAIL'} sw-header--${id} @${width} diff ${diff}`)
}
await browser.close()
await writeFile(path.join(out, 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
const failed = results.filter(item => item.result !== 'PASS').length
console.log(`${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)
+391
View File
@@ -0,0 +1,391 @@
// SW viewer mode verification (2026-10-19).
// 1) Captures the reference DWG/DXF viewer and the SW/Viewer Stories at the same resolution (1440 x 900).
// 2) Runs interaction checks on the Stories (menus, keyboard, Escape, outside click, panels, More, modal).
// Usage: node packages/sw/tests/viewer.browser.mjs <storybook-url> <reference-url|-> <out-dir>
// e.g. node packages/sw/tests/viewer.browser.mjs http://127.0.0.1:6034 http://127.0.0.1:5182 docs/sw/viewer-qa
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const [storybook, reference, outArg] = process.argv.slice(2)
const out = path.resolve(outArg ?? 'docs/sw/viewer-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 ? ` — ${detail}` : ''}`) }
const errors = []
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story`
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}`))
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
return page
}
// Test-only: lift the 1280px Story page envelope so the 1440px frames are measured at full width.
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 log = page => page.locator('[data-callback-log]').first().innerText()
const box = locator => locator.boundingBox()
// ---------- 1. Same-resolution captures ----------
const geometry = async (page, selectors) => page.evaluate(selectors => {
const header = document.querySelector(selectors.header)
const base = header.getBoundingClientRect()
return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...document.querySelectorAll(selector)].map(element => {
const rect = element.getBoundingClientRect()
return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) }
})]))
}, selectors)
const parts = {
reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } },
package: { header: '.hm-sw-viewer-header', parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } },
}
const comparison = {}
if (reference && reference !== '-') {
const page = await newPage()
await page.goto(reference)
await page.waitForSelector('header')
await page.evaluate(() => document.fonts.ready)
await page.screenshot({ path: path.join(out, 'reference-1440-idle.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
await page.getByRole('button', { name: '샘플' }).click()
await page.screenshot({ path: path.join(out, 'reference-1440-sample-menu.png'), clip: { x: 0, y: 0, width: 1440, height: 200 } })
await page.getByRole('menuitem', { name: /평면 및 종단면도/ }).click()
await page.waitForFunction(() => document.querySelector('.cad-toolbar-filename')?.textContent?.includes('C0060202'), null, { timeout: 30000 })
await page.waitForTimeout(1500)
comparison.reference = await geometry(page, parts.reference)
await page.screenshot({ path: path.join(out, 'reference-1440-loaded.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
await page.getByRole('button', { name: '레이어 패널' }).click()
await page.getByRole('button', { name: '옵션 패널' }).click()
await page.waitForTimeout(300)
await page.screenshot({ path: path.join(out, 'reference-1440-panels.png') })
await page.close()
}
{
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
const frame = page.locator('[data-viewer-frame]')
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()
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()
await page.getByRole('button', { name: '설정' }).click()
await page.waitForTimeout(200)
await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } })
await page.close()
}
if (comparison.reference) {
// Same item = same size (positions shift with the file-name text width, which differs by sample data).
for (const key of ['button', 'toggle', 'tab', 'switchField', 'field']) {
const a = comparison.reference[key].map(item => `${item.w}x${item.h}`), b = comparison.package[key].map(item => `${item.w}x${item.h}`)
// field: the reference Tabs root is 39px inside its 40px frame, so its tab field is 41px; the package keeps both fields 42px (documented 1px difference).
const within = key === 'field' ? comparison.reference[key].every((item, index) => comparison.package[key][index] && item.w === comparison.package[key][index].w && Math.abs(item.h - comparison.package[key][index].h) <= 1) : JSON.stringify(a) === JSON.stringify(b)
check(`geometry ${key} sizes equal to reference${key === 'field' ? ' (±1px height, known)' : ''}`, within, `reference ${a.join(' ')} / package ${b.join(' ')}`)
}
const [r, p] = [comparison.reference.document[0], comparison.package.document[0]]
// x differs by design: the reference always draws "HM" (72px cap); the package draws no logo unless given (24px cap).
check('geometry document well height / top (x: logo rule)', r.h === p.h && r.y === p.y && p.x === r.x - 48, `reference x${r.x} y${r.y} h${r.h} / package x${p.x} y${p.y} h${p.h}`)
}
// ---------- 2. Interactions ----------
{
// 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: '샘플' })
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 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')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
check('ArrowDown moves focus in menu', (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')
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))
// Outside click closes.
await samples.click()
await page.mouse.click(700, 600)
check('outside click closes menu', (await page.getByRole('menu').count()) === 0)
// Tabs: arrow keys select.
const model = page.getByRole('radio', { name: /Model/ })
await model.focus()
await page.keyboard.press('ArrowRight')
check('tabs ArrowRight selects Layout', (await page.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true')
// Switch
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)')
// Toggle opens the panel; Escape inside closes it and focus goes back.
const layers = page.getByRole('button', { name: '레이어', exact: true })
await layers.click()
const layerPanel = page.getByRole('dialog', { name: /레이어 \(/ })
check('toggle opens panel', await layerPanel.isVisible())
check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)')
check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('Escape closes panel', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement))
// Resize by the native corner (right after opening, top-end placement).
await layers.click()
const sized = await box(layerPanel)
await page.mouse.move(sized.x + sized.width - 6, sized.y + sized.height - 6)
await page.mouse.down()
await page.mouse.move(sized.x + sized.width - 63, sized.y + sized.height - 103, { steps: 6 })
await page.mouse.up()
const resized = await box(layerPanel)
check("corner resize changes size", resized.width < sized.width - 40 && resized.height < sized.height - 80, `${Math.round(sized.width)}x${Math.round(sized.height)} → ${Math.round(resized.width)}x${Math.round(resized.height)}`)
// Drag: title bar moves the panel and it stays inside the frame.
const before = await box(layerPanel)
const title = layerPanel.locator('.hm-sw-viewer-panel__titlebar')
const titleBox = await box(title)
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
await page.mouse.down()
await page.mouse.move(titleBox.x - 260, titleBox.y + 45, { steps: 8 })
await page.mouse.up()
const after = await box(layerPanel)
check('drag moves panel', Math.round(before.x - after.x) === 300 && Math.round(after.y - before.y) === 30, `dx ${Math.round(after.x - before.x)} dy ${Math.round(after.y - before.y)}`)
await page.mouse.move(after.x + 40, after.y + 15)
await page.mouse.down()
await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 })
await page.mouse.up()
const frame = await box(page.locator('.sw-viewer-story-canvas'))
const clamped = await box(layerPanel)
check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10,
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
// Collapse by button and double click.
await layerPanel.getByRole('button', { name: '접기' }).click()
check('collapse hides body', (await layerPanel.locator('.hm-sw-viewer-panel__body').count()) === 0)
await title.dblclick({ position: { x: 30, y: 15 } })
check('title double click expands', (await layerPanel.locator('.hm-sw-viewer-panel__body').count()) === 1)
// Panel content works (layer eye).
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
// Close button hover face.
const close = layerPanel.getByRole('button', { name: '닫기' })
await close.hover()
check('close hover #E40000 / white X', (await close.evaluate(element => [getComputedStyle(element).getPropertyValue('--hm-sw-viewer-close-glow').trim(), getComputedStyle(element).color].join(' '))) === '#e40000 rgb(255, 255, 255)')
await close.click()
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
// Two panels: the last touched comes to the front.
await layers.click()
await page.getByRole('button', { name: '설정', exact: true }).click()
const options = page.getByRole('dialog', { name: '옵션' })
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
await layerPanel.locator('.hm-sw-viewer-panel__titlebar').click({ position: { x: 30, y: 15 } })
const [zl, zo] = await z()
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
// Modal popup: backdrop, focus trap, Escape.
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
const about = page.getByRole('dialog', { name: /라이선스/ })
check('modal popup opens with backdrop', (await about.getAttribute('aria-modal')) === 'true' && (await page.locator('.hm-sw-viewer-panel-backdrop').count()) === 1)
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('modal Escape closes', (await page.getByRole('dialog', { name: /라이선스/ }).count()) === 0)
await page.close()
}
{
// Submenus and the context menu.
const page = await newPage(1920, 1100)
await open(page, 'dropdowns')
await page.getByRole('button', { name: '도면' }).press('Enter')
const recent = page.getByRole('menuitem', { name: '최근 도면' })
await recent.focus()
await page.keyboard.press('ArrowRight')
const sub = page.getByRole('menu', { name: '최근 도면' })
check('ArrowRight opens submenu with focus', (await sub.isVisible()) && await sub.evaluate(element => element.contains(document.activeElement)))
const parentBox = await box(page.getByRole('menu', { name: '도면', exact: true })), subBox = await box(sub)
check('submenu opens to the side', subBox.x >= parentBox.x + parentBox.width, `parent right ${Math.round(parentBox.x + parentBox.width)}, sub left ${Math.round(subBox.x)}`)
await page.getByRole('menuitem', { name: '보관 도면' }).hover()
check('third level opens on hover', await page.getByRole('menu', { name: '보관 도면' }).isVisible())
await page.getByRole('menuitem', { name: '보관 도면' }).focus()
await page.keyboard.press('ArrowLeft')
check('ArrowLeft closes one level', (await page.getByRole('menu', { name: '최근 도면' }).count()) === 0 && (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).click()
check('checkbox entry toggles via onCommand', (await log(page)).includes('file/lineweight=true'), await log(page))
await page.getByRole('button', { name: '도면' }).click()
check('checkbox state reflected', (await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).getAttribute('aria-checked')) === 'true')
await page.getByRole('menuitem', { name: '인쇄 (미연결)' }).click({ force: true })
check('disabled entry does nothing', (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
await page.keyboard.press('Escape')
const right = page.getByRole('button', { name: '오른쪽 정렬 메뉴' })
await right.click()
const rightMenu = page.getByRole('menu', { name: '오른쪽 정렬 메뉴' })
const [rb, rm] = [await box(right), await box(rightMenu)]
check('menuAlign end + menuWidth', Math.abs(rb.x + rb.width - (rm.x + rm.width)) <= 1 && Math.round(rm.width) === 220, `width ${Math.round(rm.width)}`)
await page.keyboard.press('Escape')
const area = page.locator('[data-context-area]')
const areaBox = await box(area)
await page.mouse.click(areaBox.x + 50, areaBox.y + 40, { button: 'right' })
const context = page.getByRole('menu', { name: '도면 메뉴' })
const contextBox = await box(context)
check('context menu opens at pointer', Math.abs(contextBox.x - (areaBox.x + 50)) <= 1 && Math.abs(contextBox.y - (areaBox.y + 40)) <= 1, `${Math.round(contextBox.x)},${Math.round(contextBox.y)}`)
await page.getByRole('menuitem', { name: '선택 확대' }).click()
check('context entry reported', (await log(page)).includes('context/zoom-selection'))
// Menu kept inside the viewport near the right edge.
await page.setViewportSize({ width: 900, height: 700 })
await page.mouse.click(0, 0)
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(100)
await page.locator('.sw-story-scroller').first().evaluate(element => { element.scrollLeft = element.scrollWidth })
await right.click()
const edge = await box(page.getByRole('menu', { name: '오른쪽 정렬 메뉴' }))
check('menu stays inside the viewport', edge.x >= 4 && edge.x + edge.width <= 900 - 4, `left ${Math.round(edge.x)}, right ${Math.round(edge.x + edge.width)}`)
await page.close()
}
{
// Responsive: More collects the collapsed items, no horizontal overflow, icon-only below 560.
const page = await newPage(1920, 1200)
await open(page, '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 => ({
overflow: element.scrollWidth - element.clientWidth,
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
more: !!element.querySelector('.hm-sw-viewer-header__more'),
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => item.textContent.trim() || item.getAttribute('aria-label')),
}))
report.push({ width, ...info })
const box2 = await box(header)
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: '추가 도구' })
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))
await more.click()
await page.getByRole('menuitemradio', { name: /Layout/ }).click()
check('More radio selects tab value', (await log(page)).includes('space=layout1'))
await page.screenshot({ path: path.join(out, 'package-responsive-more-open.png') })
await page.close()
}
{
// Story captures (full page) for the report.
const page = await newPage(1920, 1000)
for (const id of ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'logo', 'compare-with-sw-header']) {
await open(page, id)
await page.screenshot({ path: path.join(out, `story-${id}.png`), fullPage: true })
}
// Mode switch keeps state.
await open(page, 'compare-with-sw-header')
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
const frame = page.locator('[data-viewer-frame]').last()
check('mode switch shows viewer header', (await frame.locator('.hm-sw-viewer-header').count()) === 1 && (await frame.locator('.hm-sw-header').count()) === 0)
await frame.getByRole('button', { name: '레이어', exact: true }).click()
await frame.getByRole('button', { name: '임시 로고 (일반 모드로) 홈' }).click()
check('brand click returns to normal SWHeader', (await frame.locator('.hm-sw-header').count()) === 1 && (await frame.locator('.hm-sw-viewer-header').count()) === 0)
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
check('state kept across mode switch (layer panel still open)', (await frame.getByRole('dialog', { name: /레이어 \(/ }).count()) === 1)
await page.close()
}
{
// Logo: nothing drawn without one, image / element when given; layout unchanged.
const page = await newPage(1920, 1000)
await open(page, 'logo')
const cases = page.locator('[data-logo-case]')
const read = index => cases.nth(index).evaluate(element => {
const brand = element.querySelector('.hm-sw-viewer-header__brand'), doc = element.querySelector('.hm-sw-viewer-header__document')
const base = element.getBoundingClientRect()
return {
text: brand.textContent.trim(), img: !!brand.querySelector('img, .hm-sw-viewer-header__brand-mark svg'),
width: Math.round(brand.getBoundingClientRect().width), docX: Math.round(doc.getBoundingClientRect().x - base.x),
height: Math.round(element.querySelector('.hm-sw-viewer-header').getBoundingClientRect().height),
fileOpenY: Math.round(element.querySelector('.hm-sw-viewer-header__button').getBoundingClientRect().y - base.y),
}
})
const [none, src, node] = [await read(0), await read(1), await read(2)]
check('logo none: no HM / text / icon', none.text === '' && !none.img, JSON.stringify(none))
check('logo none: cap shrinks to its edge (no wide gap)', none.width === 24, JSON.stringify(none))
const logoWidth = await cases.nth(1).locator('.hm-sw-viewer-header__brand-mark').evaluate(element => Math.round(element.getBoundingClientRect().width))
check('logoSrc shows the image (cap = 24 + logo + 25, min 72)', src.img && src.width === Math.max(72, 24 + logoWidth + 25), JSON.stringify({ ...src, logoWidth }))
check('logo element shown', node.img && node.width >= 72, JSON.stringify(node))
check('header height and menu row unchanged with / without logo', [none, src, node].every(item => item.height === 52 && item.fileOpenY === none.fileOpenY), [none, src, node].map(item => item.height + '/' + item.fileOpenY).join(' '))
for (const [index, name] of [[0, 'none'], [1, 'src'], [2, 'element']]) {
const b = await box(cases.nth(index))
await page.screenshot({ path: path.join(out, 'package-logo-' + name + '.png'), clip: { x: b.x, y: b.y, width: 520, height: 52 } })
}
// Text / icon outline: same tokens as SWHeader, fill unchanged.
const outline = await page.evaluate(() => {
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,
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: style('.hm-sw-viewer-header__icon').filter, chevron: style('.hm-sw-viewer-header__chevron').filter,
labelColor: style('.hm-sw-viewer-header__label').color,
labelFont: [style('.hm-sw-viewer-header__label').fontSize, style('.hm-sw-viewer-header__label').fontWeight, style('.hm-sw-viewer-header__label').letterSpacing].join(' '),
}
})
const blacks = value => value.split('rgb(0, 0, 0)').length - 1
check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60))
check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60))
check('icon outline: 4 x 1px black drop-shadows + #061A14 glow', [outline.icon, outline.chevron].every(value => (value.match(/drop-shadow/g) ?? []).length === 5 && value.includes('rgb(0, 0, 0) 1px 0px 0px') && value.includes('rgb(6, 26, 20)')), outline.icon.slice(0, 90))
check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont)
await page.close()
}
{
// SWHeader vs SWViewerHeader text / icon outline at 3x for the report.
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, deviceScaleFactor: 3 })
await open(page, 'compare-with-sw-header')
const sw = await box(page.locator('.hm-sw-header').first()), vw = await box(page.locator('.hm-sw-viewer-header').first())
await page.screenshot({ path: path.join(out, 'outline-swheader-3x.png'), clip: { x: sw.x, y: sw.y, width: 760, height: sw.height } })
await page.screenshot({ path: path.join(out, 'outline-viewer-3x.png'), clip: { x: vw.x, y: vw.y, width: 760, height: vw.height } })
await page.screenshot({ path: path.join(out, 'outline-viewer-right-3x.png'), clip: { x: vw.x + 1000, y: vw.y, width: 440, height: vw.height } })
const hovered = page.locator('.hm-sw-viewer-header').first().getByRole('button', { name: '레이어', exact: true })
await hovered.hover()
check('hover changes fill only (outline kept)', (await hovered.locator('.hm-sw-viewer-header__label').evaluate(element => getComputedStyle(element).textShadow)).includes('1.3px'))
await page.close()
const states = await browser.newPage({ viewport: { width: 1920, height: 900 }, deviceScaleFactor: 2 })
await open(states, 'states')
const header = await box(states.locator('.hm-sw-viewer-header'))
await states.getByRole('button', { name: '기본' }).hover()
await states.screenshot({ path: path.join(out, 'outline-states-2x.png'), clip: { x: header.x, y: header.y, width: 1440, height: header.height } })
const disabled = await states.getByRole('button', { name: 'Disabled', exact: true }).evaluate(element => [getComputedStyle(element).opacity, getComputedStyle(element.querySelector('.hm-sw-viewer-header__label')).textShadow.includes('1.3px')])
check('disabled fades text + outline together', disabled[0] === '0.6' && disabled[1] === true, JSON.stringify(disabled))
await states.close()
}
{
// Storybook sidebar: SW/Tool removed; SW/Popup (common SWPopup examples), SW/Header and SW/Viewer remain.
const index = await (await fetch(storybook + '/index.json')).json()
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW')))]
check('SW/Tool removed from the sidebar', !titles.includes('SW/Tool'), titles.join(', '))
check('SW/Popup, SW/Header and SW/Viewer remain', ['SW/Popup', 'SW/Header', 'SW/Viewer'].every(title => titles.includes(title)))
}
check('no page errors', errors.length === 0, errors.join(' | '))
await browser.close()
await writeFile(path.join(out, 'report.json'), JSON.stringify({ storybook, reference, comparison, results, errors }, null, 2) + '\n')
const failed = results.filter(item => item.result === 'FAIL').length
console.log(`\n${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)