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:
1 parent
c542bdc282
commit
170a5c23ae
71 files changed
+12535
-150
No files matched your search
@@ -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는 없습니다.
|
||||
|
||||
@@ -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부터). 로고·파일명·창 영역은 가리지 않습니다.
|
||||
|
||||
@@ -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를 소비 코드에 쓰지 않습니다.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user