feat(sw): integrate responsive home menu into header

- Merge SW / Home Menu Stories into SW / Header (sidebar: Home Menu; others verification-tagged)
- Home On: logo hover ring and header top/bottom #5F4B0F lines while the menu is open
- Measured More in SWHeader; home menu narrow widths use a More (...) section with a grouped
  text menu instead of horizontal scroll, placed right after the last visible section
- Action tiles no longer stay on after a click; 최근파일 stays Active only while its list is open
- More dropdown and recent files list never open together; list top joins the accent line
- Recent files preview falls back left / over the list at narrow widths (390px)
- Update Storybook, browser tests, docs, AI guides and QA evidence
This commit is contained in:
shkim4 committed 2026-10-19 22:46:28 +09:00
1 parent c542bdc282
commit 170a5c23ae
71 files changed
+12535 -150

No files matched your search

+1
View File
@@ -44,6 +44,7 @@ import "@hm-design/sw/styles.css"
/>
```
- 반응형 More: 좁은 헤더에서 더보기의 필드를 고르면 그 필드가 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 아래 열립니다. 레이어는 더보기에서 다시 고를 수 있으며, 전환은 UI 상태라 데이터 Callback을 호출하지 않습니다([명세 §5.4](../../docs/sw/sw-header-design-spec.md#54-반응형-more--대표-필드-전환-2026-10-16)).
- 실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해 표시할 수 없는 메뉴만 More로 옮기고, 공간이 생기면 원래 자리·순서로 돌려놓습니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다([명세 §12.1](../../docs/sw/sw-header-design-spec.md#121-실측-more--실제-사용-가능한-공간-기준-2026-10-19)). Storybook의 홈 메뉴 예제는 `SW / Header` → `Home Menu`입니다.
- Text Stroke: Figma에 Stroke가 있는 텍스트만 검정 외곽선(14·16px 1.3px, 12px 1px)을 표시합니다. 자기 색 0.3px Stroke는 2026-10-16 추가 수정으로 제거했습니다. 더보기·Tooltip·`+ New`/`Open`은 제외([명세 §6.1](../../docs/sw/sw-header-design-spec.md#61-text-stroke-2026-10-16)).
- `SWHeaderInput`: 레이어 상세 등 상세 영역에서 쓰는 수정 가능 Input(Figma 26:11656, Default / Hover / 편집, caret `#E60403`). `value`(확정값) · `onValueChange`(임시값) · `onCommit`(Enter·Focus 이동, 유효 + 변경 시) · `onCancel`(Escape) · `valueType`(`text` / `number`) · `parse` · `readOnly` · `disabled`([명세 §5.5](../../docs/sw/sw-header-design-spec.md#55-레이어-상세-버튼--수정-가능한-input-2026-10-16)).
- Peer: `react`, `react-dom` `^19.0.0`. 런타임 dependency는 없습니다.
+3 -2
View File
@@ -35,6 +35,7 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F).
- 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다.
- 메뉴도 고정 폭만이 아니라 실제 남은 공간을 기준으로 More에 옮기고 공간이 생기면 원래 자리·순서로 돌려놓습니다(Breakpoint는 그대로, 추가 Prop 없음). 메뉴를 열고 닫아도 Header 요소의 위치·폭은 바뀌지 않습니다.
```tsx
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
```
@@ -96,9 +97,9 @@ Figma 26:10591(home on), 타일 15종 26:11355 … 26:11631, 섹션 제목 26:12
| 컴포넌트 | 용도 | 주요 Props | 크기·동작 | 검증 |
|---|---|---|---|---|
| `SWHomeMenu` | 2단 메뉴 전체(섹션 여러 개). 보통 `SWHeader homeMenu`로 씀 | `sections`(`SWHomeMenuSectionData[]`) 또는 `children`, `open`/`defaultOpen`(false)/`onOpenChange(open, reason)`, `activeIds`/`defaultActiveIds`/`onActiveChange`, `selection`(`multiple` 기본 / `single`), `onItemSelect`, `closeOnBackgroundClick`(true), `closeOnSelect`(false), `closeOnEscape`(true), `closeOnOutsideClick`(false), `autoFocus`(true), `returnFocusRef`, `accent`(`auto`/`sections`/`outline`) | 높이 77(섹션 75 + 강조 2), 섹션 폭은 타일 수로 자동. 좁으면 메뉴 안 가로 스크롤 | ✅ |
| `SWHomeMenu` | 2단 메뉴 전체(섹션 여러 개). 보통 `SWHeader homeMenu`로 씀 | `sections`(`SWHomeMenuSectionData[]`) 또는 `children`, `open`/`defaultOpen`(false)/`onOpenChange(open, reason)`, `activeIds`/`defaultActiveIds`/`onActiveChange`, `selection`(`multiple` 기본 / `single`), `onItemSelect`, `closeOnBackgroundClick`(true), `closeOnSelect`(false), `closeOnEscape`(true), `closeOnOutsideClick`(false), `autoFocus`(true), `returnFocusRef`, `accent`(`auto`/`sections`/`outline`), `moreLabel`("더보기") | 높이 77(섹션 75 + 강조 2), 섹션 폭은 타일 수로 자동. 좁으면 들어가는 섹션만 표시하고 나머지는 더보기(⋯) Dropdown의 텍스트 메뉴(섹션 제목 + 항목, 같은 Active·Callback·하위 패널)로 이동(가로 스크롤 없음, 넓어지면 복귀) | ✅ |
| `SWHomeMenuSection` | 섹션(제목 badge + 타일 그룹) | `title`, `children` | badge 폭 = 글자 + 46px | ✅ |
| `SWHomeMenuItem` | 80 × 46 타일(Default / Hover / Active) | 데이터 `SWHomeMenuItemData`: `id`, `label`, `icon`, `hoverIcon`, `toggle`(true = 켜고 끄기, false = 실행만), `panel`, `panelLabel`, `disabled`, `hidden`, `title`, `onSelect`. 메뉴 밖 단독이면 `active`/`defaultActive`/`onActiveChange` | Hover·Active = 주황 gradient 1px 테두리 + `#FF5C00` 아이콘·글자 | ✅ |
| `SWHomeMenuItem` | 80 × 46 타일(Default / Hover / Active) | 데이터 `SWHomeMenuItemData`: `id`, `label`, `icon`, `hoverIcon`, `toggle`(기본: `panel` 있는 항목만 true = 패널이 열린 동안 Active, 나머지는 실행 버튼 = 클릭 후 ON 없음. true = 켜고 끄기 유지), `panel`, `panelLabel`, `disabled`, `hidden`, `title`, `onSelect`. 메뉴 밖 단독이면 `active`/`defaultActive`/`onActiveChange` | Hover·Active = 주황 gradient 1px 테두리 + `#FF5C00` 아이콘·글자 | ✅ |
| `SWRecentFiles` | 선택 하위 패널: 최근파일 목록 + 2뎁스 미리보기 | `files`(`SWFileEntry[]`: `id`, `name`, `path`, `badge`, `preview`, `previewTitle`, `disabled`), `onFileOpen`, `onFileHover`, `renderPreview`, `closeOnOpen`(true), `emptyText` | 폭 354, 행 50, 미리보기 310 × 270 | ✅ |
- **Header 연결:** `SWHeader`의 `homeMenu`에 `SWHomeMenu` Props를 주면 됩니다. 기본 숨김, 로고 클릭 열기, 다시 클릭 닫기. 위치는 SWHeader가 실제 DOM으로 정합니다(객체 수정 영역 −2px ~ 창 영역, 고정 x 없음. `controls`에 `document`가 있으면 파일명 오른쪽 −2px부터). 로고·파일명·창 영역은 가리지 않습니다.
+7 -5
View File
@@ -83,7 +83,9 @@
Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음).
로고 클릭 메뉴는 좁은 폭에서 접히지 않고 메뉴 안에서 가로 스크롤합니다(좁은 폭 전용 디자인 없음, §12).
실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해, `leading`·`tools`·긴 라벨 등으로 표시할 수 없는 메뉴만 같은 단계 순서(선/문자/치수 → 그리기모드 → 속성 블록 → 색상 → 구성형의 인쇄·K, 같은 단계는 오른쪽부터)로 More에 옮기고 공간이 생기면 역순으로 돌려놓습니다. 로고 옆 Open이 가장 먼저 옮겨집니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다. 호스트 요소(`leading`·`tools`)는 More로 옮기지 않으므로 폭이 좁은 화면에는 많이 넣지 않습니다.
로고 클릭 메뉴는 좁은 폭에서는 가로 스크롤 없이 들어가는 섹션만 앞에서부터 보여 주고, 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다(2026-10-19 사용자 확정, Figma에 좁은 폭 디자인 없음). 더보기를 누르면 옮긴 섹션이 텍스트 메뉴(섹션 제목 + 기능 텍스트 목록, 그룹 사이 구분선)로 아래 Dropdown에 열리고, 폭이 넓어지면 원래 자리로 돌아갑니다. 화면에 남은 섹션은 Figma 타일 그대로입니다(§12).
조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다.
@@ -163,11 +165,11 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
| 크기 | 메뉴 높이 77(섹션 75 + 하단 강조 2), 그룹 46, 타일 80 × 46, 아이콘 18 슬롯, 섹션 폭은 타일 수로 자동 | — |
| 섹션 면 | 세로 gradient 면 + 1px `#081813` + 안쪽 1px `#20190E`, 아래 모서리 3px | `--hm-sw-home-menu-section-*` |
| 섹션 제목 | Figma tit_* badge(9-slice), 글자 400 검정 | `--hm-sw-home-menu-title-color` |
| 타일 상태 | Default 아이콘 `#B6B4AF` · Hover·Active 주황 gradient 1px 테두리 + 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동. 라벨 700 | `--hm-sw-home-menu-item-*`, `--hm-sw-home-menu-icon-color` |
| 타일 상태 | Default 아이콘 `#B6B4AF` · Hover·Pressed(누르는 동안)·Active 주황 gradient 1px 테두리 + 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동. 라벨 700. 최근파일 외 항목은 실행 버튼: 클릭 후 ON 유지 없음(2026-10-19 사용자 확정). Active 유지는 하위 패널 타일(최근파일, 목록이 열린 동안)과 더보기·홈 버튼(메뉴가 열린 동안)만. 유지형이 필요하면 `toggle: true` | `--hm-sw-home-menu-item-*`, `--hm-sw-home-menu-icon-color` |
| 하단 강조 | `sections` = 섹션마다 주황 띠, `outline` = 메뉴 전체 2px 외곽선(하위 패널이 열린 동안, `auto` 기본) | `--hm-sw-home-menu-accent-*` |
| 최근파일 패널 | 폭 354, 행 50, 배경 `#27241D`, 주황 테두리, 행 Hover `#62533D` + `#FF5C00`, 미리보기 310 × 270 | `--hm-sw-recent-files-*` |
| 최근파일 패널 | 폭 354, 행 50, 배경 `#27241D`, 주황 테두리, 행 Hover `#62533D` + `#FF5C00`, 미리보기 310 × 270. 미리보기 위치: 목록 오른쪽(Figma) → 오른쪽에 공간이 없으면 왼쪽 → 좌우 모두 없으면(예: 390) 목록 위에 겹쳐 Hover 행 바로 아래(목록 왼쪽 1px 안쪽, 4면 테두리·radius 4, 크기·색 동일), 화면 아래 공간이 없으면 행 바로 위(2026-10-19 사용자 확정, Figma 없음). 가로 스크롤 없음 | `--hm-sw-recent-files-*` |
| 동작 | 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기. 메뉴 빈 곳 클릭 닫기(Figma CLOSE). Escape: 하위 패널 → 메뉴, 닫히면 로고로 Focus. 메뉴 밖 클릭은 닫지 않음(Figma overlay 기준) | — |
| 좁은 폭 | 접는 디자인이 없어 메뉴 안 가로 스크롤(의도된 동작, 겹침·잘림 없음) | — |
| 좁은 폭 | 측정해 들어가는 섹션만 표시, 나머지는 끝의 더보기(⋯) 섹션(기존 섹션·타일 부품, 배지 `moreLabel` 기본 "더보기", 타일 "N개 그룹")으로 이동. 메뉴 배경·폭·높이·테두리·강조선은 그대로(더보기 섹션 면이 남는 폭을 채워 객체 수정 영역을 계속 덮음)이고, 더보기 배지·⋯ 타일만 마지막으로 표시된 섹션 바로 오른쪽에 붙음. 전체가 들어가는 폭에서는 더보기 숨김. 더보기 Dropdown과 최근파일 목록은 동시에 열리지 않음: 더보기를 열면 열린 목록을 먼저 닫고, Dropdown에서 최근파일을 고르면 Dropdown을 닫고 목록을 엶. Dropdown = 텍스트 메뉴(SW brown Dropdown: 배경 `#27241D`·테두리 `#000`·radius 3, 제목 12px 700 `#D4CBBD`, 행 30px·14px 700·좌우 12, 그룹 구분선 = 레이어 footer 선), ⋯ 타일 아래 왼쪽 정렬(화면 안으로 맞춤). 행 Hover = Pressed = Active = `#62533D` + `#FF5C00`. 일반 행은 클릭 후 ON·✓ 없음, 최근파일 행(›)만 목록이 열린 동안 Active. ✓는 `toggle: true` 행에만. 클릭·Callback·상태 규칙은 타일과 같음. ↓(더보기) = 첫 행, ↑/↓ 이동. Escape: Dropdown → 하위 패널 → 메뉴. 더보기 안 최근파일은 Dropdown을 닫고 더보기 타일 아래(화면 안으로 맞춤)에 기존 최근파일 목록으로 열림, 미리보기는 오른쪽에 공간이 없으면 왼쪽, 좌우 모두 없으면 목록 위 행 아래(최근파일 패널 행 참고). 목록이 메뉴 밖으로 나가는 부분은 메뉴 하단 강조선(2px, 같은 gradient)이 목록 상단으로 이어짐(실측 위치 기준, `--hm-sw-home-menu-accent-outline-width`). 가로 스크롤 없음 | `--hm-sw-home-menu-more-*` |
- 열림 상태 Header 상·하단 `#5F4B0F` 2px 선(Figma home on)과 로고 열림 표시는 **적용하지 않습니다**(디자인 승인 대기). 임의로 추가하지 않습니다.
- Home On(Figma 662:67267, 2026-10-19 사용자 확정): 메뉴가 열려 있는 동안 SWHeader가 자동으로 로고에 노란 Hover 테두리를 유지하고, Header 전폭 상·하단에 `#5F4B0F` 2px 선(`--hm-sw-header-home-on-line-color` / `-width`)을 그립니다. 닫으면 기본 상태로 돌아갑니다. 크기·위치 변화가 없고 SW 일반 모드 전용입니다(뷰어 모드에는 없음). 소비 코드에서 별도 class나 상태를 추가하지 않습니다.
- 새 색·크기는 위 토큰만 `style`로 덮어씁니다. `hm-sw-home-menu*` class를 소비 코드에 쓰지 않습니다.
+101 -27
View File
@@ -42,6 +42,34 @@ const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove",
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/**
* Order in which visible parts move into More when the measured object area has no room left (on top of the
* breakpoints): the breakpoint tier order — line / text / dimension style, 그리기모드, the property block, color,
* print / K — and within a tier the rightmost part first. The representative field never moves.
*/
const squeezeRank: Partial<Record<SWHeaderControlId, number>> = {
lineStyle: 0, textStyle: 0, dimensionStyle: 0, drawingMode: 1, details: 2, propertyBar: 2, propertyPanel: 2, color: 3, print: 4, popupGuide: 4,
}
/**
* Room left in the object area (negative: missing) when nothing in it shrinks, grows or takes free space through an
* auto margin (`data-measuring`, see sw.css "Measured More"). Read and reset in one layout effect, never painted.
* Only in-flow children count: dropdowns, tooltips and other positioned layers never change the result.
*/
function measureRoom(header: HTMLElement, area: HTMLElement) {
header.setAttribute("data-measuring", "")
const style = getComputedStyle(area)
const end = area.getBoundingClientRect().right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
let used = area.getBoundingClientRect().left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
for (const child of Array.from(area.children)) {
const childStyle = getComputedStyle(child)
if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
}
header.removeAttribute("data-measuring")
return end - used
}
/**
* Menu order: listed known ids first (duplicates and unknown ids ignored), missing ids after them in
* the default order. Menus only swap places among the slots menus already take; other parts never move.
@@ -170,8 +198,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
// Default design with removed object parts: same structure and breakpoints, but the script decides what
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
// Sub-items live inside a part and never change the layout mode.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
// Sub-items live inside a part and never change the layout mode. Parts measured into More also need the script.
const [squeezed, setSqueezed] = useState<readonly string[]>([])
const scripted = controls !== undefined || squeezed.length > 0 || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
const presentRef = useRef(new Set(configuration.map(item => item.id)))
presentRef.current = new Set(configuration.map(item => item.id))
@@ -214,6 +243,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const [headerWidth, setHeaderWidth] = useState(1920)
const [, setAreaWidth] = useState(0)
const [barOffset, setBarOffset] = useState(0)
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
@@ -228,6 +258,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const update = () => {
const bounds = header.getBoundingClientRect()
setHeaderWidth(bounds.width)
// The object area can change alone (leading content, file name, window area): it re-runs the measured More.
setAreaWidth(panelAnchorRef.current?.getBoundingClientRect().width ?? 0)
setBarOffset((panelAnchorRef.current?.getBoundingClientRect().left ?? bounds.left) - bounds.left)
}
const observer = new ResizeObserver(update)
@@ -237,8 +269,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
return () => observer.disconnect()
}, [])
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
// --hm-sw-header-leading-width). Open and the other parts move into More by measurement (see "Measured More").
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
if (!header) return
@@ -248,22 +279,6 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
else header.style.removeProperty("--hm-sw-header-leading-width")
}
if (!openInHeader) {
collapseRef.current = null
if (openCollapsed) setOpenCollapsed(false)
return
}
if (!openCollapsed) {
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
if (overflow && open) {
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
setOpenCollapsed(true)
}
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
setOpenCollapsed(false)
}
})
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
@@ -303,9 +318,23 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
const isOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : false
const breakpointOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : id === "drawingMode" ? headerWidth < 1180 : false
// Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in
// the window area there and keep the breakpoint). Keys of the moved parts are kept in move order.
const squeezeGroups = (controls === undefined
? [...menuIds.map(id => ({ key: id as string, ids: [id] as SWHeaderControlId[] })), { key: "drawingMode", ids: ["drawingMode"] as SWHeaderControlId[] },
{ key: "properties", ids: ["details", "propertyBar", "propertyPanel"] as SWHeaderControlId[] }]
: configuration.map(item => ({ key: item.id as string, ids: [item.id] })))
.map((group, index) => ({ ...group, ids: group.ids.filter(id => enabled(id) && squeezeRank[id] !== undefined), index }))
.filter(group => group.ids.length > 0)
const squeezedIds = new Set(squeezeGroups.filter(group => squeezed.includes(group.key)).flatMap(group => group.ids))
const isOverflow = (id: SWHeaderControlId) => breakpointOverflow(id) || squeezedIds.has(id)
const representative = validPromoted && isOverflow(validPromoted) ? validPromoted : baseMenu
const fieldHidden = (id: SWHeaderMenuId) => id !== representative && (isOverflow(id) || (id === baseMenu && representative !== baseMenu))
// Next part to move: shown now, never the representative slot, lowest tier first, rightmost first within a tier.
const squeezeCandidates = squeezeGroups
.filter(group => !squeezed.includes(group.key) && !group.ids.some(id => id === baseMenu || id === validPromoted || (isMenu(id) ? fieldHidden(id) : isOverflow(id))))
.sort((a, b) => (squeezeRank[a.ids[0]] ?? 0) - (squeezeRank[b.ids[0]] ?? 0) || b.index - a.index)
// Removal is structural: close stale internal dropdown state silently, never send data callbacks.
const controlKey = configuration.map(item => item.id).join(",")
useEffect(() => {
@@ -315,6 +344,51 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (!enabled("propertyBar")) setInternalBarOpen(false)
}, [controlKey, openMenu, promoted])
// Measured More (2026-10-19): on top of the breakpoints, parts move into More while the object area has no room for
// them (leading content, tools, labels, a side panel) and return once the room they freed is back, so the header
// never relies on fixed widths alone. Open next to the logo moves first and returns last. One part per pass; the
// passes are synchronous layout effects, so only the final layout is painted. Opening a menu never moves a part:
// dropdowns and the logo menu are positioned layers, which the measurement ignores.
const squeezeRef = useRef(new Map<string, { before: number; room?: number }>())
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
const area = panelAnchorRef.current
// Wait for the measured header width (breakpoint state) to be rendered first.
if (!header || !area || Math.abs(header.getBoundingClientRect().width - headerWidth) > 0.5) return
if (!openInHeader) {
collapseRef.current = null
if (openCollapsed) return setOpenCollapsed(false)
}
// Parts removed from the configuration leave the list silently.
const valid = squeezed.filter(key => squeezeGroups.some(group => group.key === key))
if (valid.length !== squeezed.length) return setSqueezed(valid)
const room = measureRoom(header, area)
const last = squeezed[squeezed.length - 1]
const metrics = last === undefined ? undefined : squeezeRef.current.get(last)
// The room a part frees is measured once, right after it moved (More appearing and padding changes included).
if (metrics && metrics.room === undefined) metrics.room = room - metrics.before
if (room < -0.5) {
const open = openShown ? leadingRef.current?.querySelector<HTMLElement>("[data-document-open]") : null
if (open) {
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
return setOpenCollapsed(true)
}
const next = squeezeCandidates[0]
if (next) {
squeezeRef.current.set(next.key, { before: room })
setSqueezed([...squeezed, next.key])
}
} else if (last !== undefined) {
if (room >= (metrics?.room ?? Infinity) - 0.5) {
squeezeRef.current.delete(last)
setSqueezed(squeezed.slice(0, -1))
}
} else if (openCollapsed && collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
// Open returns once the header is wider than when it moved by the Open width (no flicker at the edge).
setOpenCollapsed(false)
}
})
const hasMenu: Record<SWHeaderMenuId, boolean> = {
layer: layers !== undefined,
color: colors !== undefined,
@@ -518,7 +592,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
})
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} data-home-menu-open={homeOpen || undefined} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt}
aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} />
@@ -527,19 +601,19 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
{leading}
</div>}
{controls === undefined && enabled("document") && documentNode}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined} data-squeezed={squeezed.length ? squeezed.join(" ") : undefined}>
{controls === undefined && enabled("drawingMode") && !squeezedIds.has("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
<div ref={fieldsRef} className="hm-sw-header__fields" data-configured={controls !== undefined || undefined}>
{controls ? configuration.map(config => {
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id) || (config.id === "drawingMode" && headerWidth < 1180)
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
return <div key={config.id} className="hm-sw-header__control" data-control={config.id} data-hidden={hidden || undefined}>{presence(config.id, configuredNodes[config.id])}</div>
}) : scripted ? menuIds.map(id => (
<div key={id} className="hm-sw-header__control" data-control={id} data-hidden={fieldHidden(id) || undefined} data-representative={(id === representative && id !== baseMenu) || undefined}>{presence(id, fieldNodes[id])}</div>
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
{tools}
</div>
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed || squeezed.length > 0} />}
{controls === undefined && !squeezed.includes("properties") && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
</div>
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
{actionsShown && <SWHeaderActions disabled={disabled} onPrint={onPrint} onPopupGuide={onPopupGuide} showPrint={enabled("print")} showPopupGuide={enabled("popupGuide")} />}
@@ -15,7 +15,7 @@ export interface SWHeaderHomeProps {
* SWHeader always shows the logo; only the home action can be removed. Default true.
*/
interactive?: boolean
/** Logo menu state (SWHeader `homeMenu`). No visual change: Figma has no open look for the logo. */
/** Logo menu state (SWHeader `homeMenu`). The Home On look is drawn by SWHeader (`data-home-menu-open`, Figma 662:67267). */
"aria-expanded"?: boolean
"aria-controls"?: string
}
@@ -1,5 +1,5 @@
import {
createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
Children, createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
} from "react"
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
@@ -19,13 +19,26 @@ interface WindowContextValue {
}
const WindowContext = createContext<WindowContextValue | null>(null)
const SectionContext = createContext<SWHomeMenuSectionData | undefined>(undefined)
/**
* How sections and items draw: `tile` (Figma row), `text` (inside the More dropdown: title + text rows, same
* selection / Active / panel logic) or `measure` (invisible tile copy that gives the natural widths; no registration).
*/
type Presentation = "tile" | "text" | "measure"
const PresentationContext = createContext<Presentation>("tile")
/** For content inside a panel (e.g. SWRecentFiles): close the whole menu after opening a file. */
export function useSWHomeMenuClose() {
return useContext(WindowContext)?.close
}
const ITEM_SELECTOR = ".hm-sw-home-menu__item:not(:disabled)"
const ITEM_SELECTOR = ":is(.hm-sw-home-menu__item, .hm-sw-home-menu__text-item):not(:disabled)"
const TEXT_ITEM_SELECTOR = ".hm-sw-home-menu__text-item:not(:disabled)"
/** Tiles / text rows that can take focus now (the closed More dropdown stays laid out but hidden; the measure copy is hidden). */
const focusableItems = (root: Element | null, selector = ITEM_SELECTOR) =>
Array.from(root?.querySelectorAll<HTMLElement>(selector) ?? []).filter(item => getComputedStyle(item).visibility !== "hidden")
const ROW = ":scope > .hm-sw-home-menu__sections"
const MORE_PANEL = ":scope > .hm-sw-home-menu__more-panel"
const MEASURE = ":scope > .hm-sw-home-menu__measure"
/**
* While open, the menu is on the SW popup Escape stack (SWPopup / property window): Escape with the focus outside
@@ -41,13 +54,14 @@ function SWHomeMenuEscapeEntry({ onEscape, raiseRef }: { onEscape: () => void; r
* default; the host toggles `open` from SWHeader `onHomeClick` (logo click / re-click). Sections of 80 x 46 tiles;
* a tile may open an optional sub panel under the menu (Figma 최근파일 662:67331 → <SWRecentFiles>). Every text,
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader).
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader). Narrower than its sections, it never scrolls: the
* trailing sections move into a More (⋯) section and return when the width allows (`moreLabel`).
*/
export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function SWHomeMenu({
sections, children, open: openProp, defaultOpen = false, onOpenChange, activeIds: activeProp, defaultActiveIds,
onActiveChange, selection = "multiple", onItemSelect, closeOnBackgroundClick = true, closeOnSelect = false,
closeOnEscape = true, closeOnOutsideClick = false, autoFocus = true, returnFocusRef, accent = "auto",
className, style, onClick, onKeyDown, ...rest
moreLabel = "더보기", className, style, onClick, onKeyDown, ...rest
}, ref) {
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const open = openProp ?? internalOpen
@@ -91,16 +105,104 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
onOpenChange?.(false, reason)
}, [openProp, onOpenChange, returnFocusRef, setActive])
// Opening (not the first render): remember the focus to return to, then focus the first tile.
// Sections in order (data or children). The leading ones that fit stay in the row; the rest move into More.
const nodes = sections
? sections.filter(section => !section.hidden).map(section => (
<SWHomeMenuSection key={section.id} section={section} title={section.title}>
{section.items.filter(item => !item.hidden).map(item => <SWHomeMenuItem key={item.id} item={item} />)}
</SWHomeMenuSection>
))
: Children.toArray(children)
const [fit, setFit] = useState(Number.POSITIVE_INFINITY)
const rowCount = Math.min(fit, nodes.length)
const overflowCount = nodes.length - rowCount
const [moreOpenState, setMoreOpen] = useState(false)
const moreOpen = moreOpenState && overflowCount > 0
const moreId = useId()
const moreButtonRef = useRef<HTMLButtonElement>(null)
const morePanelRef = useRef<HTMLDivElement>(null)
const [moreLeft, setMoreLeft] = useState(0)
const [, setResized] = useState(0)
const pendingFocus = useRef(false)
// Keyboard (ArrowDown on More): focus the first text row once the dropdown is shown.
const pendingMoreFocus = useRef(false)
// Opening (not the first render): remember the focus to return to; the first tile is focused once the row is measured.
const wasOpen = useRef(open)
useLayoutEffect(() => {
if (open && !wasOpen.current) {
const active = document.activeElement
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
if (autoFocus) rootRef.current?.querySelector<HTMLElement>(ITEM_SELECTOR)?.focus()
pendingFocus.current = autoFocus
}
wasOpen.current = open
}, [open, autoFocus])
useEffect(() => { if (!open || overflowCount === 0) setMoreOpen(false) }, [open, overflowCount])
// Measured More (2026-10-19): one synchronous read before paint, like SWHeader's measured More. data-measuring lets
// the menu take all the room its host gives it (so the available width does not depend on what is shown). The
// natural tile widths come from the invisible measure copy (every section as a tile + the More section), because
// the moved sections are text rows in the dropdown.
useLayoutEffect(() => {
const root = rootRef.current
const row = root?.querySelector<HTMLElement>(ROW)
const [tiles, moreCopy] = Array.from(root?.querySelector(MEASURE)?.children ?? [])
if (!open || !root || !row || !tiles || !moreCopy) return
root.setAttribute("data-measuring", "")
const available = root.getBoundingClientRect().width
const rowStyle = getComputedStyle(row)
const padding = parseFloat(rowStyle.paddingLeft) + parseFloat(rowStyle.paddingRight)
const widths = Array.from(tiles.children, section => section.getBoundingClientRect().width)
const moreWidth = moreCopy.firstElementChild?.getBoundingClientRect().width ?? 0
root.removeAttribute("data-measuring")
// 0: not laid out yet (e.g. SWHeader before its placement) — keep the current split.
if (available < 1) return
let next = widths.length
if (widths.reduce((sum, width) => sum + width, 0) + padding > available + 0.5) {
let room = available - padding - moreWidth
next = 0
while (next < widths.length && widths[next] <= room + 0.5) room -= widths[next++]
}
if (next !== rowCount) { setFit(next); return }
// Dropdown under the ⋯ tile (left edges aligned, like the sub panel at its tile), kept inside the viewport.
const panel = root.querySelector<HTMLElement>(MORE_PANEL)
if (moreOpen && pendingMoreFocus.current) {
pendingMoreFocus.current = false
focusableItems(panel, TEXT_ITEM_SELECTOR)[0]?.focus()
}
const moreTile = moreButtonRef.current
if (moreTile && panel) {
const origin = root.getBoundingClientRect()
const left = Math.max(Math.min(moreTile.getBoundingClientRect().left - origin.left, document.documentElement.clientWidth - origin.left - panel.offsetWidth), -origin.left)
if (Math.abs(left - moreLeft) > 0.1) setMoreLeft(left)
}
if (pendingFocus.current) {
pendingFocus.current = false
focusableItems(root)[0]?.focus()
}
})
// The host's room can change without a render (window resize, a standalone container): measure again.
useEffect(() => {
const root = rootRef.current
if (!open || !root?.parentElement) return
const observer = new ResizeObserver(() => setResized(count => count + 1))
observer.observe(root.parentElement)
const header = root.closest(".hm-sw-header")
if (header) observer.observe(header)
return () => observer.disconnect()
}, [open])
// A click outside the dropdown and its More tile closes the dropdown (the menu stays open).
useEffect(() => {
if (!moreOpen) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (morePanelRef.current?.contains(target) || moreButtonRef.current?.closest(".hm-sw-home-menu__more")?.contains(target)) return
setMoreOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [moreOpen])
useEffect(() => {
if (!open || !closeOnOutsideClick) return
@@ -114,21 +216,42 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
}, [open, closeOnOutsideClick, close, returnFocusRef])
// Panel position: under the menu, 1px right of its tile's left edge (Figma 최근파일 808 vs tile 807).
// A tile inside More opens its panel under the More tile (the dropdown closes, see the dropdown click).
const [panelLeft, setPanelLeft] = useState(0)
const [panelFromMore, setPanelFromMore] = useState(false)
// Accent joint: the parts of the panel's top edge outside the menu (narrow widths: the panel is kept in the
// viewport and may reach past the menu). Measured with the panel position; empty while it stays under the menu.
const [joint, setJoint] = useState<readonly { left: number; width: number }[]>([])
useLayoutEffect(() => {
if (!openPanelId || !rootRef.current) return
const root = rootRef.current
const measure = () => {
const tile = root.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)
if (tile) setPanelLeft(tile.getBoundingClientRect().left - root.getBoundingClientRect().left + 1)
const inMore = !!tile?.closest(".hm-sw-home-menu__more-panel")
const anchor = inMore ? moreButtonRef.current : tile
setPanelFromMore(inMore)
if (!anchor) return
// Kept inside the viewport (narrow widths: no page scroll); at 1920 it stays at its tile as in Figma.
const origin = root.getBoundingClientRect()
const panel = root.querySelector<HTMLElement>(":scope > .hm-sw-home-menu__panel")
const tileLeft = anchor.getBoundingClientRect().left - origin.left + 1
const left = panel ? Math.max(Math.min(tileLeft, document.documentElement.clientWidth - origin.left - panel.offsetWidth), -origin.left) : tileLeft
setPanelLeft(left)
// Each joint starts inside the menu's rounded bottom corner, so the accent runs on without a notch.
const right = left + (panel?.offsetWidth ?? 0)
const corner = parseFloat(getComputedStyle(root, "::after").borderBottomRightRadius) || 0
const next: { left: number; width: number }[] = []
if (panel && left < 0) next.push({ left, width: Math.min(right, corner) - left })
if (panel && right > origin.width) { const start = Math.max(left, origin.width - corner); next.push({ left: start, width: right - start }) }
setJoint(current => JSON.stringify(current) === JSON.stringify(next) ? current : next)
}
measure()
const scroller = root.querySelector(".hm-sw-home-menu__sections")
scroller?.addEventListener("scroll", measure)
const observer = new ResizeObserver(measure)
observer.observe(root)
return () => { scroller?.removeEventListener("scroll", measure); observer.disconnect() }
}, [openPanelId])
const panel = root.querySelector(":scope > .hm-sw-home-menu__panel")
if (panel) observer.observe(panel)
return () => observer.disconnect()
}, [openPanelId, rowCount])
const context = useMemo<WindowContextValue>(() => ({
isActive: id => activeIds.includes(id),
@@ -137,7 +260,8 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
close,
select: (item, section) => {
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
const toggle = item.toggle !== false
// Action items (the default without a panel) only run: no Active after the click (2026-10-19).
const toggle = item.toggle ?? item.panel != null
const active = toggle && !current.includes(item.id)
const event: SWHomeMenuSelectEvent = { item, section, active }
if (toggle) {
@@ -156,13 +280,33 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
const raiseRef = useRef<(() => void) | null>(null)
if (!open) return null
/** Escape: an open sub panel closes first (focus back on its tile when it was inside), then the menu. */
/** Closes the open sub panel: its tile returns to Default (same event as a second click on it). */
const closePanel = () => {
if (!openPanelId) return
setActive(activeIds.filter(id => id !== openPanelId), { item: { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }, active: false })
}
/**
* The More dropdown and a sub panel never show together (they would cover each other): opening the dropdown closes
* the open panel first; a panel row inside the dropdown closes the dropdown (see the dropdown click); a click
* outside the dropdown (e.g. the 최근파일 tile in the row) closes it.
*/
const openMore = () => {
closePanel()
setMoreOpen(true)
}
/** Escape: the More dropdown closes first, then an open sub panel (focus back on its tile when it was inside), then the menu. */
const escape = () => {
if (moreOpen) {
const focusInside = morePanelRef.current?.contains(document.activeElement)
setMoreOpen(false)
if (focusInside) moreButtonRef.current?.focus()
return
}
if (openPanelId) {
const item = { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }
const focusInside = rootRef.current?.contains(document.activeElement)
setActive(activeIds.filter(id => id !== openPanelId), { item, active: false })
if (focusInside) rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)?.focus()
closePanel()
if (focusInside) (panelFromMore ? moreButtonRef.current : rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`))?.focus()
return
}
close("escape")
@@ -172,7 +316,7 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
onClick?.(event)
if (event.defaultPrevented || !closeOnBackgroundClick) return
const target = event.target as Element
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel")) close("background")
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel, .hm-sw-home-menu__more-panel")) close("background")
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
@@ -184,14 +328,34 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
escape()
return
}
// ArrowDown on the tile of the open sub panel moves into the panel (keyboard access, not in Figma).
if (event.key === "ArrowDown" && openPanelId && (document.activeElement as HTMLElement | null)?.dataset.itemId === openPanelId) {
const focused = document.activeElement as HTMLElement | null
const onMore = !!focused && focused === moreButtonRef.current
// ArrowDown on the tile of the open sub panel (or on More, for a panel opened from it) moves into the panel
// (keyboard access, not in Figma).
if (event.key === "ArrowDown" && openPanelId && (focused?.dataset.itemId === openPanelId || (onMore && panelFromMore))) {
const first = rootRef.current?.querySelector<HTMLElement>(".hm-sw-home-menu__panel :is(button, [href], input, [tabindex]):not(:disabled, [tabindex='-1'])")
if (first) { event.preventDefault(); first.focus() }
return
}
// More dropdown: ArrowDown on More opens it on its first text row; ArrowUp / ArrowDown move through the rows and
// ArrowUp on the first row goes back to More.
if (event.key === "ArrowDown" && onMore) {
event.preventDefault()
if (moreOpen) focusableItems(morePanelRef.current, TEXT_ITEM_SELECTOR)[0]?.focus()
else { pendingMoreFocus.current = true; openMore() }
return
}
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && focused && morePanelRef.current?.contains(focused)) {
const rows = focusableItems(morePanelRef.current, TEXT_ITEM_SELECTOR)
const index = rows.indexOf(focused)
if (index < 0) return
event.preventDefault()
if (event.key === "ArrowUp" && index === 0) moreButtonRef.current?.focus()
else rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]?.focus()
return
}
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
const items = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const items = focusableItems(rootRef.current)
const index = items.indexOf(document.activeElement as HTMLElement)
if (index < 0) return
event.preventDefault()
@@ -202,6 +366,9 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
const openPanel = openPanelId ? panels[openPanelId] : undefined
// More tile: Variant3 while its dropdown is open or a panel of a tile inside it is open.
const moreActive = moreOpen || (!!openPanel && panelFromMore)
const hiddenTitles = sections?.filter(section => !section.hidden).slice(rowCount).map(section => section.title)
return (
<WindowContext.Provider value={context}>
@@ -213,6 +380,7 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
className={`hm-sw-home-menu${className ? ` ${className}` : ""}`}
data-accent={resolvedAccent}
data-panel-open={openPanel ? "true" : undefined}
data-overflow={overflowCount > 0 || undefined}
style={{ "--hm-sw-home-menu-title-art": `url("${titleBadge}")`, ...style } as CSSProperties}
onClick={handleClick}
onKeyDown={handleKeyDown}
@@ -221,19 +389,65 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
>
{closeOnEscape && <SWHomeMenuEscapeEntry onEscape={escape} raiseRef={raiseRef} />}
<div className="hm-sw-home-menu__sections">
{sections
? sections.filter(section => !section.hidden).map(section => (
<SWHomeMenuSection key={section.id} section={section} title={section.title}>
{section.items.filter(item => !item.hidden).map(item => <SWHomeMenuItem key={item.id} item={item} />)}
</SWHomeMenuSection>
))
: children}
{nodes.slice(0, rowCount)}
{overflowCount > 0 && (
// The existing section + tile parts (badge, group, Hover / Variant3); only the ⋯ icon is new.
<SWHomeMenuSection title={moreLabel} className="hm-sw-home-menu__more">
<button ref={moreButtonRef} type="button" className="hm-sw-home-menu__item" data-active={moreActive ? "true" : undefined}
aria-expanded={moreOpen} aria-controls={moreId} aria-label={`${moreLabel}: 그룹 ${overflowCount}개`}
title={hiddenTitles?.join(", ") ?? moreLabel} onClick={() => moreOpen ? setMoreOpen(false) : openMore()}>
<span className="hm-sw-home-menu__icon" aria-hidden="true">
<svg width="16" height="4" viewBox="0 0 16 4" focusable="false">
<circle cx="2" cy="2" r="1.6" fill="currentColor" />
<circle cx="8" cy="2" r="1.6" fill="currentColor" />
<circle cx="14" cy="2" r="1.6" fill="currentColor" />
</svg>
</span>
<span className="hm-sw-home-menu__label">{`${overflowCount}개 그룹`}</span>
</button>
</SWHomeMenuSection>
)}
</div>
{overflowCount > 0 && (
// Always laid out (positioned before it shows), visible only while open. The moved sections draw as text
// groups (title + rows) with the same items: selection, Active, panels and callbacks are unchanged.
// A row with a sub panel (최근파일) closes the dropdown; its panel opens under More.
<div ref={morePanelRef} className="hm-sw-home-menu__more-panel" id={moreId} role="group" aria-label={moreLabel}
data-open={moreOpen || undefined} style={{ left: moreLeft }}
onClick={event => {
if (!(event.target as Element).closest(".hm-sw-home-menu__text-item[aria-expanded]")) return
setMoreOpen(false)
moreButtonRef.current?.focus()
}}>
<PresentationContext.Provider value="text">{nodes.slice(rowCount)}</PresentationContext.Provider>
</div>
)}
{openPanel && (
// The accent continues over the panel's top edge where it reaches past the menu (outline accent only).
resolvedAccent === "outline" && joint.map(part => (
<span key={part.left} className="hm-sw-home-menu__joint" aria-hidden="true" style={{ left: part.left, width: part.width }} />
))
)}
{openPanel && (
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
{openPanel.panel}
</div>
)}
{/* Natural tile widths for the measured split: every section and the More section as invisible, inert tiles
(no ids, no panel registration). Zero-size clip, so it adds no scroll area. */}
<div className="hm-sw-home-menu__measure" aria-hidden="true" inert>
<PresentationContext.Provider value="measure">
<div className="hm-sw-home-menu__measure-row">{nodes}</div>
<div className="hm-sw-home-menu__measure-row">
<SWHomeMenuSection title={moreLabel} className="hm-sw-home-menu__more">
<button type="button" className="hm-sw-home-menu__item" tabIndex={-1}>
<span className="hm-sw-home-menu__icon" />
<span className="hm-sw-home-menu__label">{`${nodes.length}개 그룹`}</span>
</button>
</SWHomeMenuSection>
</div>
</PresentationContext.Provider>
</div>
</div>
</WindowContext.Provider>
)
@@ -250,6 +464,15 @@ export interface SWHomeMenuSectionProps {
/** Figma section (e.g. Frame 2408): title badge + tile group. Its width follows the number of tiles. */
export function SWHomeMenuSection({ title, children, section, className }: SWHomeMenuSectionProps) {
const titleId = useId()
// Inside the More dropdown: the section title over a text list of its items (not in Figma, 2026-10-19).
if (useContext(PresentationContext) === "text") return (
<SectionContext.Provider value={section}>
<div className={`hm-sw-home-menu__text-group${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
<span className="hm-sw-home-menu__text-title" id={titleId}>{title}</span>
<div className="hm-sw-home-menu__text-list">{children}</div>
</div>
</SectionContext.Provider>
)
return (
<SectionContext.Provider value={section}>
<div className={`hm-sw-home-menu__section${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
@@ -277,25 +500,32 @@ export interface SWHomeMenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButto
onSelect?: (event: SWHomeMenuSelectEvent) => void
}
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (Active, same look as Variant2). */
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (pressed, or Active for a panel tile; same look as Variant2). */
export function SWHomeMenuItem({
item: itemProp, itemId, label, icon, hoverIcon, toggle, panel, panelLabel, active: activeProp, defaultActive = false,
onActiveChange, onSelect, disabled, title, className, onClick, ...rest
}: SWHomeMenuItemProps) {
const host = useContext(WindowContext)
const section = useContext(SectionContext)
const presentation = useContext(PresentationContext)
const generatedId = useId()
const item: SWHomeMenuItemData = itemProp ?? {
id: itemId ?? generatedId, label: label ?? "", icon, hoverIcon, toggle, panel, panelLabel, disabled, title, onSelect,
"aria-label": rest["aria-label"],
}
const [ownActive, setOwnActive] = useState(defaultActive)
const toggles = item.toggle !== false
const active = toggles && (host ? host.isActive(item.id) : activeProp ?? ownActive)
// Toggle by default only for a tile with a sub panel (최근파일: Active while its panel is open) and, outside a menu,
// for a tile given active / defaultActive / onActiveChange. Every other tile is an action button: Hover and the
// pressed moment (:active) only, nothing stays on after the click (2026-10-19 user decision).
const toggles = item.toggle ?? (host ? item.panel != null : activeProp !== undefined || defaultActive || onActiveChange !== undefined)
// Inside a menu, host-given activeIds still draw Variant3 (display only, e.g. the Figma Variant3 set); a click on an
// action tile never changes them.
const active = host ? host.isActive(item.id) : toggles && (activeProp ?? ownActive)
const isDisabled = item.disabled ?? disabled
const hasPanel = host != null && item.panel != null
const register = host?.registerPanel
// The measure copy never registers: the visible tile or text row of the same item does.
const register = presentation === "measure" ? undefined : host?.registerPanel
useLayoutEffect(() => {
if (!register) return
register(item.id, item.panel ?? undefined, item.panelLabel ?? item.label)
@@ -317,6 +547,31 @@ export function SWHomeMenuItem({
item.onSelect?.({ item, active: next })
}
if (presentation === "measure") return (
<button type="button" className={`hm-sw-home-menu__item${className ? ` ${className}` : ""}`} tabIndex={-1} disabled={isDisabled}>
{item.icon != null && <span className="hm-sw-home-menu__icon">{item.icon}</span>}
<span className="hm-sw-home-menu__label">{item.label}</span>
</button>
)
// More dropdown row: the label only; Hover / Active / disabled and the click are the tile's (Active = ✓, panel = ›).
if (presentation === "text") return (
<button
type="button"
{...rest}
className={`hm-sw-home-menu__text-item${className ? ` ${className}` : ""}`}
data-item-id={item.id}
data-active={active ? "true" : undefined}
aria-pressed={toggles && !hasPanel ? active : undefined}
aria-expanded={hasPanel ? active : undefined}
aria-controls={hasPanel && active ? host.panelId : undefined}
aria-label={item["aria-label"]}
title={item.title ?? item.label}
disabled={isDisabled}
onClick={handleClick}
>
<span className="hm-sw-home-menu__text-label">{item.label}</span>
</button>
)
return (
<button
type="button"
@@ -1,8 +1,10 @@
import { useId, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import "../../theme/sw-home-menu.css"
import { useSWHomeMenuClose } from "./SWHomeMenu"
import type { SWFileEntry } from "./types"
type PreviewSide = "right" | "left" | "below" | "above"
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/** Host-owned list (most recent first). Nothing is read from disk here. */
files: readonly SWFileEntry[]
@@ -31,16 +33,33 @@ export function SWRecentFiles({
const closeWindow = useSWHomeMenuClose()
const listId = useId()
const listRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number; rowHeight: number; side: PreviewSide } | null>(null)
const current = hovered ? files.find(file => file.id === hovered.id) : undefined
const preview = current ? current.preview ?? renderPreview?.(current) : undefined
const show = (file: SWFileEntry | null, row?: HTMLElement) => {
const list = listRef.current
const top = row && list ? row.offsetTop - list.scrollTop + 1 : 0
setHovered(file ? { id: file.id, top } : null)
// Beside the list on the right (Figma); on the left when the viewport has no room there (narrow widths, the list
// opened from More at the right edge); over the list under the row when neither side has room (e.g. 390px), so
// the preview never adds a page scroll and stays readable.
const width = list ? parseFloat(getComputedStyle(list).getPropertyValue("--hm-sw-recent-files-preview-width")) || 0 : 0
const rect = list?.getBoundingClientRect()
const side: PreviewSide = !rect || rect.right + 1 + width <= document.documentElement.clientWidth ? "right"
: rect.left - 1 - width >= 0 ? "left" : "below"
setHovered(file ? { id: file.id, top, rowHeight: row?.offsetHeight ?? 0, side } : null)
if ((file?.id ?? null) !== (hovered?.id ?? null)) onFileHover?.(file)
}
// Over the list: under the row, or above it when the viewport has no room below (and there is room above).
useLayoutEffect(() => {
const preview = previewRef.current
if (hovered?.side !== "below" || !preview) return
const rect = preview.getBoundingClientRect()
if (rect.bottom > document.documentElement.clientHeight && rect.top - hovered.rowHeight - rect.height >= 0)
setHovered({ ...hovered, side: "above" })
}, [hovered])
const open = (file: SWFileEntry) => {
if (file.disabled) return
onFileOpen?.(file)
@@ -82,7 +101,9 @@ export function SWRecentFiles({
))}
</div>
{current && preview != null && (
<div className="hm-sw-recent-files__preview" style={{ top: hovered?.top }} aria-hidden="true">
<div ref={previewRef} className="hm-sw-recent-files__preview" data-side={hovered?.side} aria-hidden="true"
style={{ top: hovered?.side === "below" ? hovered.top - 1 + hovered.rowHeight
: hovered?.side === "above" ? hovered.top - 1 : hovered?.top }}>
<span className="hm-sw-recent-files__preview-title">{current.previewTitle ?? current.path ?? current.name}</span>
<div className="hm-sw-recent-files__preview-body">{preview}</div>
</div>
@@ -13,8 +13,10 @@ export interface SWHomeMenuItemData {
/** Replaces `icon` on Hover and Active, for fixed-color image pairs (Figma default / hover SVG). Same name as SWToolButton. */
hoverIcon?: ReactNode
/**
* true (default): a click toggles Active (Figma Default → Variant2 on hover → Variant3 on click → Default).
* false: a plain action button without Active state.
* Default: true only for an item with a `panel` (최근파일: Active = Variant3 while its panel is open, off when it
* closes). Every other item is an action button (2026-10-19 user decision): Hover (Variant2) and the pressed moment
* (Variant3 while held) only; nothing stays on after the click and no ✓ in the More text menu. Its `onSelect` /
* `onItemSelect` still run (active = false). true: opt in to a toggle that keeps Active (Default → Variant3 → Default).
*/
toggle?: boolean
/**
@@ -44,7 +46,7 @@ export interface SWHomeMenuSectionData {
export interface SWHomeMenuSelectEvent {
item: SWHomeMenuItemData
section?: SWHomeMenuSectionData
/** Active state after this click (always false for `toggle: false` items). */
/** Active state after this click (always false for action items, i.e. not toggling). */
active: boolean
}
@@ -66,11 +68,11 @@ export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "o
/** Uncontrolled initial state. Default false: the menu is hidden until the logo is clicked. */
defaultOpen?: boolean
onOpenChange?: (open: boolean, reason: SWHomeMenuCloseReason) => void
/** Controlled Active (Figma Variant3) tile ids. */
/** Controlled Active (Figma Variant3) tile ids. A click changes them only for toggling items (by default the panel tile, 최근파일). */
activeIds?: readonly string[]
defaultActiveIds?: readonly string[]
onActiveChange?: (activeIds: string[], event: SWHomeMenuSelectEvent) => void
/** `multiple` (default, each tile toggles on its own as in Figma) or `single` (one Active tile at a time). */
/** Toggling items: `multiple` (default, each toggles on its own) or `single` (one Active at a time). */
selection?: "multiple" | "single"
/** Every tile click, after the item's own `onSelect`. */
onItemSelect?: (event: SWHomeMenuSelectEvent) => void
@@ -87,6 +89,12 @@ export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "o
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
returnFocusRef?: RefObject<HTMLElement | null>
accent?: SWHomeMenuAccent
/**
* Not in Figma (1920px only): when the sections do not fit the width the host gives the menu, the trailing ones
* move into a More section (⋯ tile); its dropdown lists them as a text menu (section title + item rows, same
* selection, Active, panels and callbacks as the tiles). They return when there is room again. Badge text and accessible name of that section. Default "더보기".
*/
moreLabel?: string
"aria-label"?: string
}
+148 -13
View File
@@ -26,6 +26,22 @@
/* Bottom accent: 26:10591 per-section strip (top → bottom) / 662:67267 2px outline (bottom → top). */
--hm-sw-home-menu-accent-strip: linear-gradient(180deg, #5f4b0f -6.4%, #ff5100 96.8%);
--hm-sw-home-menu-accent-outline: linear-gradient(0deg, #ff5100 0%, #5f4b0f 76.7%);
--hm-sw-home-menu-accent-outline-width: 2px;
--hm-sw-home-menu-accent-outline-height: 30px;
/* More dropdown (not in Figma): a text menu in the SW brown dropdown family (header/menu/레이어 26:10940, rows
header/dropdown/brown/row 26:10687): surface, border, radius, shadow, 30px rows, row hover; group title in the
최근파일 path color, groups split by the layer footer divider. */
--hm-sw-home-menu-more-background: var(--hm-sw-header-layer-menu-background);
--hm-sw-home-menu-more-border-color: var(--hm-sw-header-layer-menu-border-color);
--hm-sw-home-menu-more-radius: var(--hm-sw-header-layer-menu-radius);
--hm-sw-home-menu-more-shadow: var(--hm-sw-header-layer-menu-shadow);
--hm-sw-home-menu-more-offset: 2px;
--hm-sw-home-menu-more-min-width: 180px;
--hm-sw-home-menu-more-divider-color: var(--hm-sw-header-layer-footer-border-color);
--hm-sw-home-menu-more-title-color: var(--hm-sw-recent-files-path-color);
--hm-sw-home-menu-more-row-height: 30px;
--hm-sw-home-menu-more-row-hover-background: var(--hm-sw-header-layer-row-hover-background);
--hm-sw-home-menu-more-row-hover-color: var(--hm-sw-header-layer-row-hover-color);
/* Optional sub panel (Figma 최근파일 662:67331 / 파일출력 2뎁스 662:67471). */
--hm-sw-recent-files-width: 354px;
@@ -49,10 +65,108 @@
max-width: 100%;
/* 2px of the accent strip shows left, right and under the sections (menu 1305 x 77 in Figma). */
padding: 0 2px 2px;
/* Not in Figma (1920px only): narrower hosts scroll the menu horizontally, like the property bar. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
/* Not in Figma (1920px only): narrower hosts get the measured More section instead of a scroller (SWHomeMenu).
The clip only keeps the badge shadow and the strip inside the menu, as before; nothing is cut off. */
overflow: hidden;
}
/* Measurement pass (one synchronous read, removed before paint) and overflow: the menu takes all the room of its host
(clamped by max-width), so with More it still covers the whole area SWHeader gives it, as the full menu does. */
.hm-sw-home-menu:is([data-measuring], [data-overflow]) { width: 100000px; }
/* More fills the rest of the row with the section surface (the menu background stays as it is); only its badge and
⋯ tile sit right after the last shown section (2026-10-19 user correction: start, not end). */
.hm-sw-home-menu__section.hm-sw-home-menu__more { flex: 1 0 auto; align-items: flex-start; }
/* Natural tile widths (SWHomeMenu measure copy): laid out, invisible, zero-size clip (no scroll area, no hit area). */
.hm-sw-home-menu__measure {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.hm-sw-home-menu__measure-row { display: flex; align-items: flex-start; width: max-content; }
/* More (not in Figma): the section + tile parts above with a ⋯ icon. Its dropdown sits under the ⋯ tile, left edges aligned
(left set from the DOM): a text menu of the moved sections (title + rows). Above the sub panel; laid out while
closed so it is positioned before it shows. */
.hm-sw-home-menu__more-panel {
position: absolute;
z-index: 2;
top: calc(100% + var(--hm-sw-home-menu-more-offset));
display: flex;
flex-direction: column;
min-width: var(--hm-sw-home-menu-more-min-width);
max-width: calc(100vw - 16px);
padding: 4px 0;
border: 1px solid var(--hm-sw-home-menu-more-border-color);
border-radius: var(--hm-sw-home-menu-more-radius);
background: var(--hm-sw-home-menu-more-background);
box-shadow: var(--hm-sw-home-menu-more-shadow);
color: var(--hm-sw-home-menu-item-color);
}
.hm-sw-home-menu__more-panel:not([data-open]) { visibility: hidden; pointer-events: none; }
.hm-sw-home-menu__text-group { display: flex; flex-direction: column; padding: 4px 0; }
.hm-sw-home-menu__text-group + .hm-sw-home-menu__text-group { border-top: 1px solid var(--hm-sw-home-menu-more-divider-color); }
.hm-sw-home-menu__text-title {
padding: 4px 12px;
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
color: var(--hm-sw-home-menu-more-title-color);
font-size: var(--hm-sw-header-label-size);
font-weight: 700;
line-height: 16px;
letter-spacing: 0.04em;
white-space: nowrap;
text-overflow: ellipsis;
text-shadow: var(--hm-sw-header-label-outline);
}
.hm-sw-home-menu__text-list { display: flex; flex-direction: column; }
/* Row = header/dropdown/brown/row: 30px, 12px in, 14px 700 with the menu text outline. */
.hm-sw-home-menu__text-item {
display: flex;
align-items: center;
gap: 12px;
height: var(--hm-sw-home-menu-more-row-height);
padding: 0 12px;
color: var(--hm-sw-home-menu-item-color);
font-size: var(--hm-sw-header-font-size);
font-weight: 700;
line-height: 16px;
letter-spacing: 0.04em;
white-space: nowrap;
text-align: left;
}
.hm-sw-home-menu__text-label {
flex: 1 1 auto;
min-width: 0;
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
text-overflow: ellipsis;
text-shadow: var(--hm-sw-header-text-outline);
}
/* Indicator column: › for a row that opens a sub panel (최근파일); ✓ only for an opt-in toggle row (toggle: true). */
.hm-sw-home-menu__text-item::after { content: ""; flex: 0 0 14px; width: 14px; text-align: center; }
.hm-sw-home-menu__text-item[aria-pressed="true"]::after { content: "✓"; }
.hm-sw-home-menu__text-item[aria-expanded]::after { content: "›"; }
/* Hover = pressed = Active (the tiles' Variant2 = Variant3, the brown rows' hover = selected): brown row, orange
text. Action rows keep nothing after the click; only the 최근파일 row stays Active while its list is open. */
.hm-sw-home-menu__text-item:is(:hover, :active, [data-active="true"]):not(:disabled) {
background: var(--hm-sw-home-menu-more-row-hover-background);
color: var(--hm-sw-home-menu-more-row-hover-color);
}
/* Accent joint (narrow widths, 2026-10-19): where the sub panel reaches past the menu, the outline's bottom band runs
on over the panel's top edge — same 2px, same gradient slice (the outline gradient sized to its box, bottom), so
the menu's bottom line and the list's side borders meet without a gap. Left / width from the measured positions. */
.hm-sw-home-menu__joint {
position: absolute;
z-index: 1;
top: calc(100% - var(--hm-sw-home-menu-accent-outline-width));
height: var(--hm-sw-home-menu-accent-outline-width);
background: var(--hm-sw-home-menu-accent-outline) left bottom / 100% var(--hm-sw-home-menu-accent-outline-height) no-repeat;
pointer-events: none;
}
/* Sub panel under the menu, at its tile (position set from the DOM). */
.hm-sw-home-menu__panel { position: absolute; z-index: 1; top: 100%; }
@@ -104,8 +218,8 @@
top: 47px;
right: 0;
left: 0;
height: 30px;
padding: 2px;
height: var(--hm-sw-home-menu-accent-outline-height);
padding: var(--hm-sw-home-menu-accent-outline-width);
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-accent-outline);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
@@ -238,16 +352,17 @@
text-shadow: var(--hm-sw-header-text-outline);
}
/* Variant2 (hover) = Variant3 (active): orange stroke, #FF5C00 icon and label, art 1px up-left. */
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"])::after { opacity: 1; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) {
/* Variant2 (hover) = Variant3 (pressed while held; stays only as Active of a panel tile / More): orange stroke,
#FF5C00 icon and label, art 1px up-left. Action tiles keep nothing after the click (2026-10-19). */
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"])::after { opacity: 1; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) {
--hm-sw-home-menu-icon-color: var(--hm-sw-home-menu-item-accent-color);
color: var(--hm-sw-home-menu-item-accent-color);
}
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
/* SWRecentFiles — Figma 최근파일 662:67331 (354 x 750: rows 50px) + 2-depth preview 662:67471 (310 x 270). */
.hm-sw-recent-files { position: relative; width: var(--hm-sw-recent-files-width); }
@@ -321,6 +436,26 @@
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.5));
pointer-events: none;
}
/* No room on the right (narrow widths): the same preview mirrored on the left of the list. */
.hm-sw-recent-files__preview[data-side="left"] {
right: calc(100% + 1px);
left: auto;
border-right: 0;
border-left: 1px solid var(--hm-sw-recent-files-preview-background);
border-radius: 3px 0 0 4px;
box-shadow: inset -2px 0 2.5px rgba(0, 0, 0, 0.5);
}
.hm-sw-recent-files__preview[data-side="left"] .hm-sw-recent-files__preview-body { border-radius: 0 0 0 4px; }
/* No room on either side (e.g. 390px): the same preview over the list, right under the row (above it when the
viewport has no room below). Not in Figma (2026-10-19 사용자 확정). */
.hm-sw-recent-files__preview:is([data-side="below"], [data-side="above"]) {
left: 1px;
border-left: 1px solid var(--hm-sw-recent-files-preview-background);
border-radius: 4px;
box-shadow: none;
}
.hm-sw-recent-files__preview[data-side="above"] { transform: translateY(-100%); }
.hm-sw-recent-files__preview:is([data-side="below"], [data-side="above"]) .hm-sw-recent-files__preview-body { border-radius: 0 0 4px 4px; }
.hm-sw-recent-files__preview-title {
width: 294px;
overflow: hidden;
+40
View File
@@ -29,6 +29,9 @@
/* Figma header/btn_home (26:10655) "hover" stroke: 1px, top -> bottom. */
--hm-sw-header-hover-border-gradient: linear-gradient(180deg, #a67d03 0%, #f2cc0f 16.41%, #fadd4f 37.19%, #f2cc0f 62.03%, #926e03 87.88%);
--hm-sw-header-hover-border-width: 1px;
/* Figma 662:67267 "home on" (logo menu open): header border-top / border-bottom, 2px, full width. */
--hm-sw-header-home-on-line-color: #5f4b0f;
--hm-sw-header-home-on-line-width: 2px;
/* Figma header/document_tit (26:10662) document dropdown */
--hm-sw-header-document-menu-width: 310px;
@@ -1883,7 +1886,44 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
/* Measured More (SWHeader, 2026-10-19): on top of the breakpoints, parts move into More while the object area has no
room for them and return when it has. data-measuring is set for one synchronous layout read and removed before
paint: nothing in the object area shrinks, grows or takes free space through an auto margin, so the in-flow content
shows the room it really needs. A configured file name counts with the smallest default file-name width (96px).
!important: measurement only, it must win over every tier and representative-slot rule above. */
.hm-sw-header[data-measuring] :is(.hm-sw-header__object-area, .hm-sw-header__fields, .hm-sw-header__control) > * { flex-grow: 0 !important; flex-shrink: 0 !important; }
.hm-sw-header[data-measuring] .hm-sw-header__properties,
.hm-sw-header[data-measuring] .hm-sw-header__object-area[data-squeezed~="properties"] > .hm-sw-header__more { margin-left: 0 !important; }
@container hm-sw-header (max-width: 1079.98px) {
.hm-sw-header[data-measuring] .hm-sw-header__more { margin-left: 0 !important; }
}
.hm-sw-header[data-measuring] .hm-sw-header__control[data-control="document"] { flex: 0 0 96px !important; min-width: 0 !important; }
/* Measured parts take the place the breakpoints give them: without 그리기모드 the fields start 4px in (as < 1180),
without the property block More sits at the end of the object area (as < 1080). */
.hm-sw-header__object-area[data-squeezed~="drawingMode"]:not([data-configured]) > .hm-sw-header__fields { padding-left: 4px; }
.hm-sw-header__object-area[data-squeezed~="properties"] > .hm-sw-header__more { margin-left: auto; margin-right: 4px; }
/* Logo (home) menu layer (SWHeader homeMenu, 2026-10-19). Top of the header, left / max-width measured from the
object area (−2px) to the window area. Above the property bar anchor (2), under the dropdowns (30) and the
property window (40). The logo, the file name and the window area stay visible beside it. */
.hm-sw-header__home-menu { position: absolute; top: 0; z-index: 20; }
/* Home On (Figma 662:67267 btn_home + border-top / border-bottom, 2026-10-19): only while the logo menu is open.
The logo keeps the yellow hover border (the Home On asset draws the same 1px inner gradient border) and the header
gets the 2px #5F4B0F lines across its full width. Figma layer order: border-bottom under the menu (20) over the
file name / window area, border-top over the menu, the logo over both. Lines are overlays: no size or position
changes, and closing the menu removes them. SW normal mode only (SWViewerHeader has no logo menu). */
.hm-sw-header[data-home-menu-open] .hm-sw-header__home-bg-hover { opacity: 1; }
.hm-sw-header[data-home-menu-open] > .hm-sw-header__bar > .hm-sw-header__home { z-index: 22; }
.hm-sw-header[data-home-menu-open]::before,
.hm-sw-header[data-home-menu-open]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: var(--hm-sw-header-home-on-line-width);
background: var(--hm-sw-header-home-on-line-color);
pointer-events: none;
}
.hm-sw-header[data-home-menu-open]::before { top: 0; z-index: 21; }
.hm-sw-header[data-home-menu-open]::after { top: calc(var(--hm-sw-header-bar-height) - var(--hm-sw-header-home-on-line-width)); z-index: 1; }
@@ -21,7 +21,8 @@ page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
const goto = async id => {
await page.goto(`${base}/iframe.html?id=sw-home-menu--${id}&viewMode=story`)
// 2026-10-19: the Home Menu Stories moved under SW/Header (sw-home-menu--<id> -> sw-header--home-menu-<id>).
await page.goto(`${base}/iframe.html?id=sw-header--home-menu-${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
@@ -36,7 +37,7 @@ const activeIsHome = () => page.evaluate(() => document.activeElement?.classList
const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8'))
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW/')))].sort()
report.data.titles = titles
check('Storybook SW titles: Header, Popup, Home Menu, Viewer (SW/Tool removed 2026-10-19); no SW/Header/Options', ['SW/Header', 'SW/Popup', 'SW/Home Menu', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.some(t => t.startsWith('SW/Header/')), titles)
check('Storybook SW titles: Header, Popup, Viewer (SW/Tool removed, SW/Home Menu merged into SW/Header 2026-10-19); no SW/Header/Options; sw-header--home-menu listed', ['SW/Header', 'SW/Popup', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.includes('SW/Home Menu') && !titles.some(t => t.startsWith('SW/Header/')) && !!index.entries['sw-header--home-menu'], titles)
// 2. Figma 26:10591 geometry at 1920 through SWHeader's measured placement
await goto('capture-default')
@@ -73,9 +74,10 @@ await page.waitForTimeout(50)
const hover = await tile.evaluate(e => getComputedStyle(e.querySelector('.hm-sw-home-menu__label')).color)
check('tile Hover: label #FF5C00', hover === 'rgb(255, 92, 0)', hover)
await tile.click()
const pressedOn = await tile.getAttribute('aria-pressed')
await tile.click()
check('tile click → Active (aria-pressed true), re-click → Default', pressedOn === 'true' && await tile.getAttribute('aria-pressed') === 'false', pressedOn)
await page.mouse.move(1, 1)
// 2026-10-19: tiles without a panel are action buttons — the click runs (onItemSelect) and nothing stays on.
const afterClick = await tile.evaluate(e => ({ pressed: e.getAttribute('aria-pressed'), active: e.dataset.active ?? null, ring: getComputedStyle(e, '::after').opacity }))
check('action tile click: onItemSelect, no Active / aria-pressed / ring after the click', afterClick.pressed === null && afterClick.active === null && afterClick.ring === '0' && (await log()).includes('onItemSelect(working / save, active=false)'), afterClick)
await home().click()
const text = await log()
check('logo re-click closes; onHomeClick never called while homeMenu is set', await menu().count() === 0 && !text.includes('onHomeClick') && text.includes('onHomeMenuOpenChange(false, home)'), text)
@@ -136,7 +138,7 @@ await page.keyboard.press('Escape')
const step5 = { menu: await menu().count(), popup: await popup.count(), focusHome: await activeIsHome() }
check('Escape inside the menu closes only the menu (popup stays), focus → logo', step5.menu === 0 && step5.popup === 1 && step5.focusHome, step5)
// 7. Responsive placement (measured), internal scroll reported
// 7. Responsive placement (measured); narrow widths use the measured More instead of a scroller (2026-10-19)
const responsive = []
for (const width of [1920, 1440, 1080, 768, 520, 390]) {
await page.setViewportSize({ width, height: 900 })
@@ -145,7 +147,7 @@ for (const width of [1920, 1440, 1080, 768, 520, 390]) {
const r = s => document.querySelector(s)?.getBoundingClientRect()
const header = r('.hm-sw-header'), area = r('.hm-sw-header__object-area'), win = r('.hm-sw-header__window'), m = r('.hm-sw-home-menu'), homeBox = r('.hm-sw-header__home'), doc = r('.hm-sw-header__document')
const scroller = document.querySelector('.hm-sw-home-menu__sections')
return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth }
return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth, rowSections: scroller.querySelectorAll(':scope > .hm-sw-home-menu__section:not(.hm-sw-home-menu__more)').length, moved: document.querySelectorAll('.hm-sw-home-menu__more-panel > .hm-sw-home-menu__text-group').length }
})
responsive.push({ width, ...box })
if (width === 390 || width === 768) await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: 0, y: 0, width, height: 90 } })
@@ -153,6 +155,8 @@ for (const width of [1920, 1440, 1080, 768, 520, 390]) {
report.data.responsive = responsive
check('every width: menu starts at object area −2px, ends at the window area, covers neither logo nor file name, header 49, no page scroll',
responsive.every(r => Math.abs(r.start) < 0.6 && r.endGap > -0.6 && !r.coversHome && !r.coversDocument && r.headerHeight === 49 && !r.pageScroll), responsive)
check('every width: no horizontal scroll in the menu; 5 sections = row + More (1920: all in the row)',
responsive.every(r => !r.scrolls && r.rowSections + r.moved === 5 && (r.width !== 1920 || r.moved === 0)), responsive)
// 8. Without homeMenu (existing Header): the logo still calls onHomeClick only, no menu, no aria-expanded
await page.setViewportSize({ width: 1920, height: 1000 })
@@ -17,6 +17,10 @@ export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidde
// 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"]
// 2026-10-19 (Header home menu merge, user request): the SW/Home Menu sidebar menu was removed; its Stories moved under
// SW/Header as sw-header--home-menu (sidebar) and sw-header--home-menu-* (verification tag, reachable by URL).
export const movedHomeMenuIds = Object.fromEntries(["figma-home-on:figma", "capture-default", "capture-active", "capture-outline", "capture-recent", "header-composition", "variants", "dynamic-data", "standalone", "recent-files-panel:recent-files", "property-bar-check", "capture-property-bar", "integration", "capture-responsive"]
.map(entry => entry.split(":")).map(([from, to = from]) => [`sw-home-menu--${from}`, `sw-header--home-menu-${to}`]))
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',