User-approved design review (2026-10-21): - SW Header: from 760px up nothing moves into More; the five menus stay with their titles (titles and values ellipsize), file name shrinks to 96px. Open next to the logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for configured `controls` headers). Dropdown yellow ring inside the button, black seam. - Property Bar values as read-only red text; property popup "=" labels, read-only black values, per-entry hover with pointer. - Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot guide off by default, Viewer file list without icons, Storybook widths 1920-760. - Tests: old More expectations (integrated-improvement, keyboard-dismiss, footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule; functional checks kept.
304 lines
19 KiB
TypeScript
304 lines
19 KiB
TypeScript
import { useState, type ReactNode } from "react"
|
||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||
// Public API only, like a consuming project.
|
||
import {
|
||
SWHeader, SWHeaderPropertyBar, SWHomeMenu, SWHomeMenuItem, SWHomeMenuSection, SWPopup, SWPopupRow, SWPopupSection,
|
||
SWRecentFiles, SWTheme,
|
||
type SWHeaderProperty, type SWHomeMenuAccent, type SWHomeMenuItemData, type SWHomeMenuSectionData, type SWHomeMenuSelectEvent,
|
||
} from "@hm-design/sw"
|
||
import "@hm-design/sw/styles.css"
|
||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||
import "./sw-home-menu.css"
|
||
import {
|
||
figmaHomeMenuSections, figmaRecentFiles, withRecentFiles,
|
||
} from "./sw-home-menu-fixture"
|
||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||
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 (단독 사용 hidden 2026-10-21); the other Stories keep their checks reachable
|
||
// by URL (verification tag). 2026-10-20: Figma screenshot comparisons that no test or document used were removed;
|
||
// the Figma originals stay in docs/sw/figma-additional/screenshots/.
|
||
const KICKER = "HM DESIGN SYSTEM / SW"
|
||
|
||
const meta = {
|
||
title: "SW/Header",
|
||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||
} satisfies Meta
|
||
export default meta
|
||
type Story = StoryObj<typeof meta>
|
||
|
||
const headerArgs = {
|
||
logoSrc: placeholderLogo,
|
||
logoAlt: "임시 로고",
|
||
documentTitle: "C0020101-006-평면및종단면도(06)",
|
||
drawingModeLabel: "그리기모드",
|
||
layer: { name: "0_Layer_Cludmark" },
|
||
color: { value: "255,229,0", swatch: "#ffe500" },
|
||
lineStyle: { name: "Continuous", weight: "0.25", scale: "1.00" },
|
||
textStyle: { font: "Arial", name: "Standard" },
|
||
dimensionStyle: { name: "Standard" },
|
||
}
|
||
const documents = [{ id: "doc-1", name: "C0020101-006-평면및종단면도(06)" }, { id: "doc-2", name: "C0020101-007-횡단면도" }]
|
||
|
||
function useLog() {
|
||
const [entries, setEntries] = useState<string[]>([])
|
||
return { entries, record: (entry: string) => setEntries(list => [entry, ...list].slice(0, 8)) }
|
||
}
|
||
function Log({ entries }: { entries: string[] }) {
|
||
return <ol className="sw-home-menu-story__log" aria-label="이벤트 기록">{entries.length ? entries.map((entry, index) => <li key={index}>{entry}</li>) : <li>이벤트 없음</li>}</ol>
|
||
}
|
||
const describe = (event: SWHomeMenuSelectEvent) => `onItemSelect(${event.section?.id ?? "-"} / ${event.item.id}, active=${event.active})`
|
||
|
||
/**
|
||
* 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, autoFocus, children }: {
|
||
width?: number; defaultOpen?: boolean; defaultActiveIds?: string[]; accent?: SWHomeMenuAccent; recent?: boolean; autoFocus?: boolean
|
||
children?: (log: ReactNode) => ReactNode
|
||
}) {
|
||
const [open, setOpen] = useState(defaultOpen)
|
||
const [activeIds, setActiveIds] = useState<string[]>(defaultActiveIds)
|
||
const { entries, record } = useLog()
|
||
const [sections] = useState(() => recent
|
||
? withRecentFiles(figmaHomeMenuSections, <SWRecentFiles files={figmaRecentFiles} onFileOpen={file => record(`onFileOpen(${file.id})`)} />)
|
||
: figmaHomeMenuSections)
|
||
|
||
return (
|
||
<>
|
||
<div className="sw-home-menu-story__frame" style={width ? { width } : undefined}>
|
||
<SWHeader {...headerArgs} documents={documents} activeDocumentId="doc-1"
|
||
onDocumentNew={() => record("onDocumentNew")} onDocumentOpen={() => record("onDocumentOpen")}
|
||
onHomeClick={() => record("onHomeClick (homeMenu 사용 중에는 호출되지 않음)")}
|
||
homeMenuOpen={open} onHomeMenuOpenChange={(next, reason) => { setOpen(next); record(`onHomeMenuOpenChange(${next}, ${reason})`) }}
|
||
homeMenu={{
|
||
sections, accent, activeIds, autoFocus,
|
||
onActiveChange: next => { setActiveIds(next); record(`onActiveChange([${next.join(", ")}])`) },
|
||
onItemSelect: event => record(describe(event)),
|
||
}} />
|
||
</div>
|
||
{children?.(<Log entries={entries} />)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 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="열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 26:10591 / 662:67267).">
|
||
<HeaderWithMenu defaultOpen autoFocus={false} />
|
||
</StorySection>
|
||
<StorySection title="최근파일 하위 패널 (662:67267)" description="최근파일 타일을 누르면(Variant3) 메뉴 하단 강조가 전체 2px 외곽선으로 바뀌고 그 아래에 최근파일 목록이 열립니다. 행에 마우스를 올리면 2뎁스 미리보기 영역이 옆에 나타나며 이미지는 표시하지 않고 빈 영역으로 둡니다(2026-10-21). 다시 누르거나 Escape로 닫습니다. 최근파일이 더보기 안에 있으면 목록은 더보기 타일 아래에 열리고 더보기 타일이 Variant3가 됩니다. 목록은 Story 데이터입니다.">
|
||
<div className="sw-home-menu-story__tall"><HeaderWithMenu defaultOpen autoFocus={false} defaultActiveIds={["recent"]}>{log => log}</HeaderWithMenu></div>
|
||
</StorySection>
|
||
<StorySection title="통합 · 속성창 · SWPopup · Escape" description="Escape는 Focus가 있는 곳을 먼저 닫습니다(메뉴 안: 하위 패널 → 메뉴, Popup 안: 그 Popup). Focus가 모두의 밖이면 가장 최근에 열거나 사용한 하나만 닫습니다.">
|
||
<IntegrationDemo />
|
||
</StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
|
||
/** 1:1 capture canvas at the Figma 1920 x 77 frame (no story page chrome); home-menu-integration.browser.mjs measures it. */
|
||
function FigmaCanvas() {
|
||
return (
|
||
<div className="sw-home-menu-story__frame" style={{ width: 1920, height: 80 }}>
|
||
{/* The integrated placement (measured by SWHeader), not a fixed x: it must land on Figma x 375. */}
|
||
<SWHeader {...headerArgs} defaultHomeMenuOpen homeMenu={{ sections: figmaHomeMenuSections, autoFocus: false }} />
|
||
</div>
|
||
)
|
||
}
|
||
export const HomeMenuCaptureDefault: Story = { name: "Home Menu · 비교 캔버스 · Default", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas /> }
|
||
|
||
export const HomeMenuHeaderComposition: Story = {
|
||
name: "Home Menu · Header 조합 · 로고 클릭",
|
||
tags: ["verification"],
|
||
render: () => (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="SWHeader homeMenu"
|
||
description="SWHeader의 homeMenu 하나로 연결합니다. 로고 클릭 = 열기/닫기(onHomeClick은 호출하지 않음), 메뉴 빈 곳 클릭 = 닫기(Figma CLOSE), 타일 클릭 = 실행(누르는 동안만 Variant3, 클릭 후 ON 없음), 최근파일만 목록이 열린 동안 Variant3. Escape는 하위 패널 → 메뉴 순으로 닫고 로고로 Focus를 돌립니다. ←/→/Home/End로 타일을 이동합니다. New / Open은 문서 Dropdown 안에 그대로 있고, Dropdown을 열면 메뉴가 닫힙니다." />
|
||
<StorySection title="현재 폭">
|
||
<HeaderWithMenu>{log => log}</HeaderWithMenu>
|
||
</StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
|
||
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[]>([])
|
||
const [count, setCount] = useState(1)
|
||
const { entries, record } = useLog()
|
||
const update = (sectionId: string, change: (items: readonly SWHomeMenuItemData[]) => SWHomeMenuItemData[]) =>
|
||
setSections(list => list.map(section => section.id === sectionId ? { ...section, items: change(section.items) } : section))
|
||
const toggleItem = (id: string, key: "hidden" | "disabled") => setSections(list => list.map(section => ({
|
||
...section, items: section.items.map(item => item.id === id ? { ...item, [key]: !item[key] } : item),
|
||
})))
|
||
const mono = <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path fill="currentColor" d="M3 3h12v12H3z M5 5v8h8V5z" /></svg>
|
||
|
||
return (
|
||
<>
|
||
<div className="sw-home-menu-story__toolbar" role="toolbar" aria-label="데이터 변경">
|
||
<button type="button" onClick={() => { update("help", items => [...items, { id: `custom-${count}`, label: `새 메뉴 ${count}`, icon: mono }]); setCount(count + 1) }}>궁금하면에 항목 추가(currentColor 아이콘)</button>
|
||
<button type="button" onClick={() => update("help", items => items.slice(0, -1))}>궁금하면 마지막 항목 삭제</button>
|
||
<button type="button" onClick={() => toggleItem("export", "hidden")}>내보내기 숨김 전환</button>
|
||
<button type="button" onClick={() => toggleItem("recover", "disabled")}>복구하기 disabled 전환</button>
|
||
<button type="button" onClick={() => update("damaged", items => items.map(item => item.id === "temp-file" ? { ...item, label: item.label === "임시파일" ? "임시파일 자동 저장 목록" : "임시파일" } : item))}>긴 라벨 전환</button>
|
||
<button type="button" onClick={() => setSections(list => list.some(section => section.id === "extra") ? list.filter(section => section.id !== "extra")
|
||
: [...list, { id: "extra", title: "새 섹션", items: [{ id: "extra-a", label: "도구 A", icon: mono }] }])}>섹션 추가/삭제</button>
|
||
<button type="button" onClick={() => setSelection(selection === "multiple" ? "single" : "multiple")}>selection: {selection}</button>
|
||
<button type="button" onClick={() => setAccent(accent === "auto" ? "sections" : accent === "sections" ? "outline" : "auto")}>accent: {accent}</button>
|
||
<button type="button" onClick={() => setActiveIds([])}>Active 모두 해제</button>
|
||
</div>
|
||
<SWTheme className="sw-home-menu-story__stage">
|
||
<SWHomeMenu defaultOpen sections={sections} selection={selection} accent={accent} closeOnBackgroundClick={false}
|
||
activeIds={activeIds} onActiveChange={next => { setActiveIds(next); record(`onActiveChange([${next.join(", ")}])`) }}
|
||
onItemSelect={event => record(describe(event))} />
|
||
</SWTheme>
|
||
<Log entries={entries} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
export const HomeMenuDynamicData: Story = {
|
||
name: "Home Menu · Props · 데이터 변경",
|
||
tags: ["verification"],
|
||
render: () => (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="데이터 기반 항목 · 선택 상태 제어"
|
||
description="항목·섹션 추가/삭제/숨김, disabled, 긴 라벨, selection, accent를 외부 데이터로 바꿉니다. 폭·구분선·모서리는 항목 수에서 자동으로 계산됩니다." />
|
||
<StorySection title="sections + activeIds (Controlled)"><DynamicDemo /></StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
|
||
function StandaloneDemo() {
|
||
const [active, setActive] = useState(false)
|
||
const { entries, record } = useLog()
|
||
const mono = <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" strokeWidth="2" /></svg>
|
||
return (
|
||
<>
|
||
<SWTheme className="sw-home-menu-story__stage">
|
||
<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} />
|
||
<SWHomeMenuItem itemId="c" label="실행" icon={mono} toggle={false} />
|
||
</SWHomeMenuSection>
|
||
</SWHomeMenu>
|
||
</SWTheme>
|
||
<SWTheme className="sw-home-menu-story__stage">
|
||
<div className="sw-home-menu-story__single">
|
||
<SWHomeMenuItem label="단독 타일" icon={mono} active={active} onActiveChange={next => { setActive(next); record(`onActiveChange(${next})`) }} />
|
||
</div>
|
||
</SWTheme>
|
||
<Log entries={entries} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
export const HomeMenuStandalone: Story = {
|
||
name: "Home Menu · 단독 사용 · 조합형",
|
||
// 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
|
||
tags: ["verification"],
|
||
render: () => (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="SWTheme 안에서 단독 사용"
|
||
description="Header 없이 <SWTheme>만으로 렌더링합니다. children(SWHomeMenuSection / SWHomeMenuItem) 조합형과 메뉴 밖 단독 SWHomeMenuItem(active / onActiveChange)을 보여 줍니다." />
|
||
<StorySection title="children 조합 · 단독 타일"><StandaloneDemo /></StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
|
||
// Property bar check (Figma 26:10492) against the current SWHeaderPropertyBar. Values are the Figma sample; the
|
||
// bar itself is unchanged (3 differences, awaiting approval: home-menu-integration report §속성Bar).
|
||
const barProperties: SWHeaderProperty[] = [
|
||
{ id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "bar" },
|
||
{ id: "diameter", name: "지름", value: "8.1052", type: "number", groupId: "bar" },
|
||
{ id: "circumference", name: "원주", value: "8.1052", type: "number", groupId: "bar" },
|
||
{ id: "area", name: "면적", value: "51.5962", type: "number", groupId: "bar" },
|
||
]
|
||
function PropertyBarCompare() {
|
||
const [values, setValues] = useState(barProperties)
|
||
return (
|
||
<SWTheme className="sw-home-menu-story__stage" style={{ display: "block" }}>
|
||
<SWHeaderPropertyBar selection={{ id: "circle", title: "원", kind: "single" }} properties={values}
|
||
onPropertyChange={(property, value) => setValues(list => list.map(item => item.id === property.id ? { ...item, value } : item))} />
|
||
</SWTheme>
|
||
)
|
||
}
|
||
export const HomeMenuPropertyBarCheck: Story = {
|
||
name: "Home Menu · 속성Bar 비교 (26:10492)",
|
||
tags: ["verification"],
|
||
render: () => (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="속성Bar — Figma 26:10492 비교"
|
||
description="현재 SWHeaderPropertyBar(수정 없음)에 Figma 예시 값을 넣었습니다. 입력에 마우스를 올리면 hover, 클릭하면 on(편집) 상태입니다." />
|
||
<StorySection title="현재 구현"><PropertyBarCompare /></StorySection>
|
||
<StorySection title="Figma 26:10492 (1222 x 28)">
|
||
<div className="sw-home-menu-story__scroll"><img className="sw-home-menu-story__figma" src={figmaPropertyBar} alt="Figma 26:10492" width={1222} height={28} /></div>
|
||
</StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
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). */
|
||
function IntegrationDemo() {
|
||
const [open, setOpen] = useState(false)
|
||
const [panelOpen, setPanelOpen] = useState(false)
|
||
const [popupOpen, setPopupOpen] = useState(false)
|
||
const { entries, record } = useLog()
|
||
const [sections] = useState(() => withRecentFiles(figmaHomeMenuSections, <SWRecentFiles files={figmaRecentFiles} onFileOpen={file => record(`onFileOpen(${file.id})`)} />))
|
||
return (
|
||
<>
|
||
<div className="sw-home-menu-story__toolbar" role="toolbar" aria-label="열기">
|
||
<button type="button" data-testid="open-popup" onClick={() => setPopupOpen(true)}>SWPopup 열기</button>
|
||
</div>
|
||
<div className="sw-home-menu-story__frame sw-home-menu-story__tall">
|
||
<SWHeader {...headerArgs} documents={documents} activeDocumentId="doc-1"
|
||
onDocumentNew={() => record("onDocumentNew")} onDocumentOpen={() => record("onDocumentOpen")}
|
||
homeMenuOpen={open} onHomeMenuOpenChange={(next, reason) => { setOpen(next); record(`onHomeMenuOpenChange(${next}, ${reason})`) }}
|
||
homeMenu={{ sections, onItemSelect: event => record(describe(event)) }}
|
||
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`onPropertyPanelOpenChange(${next})`) }}
|
||
propertyPanel={{ selection: { id: "none", title: "선택된 객체 없음", kind: "none" } }} />
|
||
{popupOpen && (
|
||
<SWTheme style={{ position: "absolute", top: 120, right: 24 }}>
|
||
<SWPopup title="사용자 Popup" onClose={() => { setPopupOpen(false); record("SWPopup onClose") }}>
|
||
<SWPopupSection title="내용"><SWPopupRow label="항목">값</SWPopupRow></SWPopupSection>
|
||
</SWPopup>
|
||
</SWTheme>
|
||
)}
|
||
</div>
|
||
<Log entries={entries} />
|
||
</>
|
||
)
|
||
}
|
||
export const HomeMenuIntegration: Story = {
|
||
name: "Home Menu · 통합 · Popup · Escape",
|
||
tags: ["verification"],
|
||
render: () => (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="로고 메뉴 + 속성창 + SWPopup"
|
||
description="Escape는 Focus가 있는 곳을 먼저 닫습니다(메뉴 안: 하위 패널 → 메뉴, Popup 안: 그 Popup). Focus가 모두의 밖이면 가장 최근에 열거나 사용한 하나만 닫습니다." />
|
||
<StorySection title="현재 폭"><IntegrationDemo /></StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
|
||
/** Responsive check: the header at the viewport width with the menu open (no story page chrome). */
|
||
export const HomeMenuCaptureResponsive: Story = { name: "Home Menu · 비교 캔버스 · 반응형", tags: ["!autodocs", "verification"], render: () => <HeaderWithMenu defaultOpen /> }
|