Files
HMDesign/src/sw/sw-header-home-menu.stories.tsx
T
shkim4 9394050c72 feat(storybook): add shared SW notice and refine SW previews
- Add StoryNotice and a withSWNotice decorator so every visible SW Story
  shows the common notice above its title; non-SW Stories are unaffected.
- Align the SW Header overview with the common SW Story page layout, let the
  preview Stage fill the available width and keep inner scroll on narrow screens.
- Rename SW/Footer to SW/하단 명령어바 (sidebar title only, Story IDs fixed via
  id: "sw-footer"), drop the top SWHeader from its preview and lower the
  workspace height bounds by the removed 49px.
2026-11-09 19:23:17 +09:00

329 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 { useLayoutEffect, useRef, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { withSWNotice } from "./sw-story-notice"
// 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 } },
decorators: [withSWNotice],
} 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} />)}
</>
)
}
/**
* Story layout only: the open sub panel is absolutely placed, so the frame takes the panel's measured bottom (+16px)
* instead of a fixed tall height. The panel wraps taller at narrow widths; closing it returns the frame to its CSS height.
*/
function FitToPanel({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const root = ref.current
if (!root) return
const fit = () => {
const frame = root.querySelector<HTMLElement>(".sw-home-menu-story__frame")
const panel = root.querySelector(".hm-sw-home-menu__more-panel")
if (!frame) return
const height = panel ? `${Math.ceil(panel.getBoundingClientRect().bottom - frame.getBoundingClientRect().top) + 16}px` : ""
if (frame.style.height !== height) frame.style.height = height
}
fit()
const resize = new ResizeObserver(fit)
resize.observe(root)
const mutation = new MutationObserver(fit)
mutation.observe(root, { childList: true, subtree: true })
return () => { resize.disconnect(); mutation.disconnect() }
}, [])
return <div ref={ref}>{children}</div>
}
/** The sidebar Story: the logo menu of SWHeader (SW normal mode) with its Figma states and sub panel (the Escape stack demo is the hidden Integration Story). */
export const HomeMenu: Story = {
name: "Home Menu",
render: () => (
<StoryPage>
<StoryPageHeader title="Home Menu — 로고(홈) 버튼 메뉴" description="SW 일반 모드 전용(뷰어 모드 미적용) · 로고 클릭으로 열고 닫기 · 상세 동작은 SW AI 가이드 COMPONENT_CATALOG" />
<StorySection title="로고 클릭 · 열기 / 닫기 (현재 폭)" description="5개 섹션 15개 타일 · 클릭 후 ON 상태 없음">
<HeaderWithMenu>{log => log}</HeaderWithMenu>
</StorySection>
<StorySection title="열림 (현재 폭)" description="로고 Home On · Header 상·하단 2px 선">
<HeaderWithMenu defaultOpen autoFocus={false} />
</StorySection>
{/* No section heading: the open recent-files panel follows the "열림" section directly. */}
<section className="sb-page-section" aria-label="최근파일 하위 패널">
<FitToPanel><HeaderWithMenu defaultOpen autoFocus={false} defaultActiveIds={["recent"]}>{log => log}</HeaderWithMenu></FitToPanel>
</section>
</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 /> }