- 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.
329 lines
19 KiB
TypeScript
329 lines
19 KiB
TypeScript
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 /> }
|