Files
HMDesign/src/sw/sw-header-home-menu.stories.tsx
T
shkim4 2fc1b3e108 fix(sw): 2026-10-21 SW design review and responsive header without More from 760px
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.
2026-10-21 17:29:25 +09:00

304 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 /> }