Files
HMDesign/src/sw/sw-header.stories.tsx
T
shkim4andClaude Opus 5.5 5faa1f1ffe refactor(storybook): remove unused SW header demo stories
Remove nine SW/Header demo pages (property-fields, property-shape-slot,
composition, colorize, dynamic-controls, property-bar, additional-hover,
property-panel, layer-detail-input) and the demo code and Storybook-only
CSS used only by them. SW Stories 19 -> 10, total 338 -> 329.

The package runtime, public API, AI template and the retained Stories
(Overview, Dropdown, Property Panel 4-state comparison, Window Move
Motion and others) are unchanged. Add a current regression script and
mark the older scripts that open the retired IDs as historical evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:10:34 +09:00

953 lines
66 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 { useEffect, useId, useRef, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
SWHeader, SWHeaderColorMenu, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWHeaderDimensionStyleMenu,
SWHeaderProperties, SWHeaderWindowControls, SWTheme, SWHeaderLayerMenu, parseSWHeaderColor, swHeaderLinePatterns, SWHeaderPropertyPanel, SWHeaderPropertyBar,
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,
} 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 placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
// Owner: docs/sw/sw-header-design-spec.md (Figma rj6wpsfk24lOWvWiVRAANP, node 26:8944 "header_green").
// The package is independent of @hm-design/ui: this file imports only @hm-design/sw for the specimen.
const KICKER = "HM DESIGN SYSTEM / SW"
/**
* Figma sample values of node 26:8944. The logo is a TEMPORARY placeholder: the logo slot is
* common design, the logo image belongs to each SW program (docs/sw/sw-header-design-spec.md §9).
*/
const figmaArgs: SWHeaderProps = {
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" },
// 2026-10-16: the default example opens a document list with the one current document (host data).
documents: [{ id: "doc-current", name: "C0020101-006-평면및종단면도(06)" }],
activeDocumentId: "doc-current",
activeMenu: null,
propertyBarOpen: false,
propertyPanelOpen: false,
disabled: false,
}
const callbacks = {
onHomeClick: () => console.info("[SWHeader] home"),
onDocumentClick: () => console.info("[SWHeader] document"),
onDocumentSelect: (document: SWHeaderDocument) => console.info("[SWHeader] documentSelect", document.id),
onDocumentClose: (document: SWHeaderDocument) => console.info("[SWHeader] documentClose", document.id),
onDocumentNew: () => console.info("[SWHeader] documentNew"),
onDocumentOpen: () => console.info("[SWHeader] documentOpen"),
onLayerOpen: () => console.info("[SWHeader] layer"),
onColorOpen: () => console.info("[SWHeader] color"),
onLineStyleOpen: () => console.info("[SWHeader] lineStyle"),
onTextStyleOpen: () => console.info("[SWHeader] textStyle"),
onDimensionStyleOpen: () => console.info("[SWHeader] dimensionStyle"),
onPropertyBarToggle: () => console.info("[SWHeader] propertyBar"),
onPropertyPanelToggle: () => console.info("[SWHeader] propertyPanel"),
onPrint: () => console.info("[SWHeader] print"),
onPopupGuide: () => console.info("[SWHeader] popupGuide"),
onMinimize: () => console.info("[SWHeader] minimize"),
onClose: () => console.info("[SWHeader] close"),
}
const meta = {
title: "SW/Header",
excludeStories: ["ComposedDemo"],
component: SWHeader,
parameters: { layout: "fullscreen" },
args: { ...figmaArgs, ...callbacks },
argTypes: {
activeMenu: { control: "select", options: [null, "layer", "color", "lineStyle", "textStyle", "dimensionStyle"] },
tools: { control: false },
logo: { control: false },
logoSrc: { control: "text" },
logoAlt: { control: "text" },
},
} satisfies Meta<typeof SWHeader>
export default meta
type Story = StoryObj<typeof meta>
function DetailButtonsComparison() {
const labels = ["레이어 상세", "색상 상세", "선스타일 상세", "문자스타일 상세", "치수스타일 상세"]
const [counts, setCounts] = useState<Record<string, number>>({})
return <SWTheme><div className="sw-story-detail-comparison" data-detail-comparison="">
<div className="sw-story-detail-row"><strong>버튼</strong><strong>Default · 클릭 기록</strong><strong>Pointer Hover</strong><strong>Disabled</strong><strong>Callback 없음</strong></div>
{labels.map(label => <div className="sw-story-detail-row" data-detail-row={label} key={label}>
<strong>{label}</strong>
{["default", "hover", "disabled", "no-callback"].map(state => <div key={state} data-detail-case={state}>
<SWHeaderMenuDetail label={label} disabled={state === "disabled"}
onClick={state === "no-callback" ? undefined : () => setCounts(values => ({ ...values, [label]: (values[label] ?? 0) + 1 }))} />
{state === "default" && <output aria-label={`${label} Callback 횟수`}>{counts[label] ?? 0}</output>}
</div>)}
</div>)}
</div></SWTheme>
}
export const DetailButtons: Story = {
name: "Detail Buttons (Shared States)",
tags: ["verification"],
parameters: { controls: { disable: true } },
render: () => <StoryPage><StoryPageHeader kicker={KICKER} title="상세 버튼 공통 상태 비교"
description="문자스타일 상세(26:10789 / 26:10792) 기준. Pointer Hover 열에 실제 포인터를 올려 비교합니다. Mouse Leave는 Default로 복귀합니다. Disabled는 Callback을 차단하고, Callback 없는 일반 버튼도 Hover되며 클릭은 안전한 무동작입니다. 팝업/CAD 기능은 구현하지 않습니다." />
<DetailButtonsComparison />
</StoryPage>,
}
/** Fixed-width frame. The outer scroller belongs to Storybook, never to the header. */
function Frame({ width, children }: { width: number; children: ReactNode }) {
return (
<div className="sw-story-scroller">
<div className="sw-story-frame" style={{ width }} data-frame-width={width}>{children}</div>
</div>
)
}
/** Hover targets (docs/sw/sw-header-design-spec.md §11). */
const HOVER_TARGETS = [
".hm-sw-header__home", ".hm-sw-header__document-button", ".hm-sw-header__field",
".hm-sw-header__toggle", ".hm-sw-header__action", ".hm-sw-header__window-button",
]
/** 인쇄 · 팝업 가이드 · 최소화 · 종료 (§11.2). */
const WINDOW_HOVER_TARGETS = [".hm-sw-header__action", ".hm-sw-header__window-button"]
/**
* Pins :hover on the given header parts with the Storybook-only data-sb-hover attribute
* (.storybook/pseudo-states.ts mirrors every :hover rule), so Hover can be compared statically.
*/
function PinHover({ selectors, children }: { selectors: string[]; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
ref.current?.querySelectorAll(selectors.join(",")).forEach(element => element.setAttribute("data-sb-hover", ""))
}, [selectors])
return <div ref={ref}>{children}</div>
}
/** Visible callback log for print / popup guide / minimize / close (each click calls only its own callback). */
function WindowCallbackDemo() {
const [log, setLog] = useState<string[]>([])
const add = (name: string, callback: () => void) => () => { callback(); setLog(lines => [...lines.slice(-7), name]) }
return (
<>
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
<Frame width={1920}>
<SWHeader
{...figmaArgs}
{...callbacks}
onPrint={add("onPrint", callbacks.onPrint)}
onPopupGuide={add("onPopupGuide", callbacks.onPopupGuide)}
onMinimize={add("onMinimize", callbacks.onMinimize)}
onClose={add("onClose", callbacks.onClose)}
/>
</Frame>
</>
)
}
/** Host-owned state: the header itself never keeps program state. */
function InteractiveHeader(props: Partial<SWHeaderProps>) {
const [activeMenu, setActiveMenu] = useState<SWHeaderMenuId | null>(null)
const [bar, setBar] = useState(false)
const [panel, setPanel] = useState(true)
const toggle = (id: SWHeaderMenuId) => () => setActiveMenu(current => (current === id ? null : id))
return (
<SWHeader
{...figmaArgs}
{...callbacks}
activeMenu={activeMenu}
propertyBarOpen={bar}
propertyPanelOpen={panel}
onLayerOpen={toggle("layer")}
onColorOpen={toggle("color")}
onLineStyleOpen={toggle("lineStyle")}
onTextStyleOpen={toggle("textStyle")}
onDimensionStyleOpen={toggle("dimensionStyle")}
onPropertyBarToggle={() => setBar(value => !value)}
onPropertyPanelToggle={() => setPanel(value => !value)}
{...props}
/>
)
}
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} />,
}
export const InteractiveStates: Story = {
name: "Interactive States",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="Interactive States" description="Hover는 Figma 홈 버튼(26:10655)의 노란 Gradient 1px 테두리를 테두리가 있는 모든 SW Header 버튼에 같은 방식으로 적용합니다. 아래 Hover 행은 Storybook 전용 data-sb-hover로 고정한 화면입니다." />
<StorySection title="Host state 연결 · Keyboard Focus" description="실제로 마우스를 올려 Hover를, 메뉴·속성 토글을 눌러 선택 상태를 확인합니다. Tab 키로 Focus 윤곽선(2px #F0F7F5)을 확인합니다. Focus 윤곽선은 Hover 테두리보다 위에 그려집니다.">
<Frame width={1920}><InteractiveHeader /></Frame>
</StorySection>
<StorySection title="Default" description="Hover 전. 각 버튼의 기존 테두리·배경·그림자.">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} /></Frame>
</StorySection>
<StorySection title="Hover (고정)" description="홈 · 5개 속성 필드 · 인쇄 · 팝업 가이드는 크기·배경·그림자·위치 변화 없이 테두리만 노란 gradient로 바뀝니다. 인쇄 · 팝업 가이드는 아이콘의 #B6D0C9 부분만 #FFC600으로 함께 바뀝니다. 파일명과 속성Bar · 속성창은 테두리 없이 글자만 #FFC600으로 바뀝니다. 최소화는 검정 20% 배경 + 흰 아이콘, 종료는 #E40000 배경 + 흰 X입니다(Figma 26:12156 / 26:12161). 그리기모드(텍스트)·창 이동은 대상이 아닙니다.">
<Frame width={1920}><PinHover selectors={HOVER_TARGETS}><SWHeader {...figmaArgs} {...callbacks} /></PinHover></Frame>
</StorySection>
<StorySection title="Selected" description="레이어 메뉴·속성창 선택. 글자만 #FFC600이고 선택 전용 테두리는 없습니다.">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} activeMenu="layer" propertyPanelOpen /></Frame>
</StorySection>
<StorySection title="Selected + Hover (고정)" description="레이어 메뉴는 선택 글자색(#FFC600)을 유지하고 Hover 테두리를 더합니다. 속성창은 선택·Hover 모두 글자색만이라 노란 테두리가 없습니다.">
<Frame width={1920}><PinHover selectors={[".hm-sw-header__field--layer", ".hm-sw-header__toggle:last-of-type"]}><SWHeader {...figmaArgs} {...callbacks} activeMenu="layer" propertyPanelOpen /></PinHover></Frame>
</StorySection>
<StorySection title="속성Bar · 속성창 상태" description="두 토글은 Hover 시 글자만 #FFC600이고 노란 테두리·배경·그림자가 없습니다. 선택(propertyBarOpen / propertyPanelOpen)도 글자 #FFC600이며, 선택된 토글에 Hover해도 테두리가 생기지 않습니다. '상세보기' 안내 글자는 바뀌지 않습니다. 키보드 Focus 윤곽선은 위 Host state 예제에서 Tab으로 확인합니다.">
<div className="sw-story-stack">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} data-toggle-state="default" /></Frame>
<Frame width={1920}><PinHover selectors={[".hm-sw-header__toggle"]}><SWHeader {...figmaArgs} {...callbacks} data-toggle-state="hover" /></PinHover></Frame>
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} propertyBarOpen propertyPanelOpen data-toggle-state="selected" /></Frame>
<Frame width={1920}><PinHover selectors={[".hm-sw-header__toggle"]}><SWHeader {...figmaArgs} {...callbacks} propertyBarOpen propertyPanelOpen data-toggle-state="selected-hover" /></PinHover></Frame>
</div>
</StorySection>
<StorySection title="Disabled + Hover (고정)" description="비활성 버튼에는 Hover 테두리와 아이콘 색 변화를 표시하지 않습니다. 홈·파일명·최소화·종료는 disabled 대상이 아니므로 Hover가 표시됩니다.">
<Frame width={1920}><PinHover selectors={HOVER_TARGETS}><SWHeader {...figmaArgs} {...callbacks} disabled /></PinHover></Frame>
</StorySection>
<StorySection title="인쇄 · 팝업 가이드 · 최소화 · 종료" description="Default(위)와 Hover 고정(아래). 인쇄 · 팝업 가이드: 기존 노란 Gradient 테두리 + 아이콘의 #B6D0C9 부분만 #FFC600(검정 외곽선·그림자·팝업 가이드 녹색 타일은 그대로). 최소화: 투명 → 검정 20% 배경, 아이콘 #ADB0AF → #FFFFFF. 종료: 투명 → #E40000 배경, X #ADB0AF → #FFFFFF. 최소화·종료는 32 × 48 그대로이며 테두리·그림자·노란 테두리가 없습니다.">
<div className="sw-story-stack">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} data-window-state="default" /></Frame>
<Frame width={1920}><PinHover selectors={WINDOW_HOVER_TARGETS}><SWHeader {...figmaArgs} {...callbacks} data-window-state="hover" /></PinHover></Frame>
</div>
</StorySection>
<StorySection title="인쇄 · 팝업 가이드 · 최소화 · 종료 Callback 기록" description="각 버튼은 자기 Callback만 한 번 호출합니다. 종료는 onClose만 호출하며 Storybook에서 실제 창을 닫지 않습니다(창 동작은 호스트 소유). Tab으로 Focus 윤곽선(2px #F0F7F5)을 확인합니다.">
<WindowCallbackDemo />
</StorySection>
<StorySection title="더보기 버튼 Hover (고정, 헤더 1440px)" description="더보기 버튼에도 같은 테두리를 적용합니다. 더보기 메뉴 안 항목은 목록 구조이므로 기존 배경 Hover를 유지합니다.">
<Frame width={1440}><PinHover selectors={[".hm-sw-header__more-trigger"]}><SWHeader {...figmaArgs} {...callbacks} /></PinHover></Frame>
</StorySection>
<StorySection title="Disabled" description="메뉴·속성·인쇄/가이드가 비활성화됩니다. 창 제어(최소화·종료)는 유지됩니다.">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} disabled /></Frame>
</StorySection>
<StorySection title="긴 파일명" description="파일명은 말줄임으로 처리하고 글자 크기를 줄이지 않습니다.">
<Frame width={1440}><SWHeader {...figmaArgs} {...callbacks} documentTitle="C0020101-006-평면및종단면도(06)-수정본-최종-검토용-2026-10-16.dwg" /></Frame>
</StorySection>
</StoryPage>
),
}
/** Preserve GIF phase ratios/positions, with the user-requested 1640 -> 1400ms speed change. */
const motionTime = (gifTime: number) => gifTime * 1400 / 1640
const MOTION_STAGES = [
{ time: undefined, title: "Default (Hover 전)", note: "Figma default 26:10221. 아이콘 32 × 30 (28, 13)." },
{ time: 0, title: "Hover 시작 · 0ms", note: "기본 아이콘과 같은 아래쪽 커서에서 출발. 창은 제자리, 문구는 아직 숨김." },
{ time: motionTime(240), title: "커서 상승 · 약 205ms", note: "GIF 6번 프레임과 같은 위치. 커서만 오른쪽 아래에서 위로 이동 중." },
{ time: motionTime(480), title: "상승 완료 직전 · 약 410ms", note: "창은 제자리이며 문구는 아직 숨김. 커서는 약 12.25px 상승." },
{ time: motionTime(520), title: "문구 등장 · 약 444ms", note: "GIF 13번 프레임과 같은 위치. 창 오른쪽에 창 이동 문구 표시." },
{ time: motionTime(800), title: "오른쪽 위로 이동 · 약 683ms", note: "창은 시작점 대비 +15.5 / −2.5px. 커서와 문구도 함께 이동." },
{ time: motionTime(1040), title: "이동 완료 · 약 888ms", note: "창 +28 / −4.5px. 이 자세로 약 512ms 유지하며 overshoot 없음." },
{ time: motionTime(1600), title: "끝 자세 유지 · 약 1366ms", note: "GIF 마지막 프레임과 같은 위치. 창·커서·문구를 유지." },
{ time: motionTime(1640), title: "다음 사이클 · 1400ms", note: "1.4초마다 처음 자세와 아래쪽 커서로 재시작. Hover 이탈 시 기본 SVG로 즉시 복귀." },
]
/**
* Freezes the hover motion at `time` ms for comparison: pins :hover with the Storybook-only
* data-sb-hover attribute, then pauses the CSS animations there (Web Animations API).
* Without `time` the area stays at rest. Nothing here styles the header.
*/
function MotionAt({ time, children }: { time?: number; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const area = ref.current?.querySelector(".hm-sw-header__motion")
if (!area || time === undefined) return
area.setAttribute("data-sb-hover", "")
// getAnimations() flushes style, so the hover animations exist right away.
area.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = time })
}, [time])
return <div ref={ref}>{children}</div>
}
/** Window controls alone (public sub-component), magnified 3x for the Figma comparison. */
function MotionSpecimen({ time }: { time?: number }) {
return (
<div className="sw-story-zoom">
<MotionAt time={time}><SWTheme className="sw-story-window"><SWHeaderWindowControls /></SWTheme></MotionAt>
</div>
)
}
export const WindowMoveMotion: Story = {
name: "Window Move Motion",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="Window Move Motion" />
<StorySection title="단계별 진행 (3배 확대)" description="CSS 애니메이션을 해당 시점에 멈춘 화면입니다(Storybook 전용 data-sb-hover + Web Animations API로 시간만 고정).">
<div className="sw-story-stages">
{MOTION_STAGES.map(stage => (
<figure key={stage.title} className="sw-story-stage" data-motion-time={stage.time ?? "rest"}>
<MotionSpecimen time={stage.time} />
<figcaption><strong>{stage.title}</strong> {stage.note}</figcaption>
</figure>
))}
</div>
</StorySection>
</StoryPage>
),
}
// ComposedDemo is exported for the Dropdown CSF file; exclude it from this file's Story index.
export function ComposedDemo() {
const panelId = useId()
const detailRef = useRef<HTMLDivElement>(null)
const [activeMenu, setActiveMenu] = useState<SWHeaderMenuId | null>(null)
const [scenario, setScenario] = useState("normal")
const [disabled, setDisabled] = useState(false)
const [layerReadOnly, setLayerReadOnly] = useState(false)
const [detailCallbacks, setDetailCallbacks] = useState(true)
const [layers, setLayers] = useState(SAMPLE_LAYERS)
const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS)
const [weightId, setWeightId] = useState("w-default")
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
const [textStyle, setTextStyle] = useState(figmaArgs.textStyle!)
const [dimensionStyle, setDimensionStyle] = useState(figmaArgs.dimensionStyle!)
const [properties, setProperties] = useState(() => propertyFixture("circle").map(item => item.id === "layer" ? { ...item, value: "0" } : item))
const [bar, setBar] = useState(false)
const [panel, setPanel] = useState(false)
const [log, setLog] = useState<string[]>([])
// The overlay positions itself on mount; focus after that initial hidden layout is resolved.
useEffect(() => {
if (!panel) return
const frame = requestAnimationFrame(() => document.getElementById(panelId)?.focus())
return () => cancelAnimationFrame(frame)
}, [panel, panelId])
const record = (line: string) => { setLog(lines => [...lines.slice(-11), line]); console.info(`[SWHeader Composed] ${line}`) }
const value = (id: string) => properties.find(item => item.id === id)?.value ?? ""
const change = (id: string, next: string, event: string) => {
setProperties(items => items.map(item => item.id === id ? { ...item, value: next, mixed: false } : item))
if (id === "color") setColorMode(null)
record(`${event}(${id}, ${next})`)
}
const availableLayers = scenario === "empty" ? [] : scenario === "long" ? LONG_LAYERS : scenario === "many" ? MANY_LAYERS : layers
const currentLayer = availableLayers.find(item => item.name === value("layer"))
const currentWeight = weights.find(item => item.id === weightId)
const color = parseSWHeaderColor(value("color"))
const menuProps = (id: SWHeaderMenuId) => ({
open: activeMenu === id, disabled,
onClick: () => record(`on${id[0].toUpperCase() + id.slice(1)}Open`),
onOpenChange: (open: boolean) => {
setActiveMenu(current => open ? id : current === id ? null : current)
record(`${id}Open(${open})`)
},
})
const detail = (name: string) => detailCallbacks ? () => record(`on${name}Detail`) : undefined
const toggleLayer = (key: "visible" | "frozen" | "locked", event: string) => layerReadOnly ? undefined : (layer: SWHeaderLayer, next: boolean) => {
setLayers(items => items.map(item => item.id === layer.id || item.name === layer.id ? { ...item, [key]: next } : item))
record(`${event}(${layer.id}, ${next})`)
}
const chooseWeight = (weight: SWHeaderLineWeight) => {
setWeightId(weight.id)
if (weight.value !== undefined) change("weight", weight.value, "onLineWeightSelect")
else record(`onLineWeightSelect(${weight.id})`)
}
const changeWeight = (weight: SWHeaderLineWeight, next: string) => {
setWeights(items => items.map(item => item.id === weight.id ? { ...item, value: next } : item))
if (weightId === weight.id) change("weight", next, "onLineWeightValueChange")
else record(`onLineWeightValueChange(${weight.id}, ${next})`)
}
const closePanel = () => {
setPanel(false); record("onPropertyPanelOpenChange(false)")
detailRef.current?.querySelector<HTMLButtonElement>("[data-property-panel-trigger]")?.focus()
}
const menus: SWHeaderPropertyMenus = {
layers: availableLayers, lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: value("scale"),
colors: FIGMA_COLORS, userColors: scenario === "many" ? MANY_USER_COLORS : FIGMA_USER_COLORS, colorMode,
onLayerVisibilityChange: toggleLayer("visible", "onLayerVisibilityChange"), onLayerFreezeChange: toggleLayer("frozen", "onLayerFreezeChange"), onLayerLockChange: toggleLayer("locked", "onLayerLockChange"),
onLayerDetail: detail("Layer"), onLineStyleDetail: detail("LineStyle"), onColorDetail: detail("Color"),
onLineWeightSelect: chooseWeight, onLineWeightValueChange: changeWeight, onLineScaleChange: next => change("scale", next, "onLineScaleChange"),
onColorModeChange: mode => { setColorMode(mode); record(`onColorModeChange(${mode})`) }, onColorPick: () => record("onColorPick"),
}
const panelProperties = properties.map(item => ({ ...item, disabled, ...(item.id === "layer" ? { options: availableLayers.map(layer => ({ ...layer, id: layer.name })) } : {}) }))
return <StoryPage className="sw-composed-controls">
<StoryPageHeader kicker={KICKER} title="Dropdown" description="SW Header의 레이어·색상·선스타일·문자스타일·치수스타일과 상세보기를 직접 조작합니다. 메뉴의 상세 버튼은 기존 호스트 Callback을 호출하며 CAD 상세 화면은 생성하지 않습니다." />
<details className="sw-composed-controls__settings">
<summary>시연 설정</summary>
<div className="sw-composed-controls__settings-content">
<label>목록 데이터 <select value={scenario} onChange={event => { setActiveMenu(null); setScenario(event.target.value) }}>
<option value="normal">기본</option><option value="long">긴 레이어명</option><option value="many">레이어 20개 · 사용자 색상 40개</option><option value="empty">빈 레이어 목록</option>
</select></label>
<label><input type="checkbox" checked={disabled} onChange={event => { setActiveMenu(null); setDisabled(event.target.checked) }} />컨트롤 Disabled</label>
<label><input type="checkbox" checked={layerReadOnly} onChange={event => setLayerReadOnly(event.target.checked)} />레이어 상태 표시 전용</label>
<label><input type="checkbox" checked={detailCallbacks} onChange={event => setDetailCallbacks(event.target.checked)} />메뉴 상세 Callback 연결</label>
</div>
</details>
<StorySection title="Composed Controls">
<SWTheme>
<div className="sw-story-composed" role="group" aria-label="Composed Controls" data-composed-controls="">
<SWHeaderLayerMenu {...menuProps("layer")} value={{ name: value("layer") }} layers={availableLayers} activeLayerId={currentLayer?.id}
onLayerSelect={layer => change("layer", layer.name, "onLayerSelect")} onDetail={detail("Layer")}
onVisibilityChange={scenario === "normal" ? menus.onLayerVisibilityChange : undefined} onFreezeChange={scenario === "normal" ? menus.onLayerFreezeChange : undefined} onLockChange={scenario === "normal" ? menus.onLayerLockChange : undefined} />
<SWHeaderColorMenu {...menuProps("color")} value={{ value: colorMode === "byLayer" ? "ByLayer" : colorMode === "byBlock" ? "ByBlock" : value("color"), swatch: color?.hex }}
colors={FIGMA_COLORS} userColors={menus.userColors} mode={colorMode}
onColorSelect={item => change("color", item.value ?? parseSWHeaderColor(item.color)?.value ?? item.color, "onColorSelect")}
onColorInput={next => change("color", next.value, "onColorInput")} onModeChange={mode => { setColorMode(mode); record(`onColorModeChange(${mode})`) }}
onPick={() => record("onColorPick")} onDetail={detail("Color")} />
<SWHeaderLineStyleMenu {...menuProps("lineStyle")} value={{ name: value("line"), weight: currentWeight?.value === undefined ? currentWeight?.name ?? value("weight") : value("weight"), scale: value("scale") }}
lineTypes={FIGMA_LINE_TYPES} lineWeights={weights} activeLineWeightId={weightId}
onLineTypeSelect={item => change("line", item.name, "onLineTypeSelect")} onLineWeightSelect={chooseWeight} onLineWeightValueChange={changeWeight}
onScaleChange={next => change("scale", next, "onLineScaleChange")} onDetail={detail("LineStyle")} />
<SWHeaderTextStyleMenu {...menuProps("textStyle")} value={textStyle} textStyles={FIGMA_TEXT_STYLES}
onTextStyleSelect={item => { setTextStyle({ font: item.font, name: item.name }); record(`onTextStyleSelect(${item.id})`) }} onDetail={detail("TextStyle")} />
<SWHeaderDimensionStyleMenu {...menuProps("dimensionStyle")} value={dimensionStyle} dimensionStyles={FIGMA_DIMENSION_STYLES}
onDimensionStyleSelect={item => { setDimensionStyle({ name: item.name }); record(`onDimensionStyleSelect(${item.id})`) }} onDetail={detail("DimensionStyle")} />
<div ref={detailRef}>
<SWHeaderProperties disabled={disabled} barOpen={bar} panelOpen={panel} panelId={panelId}
onBarToggle={() => { setActiveMenu(null); setBar(next => !next); record(`onPropertyBarOpenChange(${!bar})`) }}
onPanelToggle={() => { setActiveMenu(null); setPanel(next => !next); record(`onPropertyPanelOpenChange(${!panel})`) }} />
</div>
</div>
{bar && <SWHeaderPropertyBar selection={propertySelection("circle")} properties={panelProperties.filter(item => BAR_FIXTURE.some(barItem => barItem.id === item.id))}
onPropertyChange={(property, next) => change(property.id, next, "onPropertyChange")} />}
{panel && <SWHeaderPropertyPanel id={panelId} anchorRef={detailRef} selection={propertySelection("circle")} properties={panelProperties} groups={PROPERTY_GROUPS}
menus={{ ...menus, layers: availableLayers.map(layer => ({ ...layer, id: layer.name })) }}
onPropertyChange={(property, next) => change(property.id, next, "onPropertyChange")} onClose={closePanel} />}
</SWTheme>
</StorySection>
<div className="sw-composed-controls__status">
<output data-style-state="">레이어: {value("layer")} · 색상: {colorMode ?? value("color")} · 선: {value("line")} / {value("weight")} / {value("scale")} · 문자: {textStyle.name} · 치수: {dimensionStyle.name}</output>
<output aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</output>
</div>
</StoryPage>
}
/** Neutral sample logos with different ratios (no product or brand). */
const WIDE_SAMPLE_LOGO = "data:image/svg+xml," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" viewBox="0 0 120 20"><rect width="120" height="20" rx="4" fill="#47AF91"/><rect x="8" y="7" width="104" height="6" rx="3" fill="#F0F7F5"/></svg>',
)
const TALL_SAMPLE_LOGO = (
<svg viewBox="0 0 20 40" aria-hidden="true" focusable="false">
<rect width="20" height="40" rx="4" fill="#FFC600" />
<circle cx="10" cy="12" r="5" fill="#060606" />
</svg>
)
export const LogoSlot: Story = {
name: "Logo Slot",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Logo Slot"
description="로고 영역의 위치·크기(홈 버튼 안 12 / 16, 32 × 17)는 공통 디자인이고, 로고 이미지는 각 SW 프로그램이 정합니다. Storybook 기본 로고는 임시 자산(sw-header-logo-placeholder.svg)이며 확정 로고가 아닙니다."
/>
<StorySection title="기본 임시 로고" description="logoSrc에 임시 SVG, logoAlt에 「임시 로고」를 전달합니다.">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} /></Frame>
</StorySection>
<StorySection title="사용자 지정 로고 — 가로형 이미지(logoSrc)" description="120 × 20 비율의 이미지도 로고 영역 안에 contain으로 맞춰지고 넘치지 않습니다.">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} logoSrc={WIDE_SAMPLE_LOGO} logoAlt="샘플 SW" /></Frame>
</StorySection>
<StorySection title="사용자 지정 로고 — 세로형 React 요소(logo)" description="logo에 SVG 요소를 전달하면 logoSrc보다 우선합니다.">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} logo={TALL_SAMPLE_LOGO} logoAlt="샘플 SW" /></Frame>
</StorySection>
<StorySection title="로고 미전달" description="logo·logoSrc가 없으면 로고를 그리지 않고 홈 버튼 배경과 전체 배치를 그대로 유지합니다. 접근성 이름은 「홈」입니다.">
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} logoSrc={undefined} logoAlt={undefined} /></Frame>
</StorySection>
</StoryPage>
),
}
/** Test data only. The package never ships document names; each SW program passes its own list. */
const SAMPLE_DOCUMENTS: SWHeaderDocument[] = [
{ id: "doc-1", name: "Sample-Drawing-01.dwg" },
{ id: "doc-2", name: "Sample-Drawing-02.dwg" },
{ id: "doc-3", name: "Sample-Plan-03.dxf" },
{ id: "doc-4", name: "Sample-Section-04.dxf" },
]
/** The one document the default example has open (same name as the Figma file name). */
const CURRENT_DOCUMENT: SWHeaderDocument[] = [{ id: "doc-current", name: "C0020101-006-평면및종단면도(06)" }]
const MANY_DOCUMENTS: SWHeaderDocument[] =Array.from({ length: 18 }, (_, index) => ({
id: `many-${index + 1}`,
name: `Sample-Drawing-${String(index + 1).padStart(2, "0")}.dwg`,
}))
const LONG_DOCUMENTS: SWHeaderDocument[] = [
{ id: "long-1", name: "C0020101-006-평면및종단면도(06)-수정본-최종-검토용-2026-10-16.dwg" },
{ id: "long-2", name: "Sample-매우-긴-문서명-테스트-Ellipsis-확인용-파일-이름.dxf" },
]
const documentCallbacks = {
onDocumentSelect: (document: SWHeaderDocument) => console.info("[SWHeader] documentSelect", document.id),
onDocumentNew: () => console.info("[SWHeader] documentNew"),
onDocumentOpen: () => console.info("[SWHeader] documentOpen"),
}
/** Frame with room below the header so the overlay dropdown is not clipped by the story scroller. */
function MenuFrame({ width, children, fixedDimension = false }: { width: number; children: ReactNode; fixedDimension?: boolean }) {
return (
<div className={`sw-story-scroller sw-story-scroller--menu${fixedDimension ? " sw-story-fixed-dimension" : ""}`}>
<div className="sw-story-frame" style={{ width }} data-frame-width={width}>{children}</div>
</div>
)
}
type DocumentHostProps = Partial<SWHeaderProps> & { onLog?: (line: string) => void }
/**
* Host-owned documents: the SW program keeps the list and the current document. Selecting makes
* a document current; Close Document removes that document and, if it was current, leaves no
* current document (this example's choice).
* The header itself never reorders the data or picks the next document.
*/
function DocumentHeader({ documents: initialDocuments = SAMPLE_DOCUMENTS, activeDocumentId: initialActiveId, onLog, ...props }: DocumentHostProps) {
const [documents, setDocuments] = useState(initialDocuments)
const [activeId, setActiveId] = useState(initialActiveId)
const active = documents.find(item => item.id === activeId)
const title = active?.name ?? (initialActiveId === undefined && activeId === undefined ? figmaArgs.documentTitle : "열린 문서 없음")
return (
<SWHeader
{...figmaArgs}
{...callbacks}
documentTitle={title}
documents={documents}
activeDocumentId={activeId}
onDocumentSelect={document => {
documentCallbacks.onDocumentSelect(document)
onLog?.(`onDocumentSelect(${document.id})`)
setActiveId(document.id)
}}
onDocumentClose={document => {
console.info("[SWHeader] documentClose", document.id)
onLog?.(`onDocumentClose(${document.id})`)
setDocuments(list => list.filter(item => item.id !== document.id))
// Closing the current document leaves none selected; closing another keeps the selection.
if (document.id === activeId) setActiveId(undefined)
}}
onDocumentNew={() => { documentCallbacks.onDocumentNew(); onLog?.("onDocumentNew") }}
onDocumentOpen={() => { documentCallbacks.onDocumentOpen(); onLog?.("onDocumentOpen") }}
{...props}
/>
)
}
/** Interactive demo with a visible callback log, to check that select and close never double-fire. */
function DocumentDemo({ width, ...props }: DocumentHostProps & { width: number }) {
const [log, setLog] = useState<string[]>([])
return (
<>
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
<MenuFrame width={width}><DocumentHeader {...props} onLog={line => setLog(lines => [...lines.slice(-7), line])} /></MenuFrame>
</>
)
}
export const DocumentDropdown: Story = {
name: "Document Dropdown",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Document Dropdown"
description="파일명 영역을 클릭하면 Figma header/document_tit(26:10662)의 문서 목록이 헤더 아래로 겹쳐 열립니다. Hover만으로는 열리지 않습니다. 문서 목록·아이콘·New/Open 동작은 각 SW 프로그램이 Props로 전달하며, 아래 문서명은 테스트용 예시입니다."
/>
<StorySection title="현재 열린 문서 1개 (기본 예제)" description="2026-10-16 추가 요구: 기본 예제는 빈 목록 대신 현재 열린 문서 1개를 전달합니다(documents = [현재 문서], activeDocumentId). 펼치면 그 문서명이 #FFC600(현재 문서)으로 보이고 X·+ New·Open은 기존 그대로입니다. 문서를 늘리면 실제 개수만큼 표시되며 배열을 1개로 고정하지 않습니다.">
<MenuFrame width={1920}><DocumentHeader documents={CURRENT_DOCUMENT} activeDocumentId="doc-current" documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="현재 열린 문서 1개 · 선택 · X · New / Open Callback" description="파일명을 눌러 열고 확인합니다. 문서 선택은 onDocumentSelect, X는 onDocumentClose(이 예제의 호스트는 목록에서 제거 → '열린 문서 없음'), + New / Open은 각 Callback만 한 번 호출합니다. 존재하지 않는 문서를 임의로 만들지 않습니다.">
<DocumentDemo width={1920} documents={CURRENT_DOCUMENT} activeDocumentId="doc-current" />
</StorySection>
<StorySection title="Default" description="documents를 전달하면 파일명이 문서 목록 버튼이 됩니다(aria-expanded). 화살표는 아래 방향입니다.">
<Frame width={1920}><DocumentHeader /></Frame>
</StorySection>
<StorySection title="Hover (고정)" description="파일명 Hover는 노란 테두리 없이 글자만 #FFC600으로 바꿉니다. 녹색 테두리·배경·초록 화살표는 그대로이며 목록은 열리지 않습니다.">
<Frame width={1920}><PinHover selectors={[".hm-sw-header__document-button"]}><DocumentHeader /></PinHover></Frame>
</StorySection>
<StorySection title="Dropdown Open (Figma 4개 문서)" description="열린 상태: 파일명과 화살표 #FFC600, 화살표 위쪽. 목록 310 × 168(항목 32 × 4 + 하단 40), 배경 #103127, 1px #041611, 하단 radius 4px.">
<MenuFrame width={1920}><DocumentHeader documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="목록 항목 · + New · Open Hover (고정)" description="Figma hover: 목록 항목은 검정 40% 배경(글자·아이콘 그대로, 26:10680), + New / Open은 배경 20% → 30%, 글자 검정 70% → 100%(26:10762 / 26:10805). 노란 테두리는 쓰지 않습니다. 위 Default와 비교합니다(두 번째 항목, + New, Open 고정).">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__document-list li:nth-child(2)", ".hm-sw-header__document-action"]}><DocumentHeader documentMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="문서 항목 선택 · New / Open" description="클릭으로 열고 닫습니다. 항목을 고르면 onDocumentSelect가 호출되고(이 예제는 선택한 문서를 파일명·현재 문서로 표시), New / Open은 각각 onDocumentNew / onDocumentOpen을 호출합니다. 바깥 클릭·Escape로 닫힙니다. 키보드: Enter/Space 열기, ↓↑ 이동.">
<DocumentDemo width={1920} />
</StorySection>
<StorySection title="현재 문서 — 첫 번째 문서 선택" description="사용자 추가 요구(Figma 외): activeDocumentId 문서의 이름만 #FFC600으로 표시합니다. 목록은 documents 순서 그대로이고, 아이콘·높이·padding은 같으며 노란 테두리·배경은 없습니다. 모든 행 오른쪽에 Close Document(X) 자리가 있으며 평상시에는 숨겨져 있습니다.">
<MenuFrame width={1920}><DocumentHeader activeDocumentId="doc-1" documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="현재 문서 — 중간 문서(3번째) 선택" description="선택한 문서는 제자리(3번째)에 있고 이름만 #FFC600입니다. 목록 순서 1 → 2 → 3 → 4는 바뀌지 않습니다.">
<MenuFrame width={1920}><DocumentHeader activeDocumentId="doc-3" documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="현재 문서 — 마지막 문서 선택">
<MenuFrame width={1920}><DocumentHeader activeDocumentId="doc-4" documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="일반 문서 Hover (고정)" description="Hover한 행에만 X가 나타납니다(선택 여부와 무관). 배경은 Figma 목록 Hover(검정 40%), 글자는 #F0F7F5 그대로입니다.">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__document-list li:nth-child(2)"]}><DocumentHeader activeDocumentId="doc-3" documentMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="현재 문서 Hover (고정)" description="현재 문서는 노란 글자를 유지한 채 Hover 배경(검정 40%)과 X가 함께 표시됩니다. 선택은 글자색, Hover는 배경이라 서로 구분됩니다.">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__document-row[data-active]"]}><DocumentHeader activeDocumentId="doc-3" documentMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="X 버튼 Hover (고정)" description="X 자체에 Hover하면 배경 rgba(182,208,201,.2)와 밝은 아이콘으로 바뀝니다(사용자 추가 요소, Figma 없음). 키보드 Focus는 아래 예제에서 Tab으로 확인합니다(행에 Focus가 오면 X가 보이고, X에는 2px #F0F7F5 윤곽선).">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__document-row[data-active]", ".hm-sw-header__document-close"]}><DocumentHeader activeDocumentId="doc-3" documentMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="선택 변경 · 닫기 Callback 기록" description="다른 문서를 고르면 onDocumentSelect만 호출되고, 목록 순서는 그대로 둔 채 노란 글자가 그 문서로 옮겨갑니다. 마우스를 옮기면 X도 Hover한 행으로 옮겨갑니다. 어느 행이든 X(또는 X에 Focus 후 Enter/Space)는 그 문서의 onDocumentClose만 한 번 호출합니다. 이 예제의 호스트는 닫은 문서를 목록에서 빼고, 현재 문서를 닫았을 때만 선택을 비웁니다(다음 문서 자동 선택은 호스트가 결정).">
<DocumentDemo width={1920} activeDocumentId="doc-2" />
</StorySection>
<StorySection title="여러 문서 (18개)" description="목록이 길면 Dropdown 안에서만 세로 스크롤합니다(최대 320px).">
<MenuFrame width={1920}><DocumentHeader documents={MANY_DOCUMENTS} documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="예외 — 문서 없음 (documents = [])" description="호스트에 열린 문서가 없을 때만의 예외 상태입니다(기본 예제 아님). 목록 영역 없이 New / Open만 표시합니다. documents를 전달하지 않으면 Dropdown 없이 기존 버튼으로 동작합니다.">
<MenuFrame width={1920}><DocumentHeader documents={[]} documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="긴 파일명" description="파일명과 목록 항목 모두 말줄임을 유지합니다.">
<MenuFrame width={1440}><DocumentHeader documents={LONG_DOCUMENTS} activeDocumentId="long-1" documentMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="좁은 화면 (헤더 390px)" description="파일명이 좁아져도 목록은 파일명 왼쪽에 맞춰 열리고, 헤더 폭을 넘지 않도록 너비가 줄어듭니다.">
<MenuFrame width={390}><DocumentHeader activeDocumentId="doc-3" documentMenuOpen /></MenuFrame>
</StorySection>
</StoryPage>
),
}
/** Test data only. The package never ships layer names; each SW program passes its own list. */
const SAMPLE_LAYERS: SWHeaderLayer[] = [
{ id: "layer-0", name: "0" },
{ id: "layer-1", name: "Sample-Center", color: "#ff0000" },
{ id: "layer-2", name: "Sample-Wall", color: "#ffe500", visible: false },
{ id: "layer-3", name: "Sample-Door", color: "#00ff00", frozen: true },
{ id: "layer-4", name: "Sample-Text", color: "#00ffff", locked: true },
{ id: "layer-5", name: "Sample-Dimension", color: "#0000ff", visible: false, frozen: true, locked: true },
{ id: "layer-6", name: "Sample-Hatch", color: "#ff00ff" },
{ id: "layer-7", name: "Sample-Defpoints", color: "#808080", disabled: true },
]
const LONG_LAYERS: SWHeaderLayer[] = [
{ id: "long-1", name: "Sample-매우-긴-레이어-이름-Ellipsis-확인용-Layer-Name-0123456789", color: "#ffe500" },
{ id: "long-2", name: "Sample_Layer_With_A_Very_Long_Name_For_Truncation_Check", color: "#00ffff", frozen: true },
{ id: "long-3", name: "0" },
]
const MANY_LAYERS: SWHeaderLayer[] = Array.from({ length: 20 }, (_, index) => ({
id: `many-${index + 1}`,
name: `Sample-Layer-${String(index + 1).padStart(2, "0")}`,
color: ["#ffffff", "#ff0000", "#ffe500", "#00ff00", "#00ffff"][index % 5],
visible: index % 4 !== 1,
frozen: index % 5 === 2,
locked: index % 6 === 3,
}))
/* ------------------------------------------------------------------------------------------------
Field dropdowns (2026-10-16): color 26:10986 / 26:11036, ByLayer 26:12666, ByBlock 26:12659,
line style 26:11104 (rows 26:10702), text style 26:11181, dimension style 26:11215.
All names and values below are Figma sample data for the Storybook only; the package ships none.
------------------------------------------------------------------------------------------------ */
const FIGMA_COLORS: SWHeaderColorOption[] = [
{ id: "white", color: "#ffffff", label: "흰색" },
{ id: "red", color: "#f10404", label: "빨강" },
{ id: "green", color: "#48ff08", label: "초록" },
{ id: "yellow", color: "#ffff00", label: "노랑" },
{ id: "blue", color: "#0012ff", label: "파랑" },
{ id: "magenta", color: "#ff00f6", label: "자홍" },
{ id: "cyan", color: "#00f6ff", label: "청록" },
]
const FIGMA_USER_COLORS: SWHeaderColorOption[] = [
{ id: "user-1", color: "#00bd79", label: "사용자 색상 1" },
{ id: "user-2", color: "#ffafaf", label: "사용자 색상 2" },
]
const MANY_USER_COLORS: SWHeaderColorOption[] = Array.from({ length: 40 }, (_, index) => ({
id: `user-many-${index + 1}`,
color: `hsl(${(index * 37) % 360} 70% 55%)`,
label: `사용자 색상 ${index + 1}`,
}))
const FIGMA_LINE_TYPES: SWHeaderLineType[] = [
{ id: "byblock", name: "ByBlock" },
{ id: "bylayer", name: "ByLayer" },
{ id: "continuous", name: "Continuous" },
{ id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center },
{ id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden },
]
const FIGMA_LINE_WEIGHTS: SWHeaderLineWeight[] = [
{ id: "w-bylayer", name: "ByLayer" },
{ id: "w-byblock", name: "ByBlock" },
{ id: "w-default", name: "ByDefault", value: "0.25" },
{ id: "w-user", name: "User", value: "0.25", editable: true },
]
const FIGMA_TEXT_STYLES: SWHeaderTextStyleOption[] = [
{ id: "standard", font: "Arial", name: "Standard" },
{ id: "dotum", font: "돋움", name: "Dotum" },
]
const FIGMA_DIMENSION_STYLES: SWHeaderDimensionStyleOption[] = [
{ id: "standard", name: "Standard" },
{ id: "iso-25", name: "ISO - 25" },
]
type StyleHostProps = Partial<SWHeaderProps> & { onLog?: (line: string) => void; showState?: boolean }
/**
* Host-owned field data: the SW program keeps color, line / text / dimension style and the lists. The
* header only reports; this example writes each report back (an explicit color clears ByLayer / ByBlock,
* which is this host's choice) so the fields follow. Each property changes alone.
*/
function StyleHeader({ onLog, showState, ...props }: StyleHostProps) {
const [color, setColor] = useState<SWHeaderColorValue>(figmaArgs.color!)
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
const [lineStyle, setLineStyle] = useState<SWHeaderLineStyleValue>(figmaArgs.lineStyle!)
const [lineWeights, setLineWeights] = useState(FIGMA_LINE_WEIGHTS)
const [weightId, setWeightId] = useState<string | undefined>("w-default")
const [textStyle, setTextStyle] = useState(figmaArgs.textStyle!)
const [dimensionStyle, setDimensionStyle] = useState(figmaArgs.dimensionStyle!)
const [layers, setLayers] = useState(SAMPLE_LAYERS)
const [layerId, setLayerId] = useState("layer-1")
const log = (line: string) => onLog?.(line)
const header = (
<SWHeader
{...figmaArgs}
{...callbacks}
layers={layers}
activeLayerId={layerId}
onLayerSelect={layer => { log(`onLayerSelect(${layer.id})`); setLayerId(layer.id) }}
onLayerVisibilityChange={(layer, next) => { log(`onLayerVisibilityChange(${layer.id}, ${next})`); setLayers(list => list.map(item => (item.id === layer.id ? { ...item, visible: next } : item))) }}
onLayerOpen={() => log("onLayerOpen")}
onLayerMenuOpenChange={open => log(`onLayerMenuOpenChange(${open})`)}
color={color}
colors={FIGMA_COLORS}
userColors={FIGMA_USER_COLORS}
colorMode={colorMode}
onColorOpen={() => log("onColorOpen")}
onColorMenuOpenChange={open => log(`onColorMenuOpenChange(${open})`)}
onColorSelect={option => { log(`onColorSelect(${option.id})`); setColor({ value: option.value ?? parseSWHeaderColor(option.color)?.value ?? option.color, swatch: option.color }); setColorMode(null) }}
onColorModeChange={mode => { log(`onColorModeChange(${mode})`); setColorMode(mode); setColor(current => ({ ...current, value: mode === "byLayer" ? "ByLayer" : "ByBlock" })) }}
onColorInput={value => { log(`onColorInput(${value.value})`); setColor({ value: value.value, swatch: value.hex }); setColorMode(null) }}
onColorPick={() => log("onColorPick")}
onColorDetail={() => log("onColorDetail")}
lineStyle={lineStyle}
lineTypes={FIGMA_LINE_TYPES}
lineWeights={lineWeights}
activeLineWeightId={weightId}
onLineStyleOpen={() => log("onLineStyleOpen")}
onLineStyleMenuOpenChange={open => log(`onLineStyleMenuOpenChange(${open})`)}
onLineTypeSelect={type => { log(`onLineTypeSelect(${type.id})`); setLineStyle(current => ({ ...current, name: type.name })) }}
onLineWeightSelect={weight => { log(`onLineWeightSelect(${weight.id})`); setWeightId(weight.id); setLineStyle(current => ({ ...current, weight: weight.value ?? weight.name })) }}
onLineWeightValueChange={(weight, value) => {
log(`onLineWeightValueChange(${weight.id}, ${value})`)
setLineWeights(list => list.map(item => (item.id === weight.id ? { ...item, value } : item)))
if (weight.id === weightId) setLineStyle(current => ({ ...current, weight: value }))
}}
onLineScaleChange={value => { log(`onLineScaleChange(${value})`); setLineStyle(current => ({ ...current, scale: value })) }}
onLineStyleDetail={() => log("onLineStyleDetail")}
textStyle={textStyle}
textStyles={FIGMA_TEXT_STYLES}
onTextStyleOpen={() => log("onTextStyleOpen")}
onTextStyleMenuOpenChange={open => log(`onTextStyleMenuOpenChange(${open})`)}
onTextStyleSelect={style => { log(`onTextStyleSelect(${style.id})`); setTextStyle({ font: style.font, name: style.name }) }}
onTextStyleDetail={() => log("onTextStyleDetail")}
dimensionStyle={dimensionStyle}
dimensionStyles={FIGMA_DIMENSION_STYLES}
onDimensionStyleOpen={() => log("onDimensionStyleOpen")}
onDimensionStyleMenuOpenChange={open => log(`onDimensionStyleMenuOpenChange(${open})`)}
onDimensionStyleSelect={style => { log(`onDimensionStyleSelect(${style.id})`); setDimensionStyle({ name: style.name }) }}
onDimensionStyleDetail={() => log("onDimensionStyleDetail")}
{...props}
/>
)
if (!showState) return header
return (
<>
<pre className="sw-story-layer-state" data-style-state="">
{`layer: ${layers.find(item => item.id === layerId)?.name} · color: ${color.value} (${color.swatch}) · colorMode: ${colorMode ?? "null"}\n`}
{`lineStyle: ${lineStyle.name} / weight ${lineStyle.weight} (${weightId ?? "-"}) / scale ${lineStyle.scale} · textStyle: ${textStyle.font} ${textStyle.name} · dimensionStyle: ${dimensionStyle.name}`}
</pre>
{header}
</>
)
}
/** Interactive host with a visible callback log (checks that every action calls one callback once). */
function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
const [log, setLog] = useState<string[]>([])
return (
<>
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
<MenuFrame width={width}><StyleHeader showState {...props} onLog={line => setLog(lines => [...lines.slice(-11), line])} /></MenuFrame>
</>
)
}
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
export const ResponsiveMore: Story = {
name: "Responsive More",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Responsive More — 대표 필드 전환"
description="좁은 헤더에서 더보기(…)의 필드 항목을 고르면, 그 필드가 객체 수정 영역의 첫 칸(레이어 필드 자리, 대표 슬롯)에 표시되고 Dropdown이 바로 아래에 열립니다. 레이어는 더보기로 이동해 다시 고를 수 있습니다. 메뉴 전환은 UI 상태일 뿐이며 데이터 Callback은 호출하지 않습니다(필드 클릭과 같은 on*Open 1회만). 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)는 그대로입니다."
/>
<StorySection title="전환 · Callback 기록 (1440)" description="… → 선스타일: 대표 칸이 선스타일(미리보기·이름·가중치·스케일)로 바뀌고 선스타일 Dropdown이 열립니다. … → 문자스타일: 선스타일 Dropdown이 닫히고 대표 칸이 문자스타일로. … → 레이어: 원래 배치로 복귀. 값은 모두 유지됩니다.">
<StyleDemo width={1440} />
</StorySection>
<StorySection title="전환 · Callback 기록 (768)" description="색상도 더보기에 있는 폭입니다. … → 색상: 색상 필드와 목록.">
<StyleDemo width={768} />
</StorySection>
<StorySection title="전환 · Callback 기록 (390)" description="대표 필드는 남는 폭에 맞춰 값이 말줄임되고, 목록은 헤더 안쪽으로 이동합니다.">
<StyleDemo width={390} />
</StorySection>
{MORE_WIDTHS.map(width => (
<StorySection key={width} title={`${width}px`}>
<MenuFrame width={width}><StyleHeader /></MenuFrame>
</StorySection>
))}
</StoryPage>
),
}
/* Additional review 2026-10-16: fixture values are host data, never package defaults. */
const PROPERTY_GROUPS: SWHeaderPropertyGroup[] = [
{ id: "common", collapsedCount: 6 },
{ id: "shape", name: "형상", layout: "circle" },
{ id: "position", name: "위치", layout: "position", positionLabel: "중간점" },
]
const PROPERTY_FIXTURE: SWHeaderProperty[] = [
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 },
{ id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" },
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] },
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 },
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
{ id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" },
{ id: "circumference", name: "원주", value: "0.25", type: "computed", groupId: "shape", graphicSlot: "circumference" },
{ id: "diameter", name: "지름", value: "8.1502", type: "number", groupId: "shape", graphicSlot: "diameter", min: 0 },
{ id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", graphicSlot: "radius", min: 0 },
{ id: "area", name: "면적", value: "51.5962", type: "computed", groupId: "shape", graphicSlot: "area" },
{ id: "x", name: "X", value: "48.0808", type: "number", groupId: "position" },
{ id: "y", name: "Y", value: "14.9160", type: "number", groupId: "position" },
{ id: "z", name: "Z", value: "0.0000", type: "number", groupId: "position" },
]
const PROPERTY_COUNTS = [
{ id: "shape", name: "도형", count: 10, row: 1, column: 1 }, { id: "line", name: "직선", count: 10, row: 1, column: 2 },
{ id: "circle", name: "원", count: 1, row: 1, column: 3 }, { id: "ray", name: "광선", count: 1, row: 1, column: 4 },
{ id: "curve", name: "곡선", count: 10, row: 2, column: 3 }, { id: "arc", name: "호", count: 1, row: 2, column: 4 },
{ id: "polyline", name: "폴리선", count: 10, row: 3, column: 3 }, { id: "polyline2", name: "폴리선", count: 2, row: 3, column: 4 },
{ id: "text1", name: "문자", count: 1, row: 4, column: 1 }, { id: "text2", name: "문자", count: 1, row: 4, column: 2 },
{ id: "text3", name: "문자", count: 1, row: 4, column: 3 },
]
type PropertyExample = "none" | "all" | "circle" | "expanded"
function propertySelection(example: PropertyExample): SWHeaderPropertySelection {
return example === "none" ? { id: "none", title: "선택된 객체 - 없음", kind: "none" }
: example === "all" ? { id: "all", title: "선택된 객체 - 전체 11", kind: "all", counts: PROPERTY_COUNTS }
: { id: "circle", title: "선택된 객체 - 원1", kind: "single", overviewTitle: "선택된 객체 - 전체 11" }
}
function propertyFixture(example: PropertyExample) {
return PROPERTY_FIXTURE.filter(item => (example === "none" || example === "all") ? item.groupId === "common" : true).map(item => ({
...item, ...(example === "none" && item.id === "layer" ? { value: "0" } : {}),
...(example === "none" && item.id === "color" ? { value: "138,138,138" } : {}),
...(example === "all" && item.id === "layer" ? { mixed: true } : {}),
...((example === "none" || example === "all") && item.id === "opacity" ? { value: "0" } : {}),
}))
}
/* Property field dropdowns (2026-10-19): host data, same records as the header dropdowns. Ids are the names
because the property value is the layer / line type name. */
const PROPERTY_LAYERS: SWHeaderLayer[] = [
{ id: "0", name: "0" },
{ id: "0_Layer_Cludmark", name: "0_Layer_Cludmark", color: "#ff0000" },
{ id: "CC-D GNL", name: "CC-D GNL" },
{ id: "Sample-Wall", name: "Sample-Wall", color: "#ffe500", visible: false },
{ id: "Sample-Door", name: "Sample-Door", color: "#00ff00", frozen: true },
{ id: "Sample-Text", name: "Sample-Text", color: "#00ffff", locked: true },
{ id: "Sample-Defpoints", name: "Sample-Defpoints", color: "#808080", disabled: true },
]
const PROPERTY_LINE_TYPES: SWHeaderLineType[] = FIGMA_LINE_TYPES.map(item => ({ ...item, id: item.name }))
const linePatternOf = (value: string) => PROPERTY_LINE_TYPES.find(item => item.id === value)?.pattern
function usePropertyMenus(record: (line: string) => void, setProperties: (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void): SWHeaderPropertyMenus {
const [layers, setLayers] = useState(PROPERTY_LAYERS)
const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS)
const [weightId, setWeightId] = useState("w-default")
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
const setValue = (id: string, value: string) => setProperties(items => items.map(item => item.id === id ? { ...item, value, mixed: false } : item))
const scale = "1.0000"
const toggle = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => {
record(`layer.${key}(${layer.id}, ${next})`)
setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item))
}
return {
layers, onLayerVisibilityChange: toggle("visible"), onLayerFreezeChange: toggle("frozen"), onLayerLockChange: toggle("locked"),
onLayerDetail: () => record("layerDetail"),
lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale,
onLineWeightSelect: weight => { record(`lineWeight(${weight.id})`); setWeightId(weight.id); if (weight.value) setValue("weight", weight.value) },
onLineWeightValueChange: (weight, value) => { record(`lineWeightValue(${weight.id}, ${value})`); setWeights(items => items.map(item => item.id === weight.id ? { ...item, value } : item)) },
onLineScaleChange: value => { record(`lineScale(${value})`); setValue("scale", Number(value).toFixed(4)) },
onLineStyleDetail: () => record("lineStyleDetail"),
colors: FIGMA_COLORS, colorMode,
onColorModeChange: mode => { record(`colorMode(${mode})`); setColorMode(mode) },
onColorPick: property => record(`colorPick(${property.id})`),
onColorDetail: () => record("colorDetail"),
}
}
export const KGuide: Story = {
name: "K Guide",
parameters: { controls: { disable: true } },
render: () => <StoryPage><StoryPageHeader kicker={KICKER} title="K 가이드 반복 애니메이션" description="기존 K 버튼 Hover/Focus → 원본 이미지 Pan·Zoom 반복. 영역을 벗어나면 정지, 재진입은 처음부터. Reduced Motion은 정지 이미지." /><div className="sw-story-property-room"><SWHeader {...figmaArgs} {...callbacks} /></div></StoryPage>,
}
const BAR_FIXTURE: SWHeaderProperty[] = [
{ id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", min: 0 },
{ id: "diameter", name: "지름", value: "8.1052", type: "number", groupId: "shape", min: 0 },
{ id: "circumference", name: "원주", value: "8.1052", type: "number", groupId: "shape", min: 0 },
{ id: "area", name: "면적", value: "51.5962", type: "number", groupId: "shape", min: 0 },
]
/* Property panel: the four Figma states with the same component and host data. */
const PROPERTY_STATES: { example: PropertyExample; title: string; node: string; size: string }[] = [
{ example: "none", title: "선택된 객체 없음", node: "26:11886", size: "314 × 256" },
{ example: "all", title: "전체 객체 선택", node: "26:11823", size: "314 × 361" },
{ example: "circle", title: "원 1개 선택", node: "26:11932", size: "314 × 658" },
{ example: "expanded", title: "모든 항목 펼침", node: "26:12035", size: "314 × 754" },
]
function PropertyState({ example, shapeContent, onLog }: { example: PropertyExample; shapeContent?: SWHeaderPropertyShapeContent; onLog?: (line: string) => void }) {
const [properties, setProperties] = useState(() => propertyFixture(example))
const [expanded, setExpanded] = useState<string[]>(example === "expanded" ? ["common"] : [])
const menus = usePropertyMenus(line => onLog?.(line), setProperties)
return <SWTheme><SWHeaderPropertyPanel selection={propertySelection(example)} properties={properties} groups={PROPERTY_GROUPS}
expandedGroupIds={expanded} onExpandedGroupsChange={setExpanded} shapeContent={shapeContent} menus={menus}
onPropertyChange={(property, value) => { onLog?.(`property(${property.id}, ${value})`); setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: linePatternOf(value) } : {}) } : item)) }}
onClose={() => onLog?.("close")} /></SWTheme>
}
/** Design check only: a real header shows one panel for the current selection.
2 columns × 2 rows at the panels' own size; the Inner scrolls instead of shrinking or wrapping them. */
function PropertyCompare() {
const [sizes, setSizes] = useState<Record<string, string>>({})
const [log, setLog] = useState<string[]>([])
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const measure = () => setSizes(Object.fromEntries(Array.from(ref.current?.querySelectorAll<HTMLElement>("[data-compare]") ?? []).map(cell => {
const panel = cell.querySelector<HTMLElement>(".hm-sw-property")!.getBoundingClientRect()
return [cell.dataset.compare!, `${Math.round(panel.width)} × ${Math.round(panel.height)}`]
})))
measure()
const observer = new ResizeObserver(measure)
ref.current?.querySelectorAll(".hm-sw-property").forEach(panel => observer.observe(panel))
return () => observer.disconnect()
}, [])
return <>
<p className="sw-story-log" data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
<div className="sw-story-compare-scroll" data-compare-scroll="">
<div ref={ref} className="sw-story-compare sw-story-compare--grid">
{PROPERTY_STATES.map(state => <figure key={state.example} className="sw-story-compare__cell" data-compare={state.example}>
<figcaption><strong>{state.title}</strong><span>Figma {state.node} · {state.size}</span><span data-measured="">측정 {sizes[state.example] ?? "-"}</span></figcaption>
<PropertyState example={state.example} onLog={line => setLog(lines => [...lines.slice(-7), `${state.example}: ${line}`])} />
</figure>)}
</div>
</div>
</>
}
export const PropertyPanelCompare: Story = {
name: "Property Panel · 4개 비교",
parameters: { controls: { disable: true } },
render: () => <StoryPage className="sw-story-compare-page"><StoryPageHeader kicker={KICKER} title="속성창 4개 상태 비교" description="네 패널은 같은 SWHeaderPropertyPanel입니다. 실제 Header에서는 현재 선택 객체의 패널 하나만 표시합니다." /><PropertyCompare /></StoryPage>,
}