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
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
// Public API only, like a consuming project.
|
||||
import {
|
||||
@@ -20,10 +20,12 @@ import figmaPreview from "./assets/home-menu/662-67471_file-preview-2depth.png"
|
||||
import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png"
|
||||
|
||||
// Owner: docs/sw/figma-additional/analysis-report.md (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 / 662:67267).
|
||||
// 2026-10-19: the former SW/Home Menu Stories live under SW/Header (SW normal mode only; SW/Viewer has no logo menu).
|
||||
// "Home Menu" is the sidebar Story; the other Stories keep their checks reachable by URL (verification tag).
|
||||
const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
|
||||
const meta = {
|
||||
title: "SW/Home Menu",
|
||||
title: "SW/Header",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
@@ -54,9 +56,10 @@ const describe = (event: SWHomeMenuSelectEvent) => `onItemSelect(${event.section
|
||||
/**
|
||||
* SWHeader `homeMenu`: the logo toggles the menu (no onHomeClick), SWHeader places it from the measured header
|
||||
* (object area − 2px to the window area; Figma x 375 → 1680 at 1920). New / Open stay in the document dropdown.
|
||||
* `autoFocus={false}`: a menu open on load does not take focus (several menus share the Home Menu page).
|
||||
*/
|
||||
function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], accent, recent = true, children }: {
|
||||
width?: number; defaultOpen?: boolean; defaultActiveIds?: string[]; accent?: SWHomeMenuAccent; recent?: boolean
|
||||
function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], accent, recent = true, autoFocus, children }: {
|
||||
width?: number; defaultOpen?: boolean; defaultActiveIds?: string[]; accent?: SWHomeMenuAccent; recent?: boolean; autoFocus?: boolean
|
||||
children?: (log: ReactNode) => ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
@@ -74,7 +77,7 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
|
||||
onHomeClick={() => record("onHomeClick (homeMenu 사용 중에는 호출되지 않음)")}
|
||||
homeMenuOpen={open} onHomeMenuOpenChange={(next, reason) => { setOpen(next); record(`onHomeMenuOpenChange(${next}, ${reason})`) }}
|
||||
homeMenu={{
|
||||
sections, accent, activeIds,
|
||||
sections, accent, activeIds, autoFocus,
|
||||
onActiveChange: next => { setActiveIds(next); record(`onActiveChange([${next.join(", ")}])`) },
|
||||
onItemSelect: event => record(describe(event)),
|
||||
}} />
|
||||
@@ -84,13 +87,86 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
|
||||
)
|
||||
}
|
||||
|
||||
export const FigmaHomeOn: Story = {
|
||||
name: "Figma 재현 (26:10591)",
|
||||
/**
|
||||
* Header width set by a range input (Storybook control only): the header measures its own width. The range ends at
|
||||
* the room the page gives (no page scroll), so the browser width and the slider both drive it.
|
||||
*/
|
||||
function ResizableHeaderWithMenu() {
|
||||
const [width, setWidth] = useState(1280)
|
||||
const [room, setRoom] = useState(1920)
|
||||
const observed = useRef<ResizeObserver | null>(null)
|
||||
const measureRef = useCallback((element: HTMLDivElement | null) => {
|
||||
observed.current?.disconnect()
|
||||
if (!element) return
|
||||
observed.current = new ResizeObserver(() => setRoom(Math.floor(element.clientWidth)))
|
||||
observed.current.observe(element)
|
||||
}, [])
|
||||
const shown = Math.min(width, room)
|
||||
return (
|
||||
<>
|
||||
<label className="sw-home-menu-story__width">
|
||||
헤더 폭 <input type="range" min={Math.min(390, room)} max={room} step={1} value={shown} onChange={event => setWidth(Number(event.target.value))} aria-label="헤더 폭" />
|
||||
<output>{shown}px</output>
|
||||
</label>
|
||||
<div ref={measureRef}><HeaderWithMenu width={shown}>{log => log}</HeaderWithMenu></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tileTable = (
|
||||
<table className="sw-home-menu-story__table">
|
||||
<thead><tr><th>섹션</th><th>제목 노드</th><th>타일 (노드)</th></tr></thead>
|
||||
<tbody>{figmaHomeMenuSections.map(section => (
|
||||
<tr key={section.id}><td>{section.title}</td><td>{figmaTitleNodes[section.id]}</td>
|
||||
<td>{section.items.map(item => `${item.label} (${figmaTileNodes[item.id]})`).join(" · ")}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
)
|
||||
|
||||
/** The sidebar Story: the logo menu of SWHeader (SW normal mode) with its Figma states, sub panel and Escape stack. */
|
||||
export const HomeMenu: Story = {
|
||||
name: "Home Menu",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="Home Menu — 로고(홈) 버튼 메뉴"
|
||||
description="SWHeader의 homeMenu 하나로 연결합니다(SW 일반 모드 전용, SW 뷰어 모드에는 적용하지 않음). 로고 클릭 = 열기, 다시 클릭 = 닫기(onHomeClick은 호출하지 않음), 메뉴 빈 곳 클릭 = 닫기(Figma CLOSE), 타일 클릭 = 실행(누르는 동안만 Variant3, 클릭 후 ON 없음), 최근파일만 목록이 열린 동안 Variant3. Escape는 더보기 → 하위 패널 → 메뉴 순으로 닫고 로고로 Focus를 돌립니다. ←/→/Home/End로 타일을 이동합니다. New / Open은 문서 Dropdown 안에 그대로 있고, Dropdown을 열면 메뉴가 닫힙니다. 메뉴는 Header 위에 겹치는 층이라 열고 닫아도 Header 요소의 위치·너비가 바뀌지 않습니다." />
|
||||
<StorySection title="로고 클릭 · 열기 / 닫기 (현재 폭)" description="5개 섹션(파일 · 손상된 파일 · Display · 설정 · 궁금하면) 15개 타일. 마우스를 올리면 Hover(Variant2), 누르는 동안 Pressed(Variant3)이며 클릭 후 ON 상태는 남지 않습니다. 최근파일 타일만 하위 패널을 열고, 목록이 열린 동안 Variant3를 유지합니다.">
|
||||
<HeaderWithMenu>{log => log}</HeaderWithMenu>
|
||||
</StorySection>
|
||||
<StorySection title="반응형 · 헤더 폭 조절 · 더보기(⋯)" description="메뉴 위치와 폭은 고정 x가 아니라 Header를 측정해 정합니다(객체 수정 영역 −2px ~ 창 영역). 폭이 부족한 Header에서는 기존 더보기(…) 규칙이 그대로 동작하고, 메뉴는 가로 스크롤 없이 들어가는 섹션만 앞에서부터 보여 주며 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다. 더보기를 누르면 옮긴 섹션이 텍스트 메뉴(섹션 제목 + 기능 목록, 그룹 사이 구분선)로 아래에 열리고(Hover·Active·클릭은 타일과 같음, 최근파일은 기존 목록을 엶), 폭이 넓어지면 원래 자리로 돌아갑니다. 브라우저 폭이나 슬라이더로 확인합니다(슬라이더 최대 = 현재 페이지 폭).">
|
||||
<ResizableHeaderWithMenu />
|
||||
</StorySection>
|
||||
<StorySection title="Figma 26:10591 비교 (현재 폭)" description="위는 구현(열림, 현재 폭), 아래는 Figma 스크린샷(1920px을 현재 폭에 맞춰 축소)입니다. 1:1 비교는 'Home Menu · Figma 재현 (26:10591)' Story(1920px)에서 합니다. 열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 662:67267).">
|
||||
<HeaderWithMenu defaultOpen autoFocus={false} />
|
||||
<img className="sw-home-menu-story__figma-fit" src={figmaHomeOn} alt="Figma 26:10591 home on" width={1920} height={78} />
|
||||
</StorySection>
|
||||
<StorySection title="Default / Variant2 · Hover / Variant3 · Active" description="Figma 버튼 세트 15개(26:11355 … 26:11631)는 모두 같은 SWHomeMenuItem입니다. Variant2(Hover)는 마우스를 올려 확인하며 Figma에서 Variant3와 시각 속성이 같습니다. Variant3는 activeIds로 지정합니다.">
|
||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Default" /></SWTheme>
|
||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} activeIds={figmaHomeMenuItemIds} closeOnBackgroundClick={false} aria-label="Active" /></SWTheme>
|
||||
{tileTable}
|
||||
</StorySection>
|
||||
<StorySection title="최근파일 하위 패널 (662:67267)" description="최근파일 타일을 누르면(Variant3) 메뉴 하단 강조가 전체 2px 외곽선으로 바뀌고 그 아래에 최근파일 목록이 열립니다. 행에 마우스를 올리면 2뎁스 미리보기가 옆에 나타납니다. 다시 누르거나 Escape로 닫습니다. 최근파일이 더보기 안에 있으면 목록은 더보기 타일 아래에 열리고 더보기 타일이 Variant3가 됩니다. 목록과 미리보기 이미지는 Story 데이터입니다. 1920px 1:1은 'Home Menu · 최근파일 하위 패널' Story입니다.">
|
||||
<div className="sw-home-menu-story__tall"><HeaderWithMenu defaultOpen autoFocus={false} defaultActiveIds={["recent"]}>{log => log}</HeaderWithMenu></div>
|
||||
<div className="sw-home-menu-story__figma-row">
|
||||
<img src={figmaRecent} alt="Figma 662:67331 최근파일" width={362} height={758} />
|
||||
<img src={figmaPreview} alt="Figma 662:67471 파일출력 2뎁스" width={315} height={276} />
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="통합 · 속성창 · SWPopup · Escape" description="Escape는 Focus가 있는 곳을 먼저 닫습니다(메뉴 안: 하위 패널 → 메뉴, Popup 안: 그 Popup). Focus가 모두의 밖이면 가장 최근에 열거나 사용한 하나만 닫습니다.">
|
||||
<IntegrationDemo />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const HomeMenuFigma: Story = {
|
||||
name: "Home Menu · Figma 재현 (26:10591)",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="로고 클릭 메뉴 — Figma 26:10591 재현"
|
||||
description="1920px Header 위에 SWHomeMenu를 겹친 상태입니다. 위는 구현, 아래는 Figma 스크린샷(1x)입니다. 로고를 다시 누르면 닫힙니다." />
|
||||
<StorySection title="구현 (1920px)" description="SWHeader가 객체 수정 영역 시작(−2px)~창 영역 시작을 DOM에서 측정해 메뉴를 놓습니다. Figma 열림 상태의 Header #5F4B0F 상·하단 선은 별도 승인 전까지 적용하지 않습니다.">
|
||||
<StorySection title="구현 (1920px)" description="SWHeader가 객체 수정 영역 시작(−2px)~창 영역 시작을 DOM에서 측정해 메뉴를 놓습니다. 열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 662:67267).">
|
||||
<div className="sw-home-menu-story__scroll"><HeaderWithMenu width={1920} defaultOpen /></div>
|
||||
</StorySection>
|
||||
<StorySection title="Figma 26:10591">
|
||||
@@ -111,17 +187,18 @@ function FigmaCanvas({ active, accent, recent }: { active?: boolean; accent?: SW
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const CaptureDefault: Story = { name: "비교 캔버스 · Default", tags: ["!autodocs"], render: () => <FigmaCanvas /> }
|
||||
export const CaptureActive: Story = { name: "비교 캔버스 · Variant3", tags: ["!autodocs"], render: () => <FigmaCanvas active /> }
|
||||
export const CaptureOutline: Story = { name: "비교 캔버스 · outline", tags: ["!autodocs"], render: () => <FigmaCanvas accent="outline" /> }
|
||||
export const CaptureRecent: Story = { name: "비교 캔버스 · 최근파일", tags: ["!autodocs"], render: () => <FigmaCanvas recent /> }
|
||||
export const HomeMenuCaptureDefault: Story = { name: "Home Menu · 비교 캔버스 · Default", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas /> }
|
||||
export const HomeMenuCaptureActive: Story = { name: "Home Menu · 비교 캔버스 · Variant3", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas active /> }
|
||||
export const HomeMenuCaptureOutline: Story = { name: "Home Menu · 비교 캔버스 · outline", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas accent="outline" /> }
|
||||
export const HomeMenuCaptureRecent: Story = { name: "Home Menu · 비교 캔버스 · 최근파일", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas recent /> }
|
||||
|
||||
export const HeaderComposition: Story = {
|
||||
name: "Header 조합 · 로고 클릭",
|
||||
export const HomeMenuHeaderComposition: Story = {
|
||||
name: "Home Menu · Header 조합 · 로고 클릭",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SWHeader homeMenu"
|
||||
description="SWHeader의 homeMenu 하나로 연결합니다. 로고 클릭 = 열기/닫기(onHomeClick은 호출하지 않음), 메뉴 빈 곳 클릭 = 닫기(Figma CLOSE), 타일 클릭 = Active 전환, 다시 클릭 = 기본. Escape는 하위 패널 → 메뉴 순으로 닫고 로고로 Focus를 돌립니다. ←/→/Home/End로 타일을 이동합니다. New / Open은 문서 Dropdown 안에 그대로 있고, Dropdown을 열면 메뉴가 닫힙니다." />
|
||||
description="SWHeader의 homeMenu 하나로 연결합니다. 로고 클릭 = 열기/닫기(onHomeClick은 호출하지 않음), 메뉴 빈 곳 클릭 = 닫기(Figma CLOSE), 타일 클릭 = 실행(누르는 동안만 Variant3, 클릭 후 ON 없음), 최근파일만 목록이 열린 동안 Variant3. Escape는 하위 패널 → 메뉴 순으로 닫고 로고로 Focus를 돌립니다. ←/→/Home/End로 타일을 이동합니다. New / Open은 문서 Dropdown 안에 그대로 있고, Dropdown을 열면 메뉴가 닫힙니다." />
|
||||
<StorySection title="현재 폭">
|
||||
<HeaderWithMenu>{log => log}</HeaderWithMenu>
|
||||
</StorySection>
|
||||
@@ -129,8 +206,9 @@ export const HeaderComposition: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Variants: Story = {
|
||||
name: "3가지 Variant",
|
||||
export const HomeMenuVariants: Story = {
|
||||
name: "Home Menu · 3가지 Variant",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="Default / Variant2 / Variant3"
|
||||
@@ -138,21 +216,13 @@ export const Variants: Story = {
|
||||
<StorySection title="Default">
|
||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Default" /></SWTheme>
|
||||
</StorySection>
|
||||
<StorySection title="Variant2 · Hover" description="마우스를 올린 타일만 Variant2입니다. 클릭하면 Variant3, 다시 클릭하면 Default입니다.">
|
||||
<StorySection title="Variant2 · Hover" description="마우스를 올린 타일만 Variant2입니다. 누르는 동안 Variant3이고 클릭 후에는 Default로 돌아갑니다(실행 버튼). 최근파일만 목록이 열린 동안 Variant3입니다.">
|
||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Hover" /></SWTheme>
|
||||
</StorySection>
|
||||
<StorySection title="Variant3 · Active (activeIds = 15개 전체)">
|
||||
<StorySection title="Variant3 · Active (activeIds = 15개 전체)" description="Figma Variant3 시각 확인용으로 host가 activeIds를 직접 지정한 상태입니다(표시 전용). 클릭으로는 바뀌지 않습니다.">
|
||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} activeIds={figmaHomeMenuItemIds} closeOnBackgroundClick={false} aria-label="Active" /></SWTheme>
|
||||
</StorySection>
|
||||
<StorySection title="Figma 노드 대응" description="섹션 제목 tit_* 와 타일 세트">
|
||||
<table className="sw-home-menu-story__table">
|
||||
<thead><tr><th>섹션</th><th>제목 노드</th><th>타일 (노드)</th></tr></thead>
|
||||
<tbody>{figmaHomeMenuSections.map(section => (
|
||||
<tr key={section.id}><td>{section.title}</td><td>{figmaTitleNodes[section.id]}</td>
|
||||
<td>{section.items.map(item => `${item.label} (${figmaTileNodes[item.id]})`).join(" · ")}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</StorySection>
|
||||
<StorySection title="Figma 노드 대응" description="섹션 제목 tit_* 와 타일 세트">{tileTable}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -161,7 +231,7 @@ function DynamicDemo() {
|
||||
const [sections, setSections] = useState<SWHomeMenuSectionData[]>(figmaHomeMenuSections)
|
||||
const [selection, setSelection] = useState<"multiple" | "single">("multiple")
|
||||
const [accent, setAccent] = useState<SWHomeMenuAccent>("auto")
|
||||
const [activeIds, setActiveIds] = useState<string[]>(["save"])
|
||||
const [activeIds, setActiveIds] = useState<string[]>([])
|
||||
const [count, setCount] = useState(1)
|
||||
const { entries, record } = useLog()
|
||||
const update = (sectionId: string, change: (items: readonly SWHomeMenuItemData[]) => SWHomeMenuItemData[]) =>
|
||||
@@ -195,8 +265,9 @@ function DynamicDemo() {
|
||||
)
|
||||
}
|
||||
|
||||
export const DynamicData: Story = {
|
||||
name: "Props · 데이터 변경",
|
||||
export const HomeMenuDynamicData: Story = {
|
||||
name: "Home Menu · Props · 데이터 변경",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="데이터 기반 항목 · 선택 상태 제어"
|
||||
@@ -213,7 +284,7 @@ function StandaloneDemo() {
|
||||
return (
|
||||
<>
|
||||
<SWTheme className="sw-home-menu-story__stage">
|
||||
<SWHomeMenu defaultOpen aria-label="조합형 메뉴" defaultActiveIds={["b"]} closeOnBackgroundClick={false} onItemSelect={event => record(describe(event))}>
|
||||
<SWHomeMenu defaultOpen aria-label="조합형 메뉴" closeOnBackgroundClick={false} onItemSelect={event => record(describe(event))}>
|
||||
<SWHomeMenuSection title="조합형 섹션">
|
||||
<SWHomeMenuItem itemId="a" label="도구 A" icon={mono} />
|
||||
<SWHomeMenuItem itemId="b" label="도구 B" icon={mono} />
|
||||
@@ -231,8 +302,9 @@ function StandaloneDemo() {
|
||||
)
|
||||
}
|
||||
|
||||
export const Standalone: Story = {
|
||||
name: "단독 사용 · 조합형",
|
||||
export const HomeMenuStandalone: Story = {
|
||||
name: "Home Menu · 단독 사용 · 조합형",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SWTheme 안에서 단독 사용"
|
||||
@@ -242,8 +314,9 @@ export const Standalone: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const RecentFilesPanel: Story = {
|
||||
name: "최근파일 하위 패널 (662:67267)",
|
||||
export const HomeMenuRecentFiles: Story = {
|
||||
name: "Home Menu · 최근파일 하위 패널 (662:67267)",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="최근파일 — Figma 662:67267 + 662:67331 + 662:67471"
|
||||
@@ -279,8 +352,9 @@ function PropertyBarCompare() {
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
export const PropertyBarCheck: Story = {
|
||||
name: "속성Bar 비교 (26:10492)",
|
||||
export const HomeMenuPropertyBarCheck: Story = {
|
||||
name: "Home Menu · 속성Bar 비교 (26:10492)",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="속성Bar — Figma 26:10492 비교"
|
||||
@@ -292,8 +366,8 @@ export const PropertyBarCheck: Story = {
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
export const CapturePropertyBar: Story = {
|
||||
name: "비교 캔버스 · 속성Bar", tags: ["!autodocs"],
|
||||
export const HomeMenuCapturePropertyBar: Story = {
|
||||
name: "Home Menu · 비교 캔버스 · 속성Bar", tags: ["!autodocs", "verification"],
|
||||
render: () => <SWTheme style={{ background: "#060606", width: 1222, padding: 0 }}><SWHeaderPropertyBar selection={{ id: "circle", title: "원", kind: "single" }} properties={barProperties} onPropertyChange={() => {}} /></SWTheme>,
|
||||
}
|
||||
/** Integration: logo menu + property window + SWPopup at the same time (Escape stack and focus). */
|
||||
@@ -327,8 +401,9 @@ function IntegrationDemo() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const Integration: Story = {
|
||||
name: "통합 · Popup · Escape",
|
||||
export const HomeMenuIntegration: Story = {
|
||||
name: "Home Menu · 통합 · Popup · Escape",
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="로고 메뉴 + 속성창 + SWPopup"
|
||||
@@ -339,4 +414,4 @@ export const Integration: Story = {
|
||||
}
|
||||
|
||||
/** Responsive check: the header at the viewport width with the menu open (no story page chrome). */
|
||||
export const CaptureResponsive: Story = { name: "비교 캔버스 · 반응형", tags: ["!autodocs"], render: () => <HeaderWithMenu defaultOpen /> }
|
||||
export const HomeMenuCaptureResponsive: Story = { name: "Home Menu · 비교 캔버스 · 반응형", tags: ["!autodocs", "verification"], render: () => <HeaderWithMenu defaultOpen /> }
|
||||
@@ -2,6 +2,7 @@ import { useId, useLayoutEffect, useRef, useState, type CSSProperties } from "re
|
||||
import {
|
||||
SWHeader, parseSWHeaderColor, type SWHeaderProps, type SWHeaderProperty, type SWHeaderPropertyGroup,
|
||||
type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderColorMode, type SWHeaderLayer, type SWHeaderLineWeight,
|
||||
type SWHomeMenuSectionData,
|
||||
} from "@hm-design/sw"
|
||||
import { StoryPage, StoryPageHeader } from "@/components/story-page"
|
||||
|
||||
@@ -13,10 +14,12 @@ interface OverviewProps {
|
||||
propertyFixture: (selection: Selection) => SWHeaderProperty[]
|
||||
propertySelection: (selection: Selection) => SWHeaderPropertySelection
|
||||
barFixture: SWHeaderProperty[]
|
||||
/** Logo (home) menu of the SW normal mode: the logo opens and closes it (SWHeader homeMenu). */
|
||||
homeMenuSections?: readonly SWHomeMenuSectionData[]
|
||||
}
|
||||
|
||||
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
|
||||
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture }: OverviewProps) {
|
||||
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture, homeMenuSections }: OverviewProps) {
|
||||
const controlId = useId()
|
||||
const slotRef = useRef<HTMLDivElement>(null)
|
||||
const headerRef = useRef<HTMLElement>(null)
|
||||
@@ -185,7 +188,9 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
|
||||
expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus,
|
||||
shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"),
|
||||
}}
|
||||
onHomeClick={() => record("onHomeClick")} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
||||
onHomeClick={() => record("onHomeClick")}
|
||||
homeMenu={homeMenuSections && { sections: homeMenuSections, onItemSelect: event => record(`onItemSelect(${event.item.id}, ${event.active})`) }}
|
||||
onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
||||
onMinimize={() => record("onMinimize")} onClose={() => record("onClose")} onWindowMoveStart={() => record("onWindowMoveStart")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
|
||||
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
|
||||
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent,
|
||||
SWRecentFiles, SWToolButton,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import "./sw-stories.css"
|
||||
import { SWHeaderOverview } from "./sw-header-overview"
|
||||
import { figmaHomeMenuSections, figmaRecentFiles, withRecentFiles } from "./sw-home-menu-fixture"
|
||||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||||
|
||||
// Owner: docs/sw/sw-header-design-spec.md (Figma rj6wpsfk24lOWvWiVRAANP, node 26:8944 "header_green").
|
||||
@@ -179,12 +181,16 @@ function InteractiveHeader(props: Partial<SWHeaderProps>) {
|
||||
)
|
||||
}
|
||||
|
||||
// Logo menu of the Overview (Figma 26:10591 sample, same fixture as the Home Menu Story).
|
||||
const OVERVIEW_HOME_MENU = withRecentFiles(figmaHomeMenuSections, <SWRecentFiles files={figmaRecentFiles} />)
|
||||
|
||||
export const Overview: Story = {
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => <SWHeaderOverview initial={{ ...figmaArgs, ...callbacks }}
|
||||
fixtures={{ documents: SAMPLE_DOCUMENTS, layers: PROPERTY_LAYERS, colors: FIGMA_COLORS, userColors: FIGMA_USER_COLORS,
|
||||
lineTypes: PROPERTY_LINE_TYPES, lineWeights: FIGMA_LINE_WEIGHTS, textStyles: FIGMA_TEXT_STYLES, dimensionStyles: FIGMA_DIMENSION_STYLES }}
|
||||
propertyGroups={PROPERTY_GROUPS} propertyFixture={propertyFixture} propertySelection={propertySelection} barFixture={BAR_FIXTURE} />,
|
||||
propertyGroups={PROPERTY_GROUPS} propertyFixture={propertyFixture} propertySelection={propertySelection} barFixture={BAR_FIXTURE}
|
||||
homeMenuSections={OVERVIEW_HOME_MENU} />,
|
||||
}
|
||||
|
||||
export const InteractiveStates: Story = {
|
||||
@@ -776,6 +782,44 @@ function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
|
||||
|
||||
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
|
||||
|
||||
/**
|
||||
* Measured More (2026-10-19): the header width comes from a range input and the host adds content the breakpoints
|
||||
* do not know about (buttons next to the logo, extra tools). Parts move into More only while the real room is
|
||||
* missing and return to their own place, in their own order, once it is back. Range / checkboxes are Storybook controls.
|
||||
*/
|
||||
function MeasuredMoreDemo() {
|
||||
const [width, setWidth] = useState(1920)
|
||||
const [leading, setLeading] = useState(true)
|
||||
const [tools, setTools] = useState(true)
|
||||
const [homeMenu, setHomeMenu] = useState(true)
|
||||
const [barOpen, setBarOpen] = useState(false)
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||
return (
|
||||
<>
|
||||
<div className="sw-story-measured-controls" role="group" aria-label="실측 More 조건">
|
||||
<label>헤더 폭 <input type="range" min={390} max={1920} step={1} value={width} onChange={event => setWidth(Number(event.target.value))} aria-label="헤더 폭" /> <output data-measured-width="">{width}px</output></label>
|
||||
<label><input type="checkbox" checked={leading} onChange={event => setLeading(event.target.checked)} />로고 오른쪽 버튼(leading)</label>
|
||||
<label><input type="checkbox" checked={tools} onChange={event => setTools(event.target.checked)} />추가 도구(tools)</label>
|
||||
<label><input type="checkbox" checked={homeMenu} onChange={event => setHomeMenu(event.target.checked)} />홈 메뉴(homeMenu)</label>
|
||||
</div>
|
||||
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
|
||||
<MenuFrame width={width}>
|
||||
<StyleHeader onLog={record}
|
||||
leading={leading ? <><SWToolButton appearance="document" onClick={() => record("leading: 저장")}>저장</SWToolButton><SWToolButton appearance="document" onClick={() => record("leading: 내보내기")}>내보내기</SWToolButton></> : undefined}
|
||||
tools={tools ? <SWToolButton onClick={() => record("tools: 측정")}>측정</SWToolButton> : undefined}
|
||||
homeMenu={homeMenu ? { sections: OVERVIEW_HOME_MENU, onItemSelect: event => record(`onItemSelect(${event.item.id})`) } : undefined}
|
||||
onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)}
|
||||
onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
||||
propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }}
|
||||
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }}
|
||||
propertyPanel={{ selection: { id: "none", title: "선택된 객체 없음", kind: "none" } }} />
|
||||
</MenuFrame>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ResponsiveMore: Story = {
|
||||
name: "Responsive More",
|
||||
parameters: { controls: { disable: true } },
|
||||
@@ -784,8 +828,11 @@ export const ResponsiveMore: Story = {
|
||||
<StoryPageHeader
|
||||
kicker={KICKER}
|
||||
title="Responsive More — 대표 필드 전환"
|
||||
description="좁은 헤더에서 더보기(…)의 필드 항목을 고르면, 그 필드가 객체 수정 영역의 첫 칸(레이어 필드 자리, 대표 슬롯)에 표시되고 Dropdown이 바로 아래에 열립니다. 레이어는 더보기로 이동해 다시 고를 수 있습니다. 메뉴 전환은 UI 상태일 뿐이며 데이터 Callback은 호출하지 않습니다(필드 클릭과 같은 on*Open 1회만). 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)는 그대로입니다."
|
||||
description="좁은 헤더에서 더보기(…)의 필드 항목을 고르면, 그 필드가 객체 수정 영역의 첫 칸(레이어 필드 자리, 대표 슬롯)에 표시되고 Dropdown이 바로 아래에 열립니다. 레이어는 더보기로 이동해 다시 고를 수 있습니다. 메뉴 전환은 UI 상태일 뿐이며 데이터 Callback은 호출하지 않습니다(필드 클릭과 같은 on*Open 1회만). 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)는 그대로이고, 그 위에 실제 남은 공간을 측정해 부족한 메뉴만 더보기로 옮깁니다."
|
||||
/>
|
||||
<StorySection title="실제 사용 가능한 공간 기준 (폭 조절)" description="고정 폭만 보지 않고 객체 수정 영역에 실제로 남은 공간을 측정합니다. 로고 오른쪽 버튼·추가 도구처럼 Breakpoint가 모르는 요소가 공간을 쓰면, 표시할 수 없는 메뉴가 선·문자·치수스타일 → 그리기모드 → 속성Bar·속성창 → 색상 순(같은 단계는 오른쪽부터)으로 더보기에 들어갑니다. 공간이 다시 생기면 원래 자리·순서로 돌아옵니다. 로고 오른쪽 Open 버튼은 가장 먼저 옮겨지고 가장 늦게 돌아옵니다. Dropdown·홈 메뉴·더보기를 열고 닫아도 Header 요소의 위치와 너비는 바뀌지 않습니다.">
|
||||
<MeasuredMoreDemo />
|
||||
</StorySection>
|
||||
<StorySection title="전환 · Callback 기록 (1440)" description="… → 선스타일: 대표 칸이 선스타일(미리보기·이름·가중치·스케일)로 바뀌고 선스타일 Dropdown이 열립니다. … → 문자스타일: 선스타일 Dropdown이 닫히고 대표 칸이 문자스타일로. … → 레이어: 원래 배치로 복귀. 값은 모두 유지됩니다.">
|
||||
<StyleDemo width={1440} />
|
||||
</StorySection>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Storybook layout only for SW / Home Menu (frames, logs, fixtures). The menu itself uses no story class. */
|
||||
/* Storybook layout only for the SW / Header Home Menu Stories (frames, logs, fixtures). The menu itself uses no story class. */
|
||||
.sw-home-menu-story__scroll { overflow-x: auto; }
|
||||
.sw-home-menu-story__frame { position: relative; min-height: 80px; }
|
||||
.sw-home-menu-story__overlay { position: absolute; top: 0; z-index: 3; }
|
||||
@@ -11,4 +11,8 @@
|
||||
.sw-home-menu-story__table { border-collapse: collapse; font-size: 13px; }
|
||||
.sw-home-menu-story__table :is(th, td) { padding: 4px 8px; border: 1px solid #ccc; text-align: left; }
|
||||
.sw-home-menu-story__tall .sw-home-menu-story__frame { height: 860px; }
|
||||
.sw-home-menu-story__figma-row { display: flex; align-items: flex-start; gap: 16px; margin-top: 12px; }
|
||||
.sw-home-menu-story__figma-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px; margin-top: 12px; }
|
||||
.sw-home-menu-story__figma-row > img { max-width: 100%; height: auto; }
|
||||
.sw-home-menu-story__figma-fit { display: block; width: 100%; height: auto; margin-top: 12px; }
|
||||
.sw-home-menu-story__width { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; }
|
||||
.sw-home-menu-story__width input { width: min(480px, 60vw); }
|
||||
@@ -70,3 +70,7 @@
|
||||
.sw-overview-stage .hm-sw-header__document:has(.hm-sw-header__document-menu) { z-index: 3; }
|
||||
.sw-overview-status { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; min-width: 0; }
|
||||
.sw-overview-status output { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Responsive More measured section: Storybook controls (width range, host content toggles) above the frame. */
|
||||
.sw-story-measured-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-bottom: 8px; font-size: 13px; }
|
||||
.sw-story-measured-controls label { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.sw-story-measured-controls input[type="range"] { width: min(480px, 60vw); }
|
||||
Reference in new issue
Block a user