refactor(storybook): consolidate dropdown stories into composed controls

This commit is contained in:
shkim4 committed 2026-10-19 12:30:29 +09:00
1 parent c165753bbe
commit 9482d45f34
74 files changed
+38833 -283

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
/* Story-owned composition only. Public controls retain package sizes and styling. */
.sw-composed-controls .sw-story-composed { flex-wrap: wrap; }
.sw-composed-controls__settings { margin-bottom: 12px; font-size: 13px; }
.sw-composed-controls__settings summary { width: max-content; cursor: pointer; }
.sw-composed-controls__settings-content { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding-block: 8px; }
.sw-composed-controls__settings-content label { display: inline-flex; align-items: center; gap: 4px; }
.sw-composed-controls__status { margin-top: 12px; font-size: 12px; overflow-wrap: anywhere; }
.sw-composed-controls__status output { display: block; }
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { ComposedDemo } from "./sw-header.stories"
import "./sw-composed-controls.css"
const meta = {
title: "SW/Header/Dropdown",
// Keep existing bookmarks while placing the single example under Dropdown.
id: "sw-header",
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta
export default meta
export const ComposedControls: StoryObj<typeof meta> = {
name: "Composed Controls",
render: () => <ComposedDemo />,
}
+122 -263
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react"
import { useEffect, useId, useRef, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
SWHeader, SWHeaderColorControl, SWHeaderLayerControl, SWHeaderLineStyle, SWHeaderTextStyle, SWHeaderDimensionStyle,
SWHeader, SWHeaderColorMenu, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWHeaderDimensionStyleMenu,
SWHeaderProperties, SWHeaderWindowControls, SWTheme, SWHeaderLayerMenu, SWHeaderInput, parseSWHeaderColor, swHeaderLinePatterns, SWHeaderPropertyPanel, SWHeaderPropertyBar,
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
@@ -65,6 +65,7 @@ const callbacks = {
const meta = {
title: "SW/Header",
excludeStories: ["ComposedDemo"],
component: SWHeader,
parameters: { layout: "fullscreen" },
args: { ...figmaArgs, ...callbacks },
@@ -302,48 +303,126 @@ export const WindowMoveMotion: Story = {
),
}
export const ComposedControls: Story = {
name: "Composed Controls",
parameters: { controls: { disable: true } },
render: () => <ComposedDemo />,
}
function ComposedDemo() {
// 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)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="Composed Controls" description="공개 하위 컴포넌트만 골라 다른 SW 화면에 재사용하는 예입니다. SWTheme이 SW 토큰·글꼴·버튼 초기화 범위를 제공하므로 소비자가 hm-* class를 붙이지 않습니다. 녹색 면은 예제 배치용 Story 배경입니다." />
<StorySection title="레이어 · 색상 · 상세보기">
<SWTheme className="sw-story-composed">
<SWHeaderLayerControl value={{ name: "0_Layer_Cludmark" }} />
<SWHeaderColorControl value={{ value: "255,229,0", swatch: "#ffe500" }} selected />
<SWHeaderProperties panelOpen={panel} onPanelToggle={() => setPanel(value => !value)} />
</SWTheme>
</StorySection>
<StorySection title="선스타일 · 문자스타일 · 치수스타일" description="필드 전체 Hover 시 라벨·값·선/폰트/치수 미리보기가 #FFC600입니다. 선의 검정 border와 치수 SVG의 검정 외곽선은 유지합니다.">
<div className="sw-story-scroller">
<SWTheme className="sw-story-composed">
<SWHeaderLineStyle value={figmaArgs.lineStyle!} onClick={callbacks.onLineStyleOpen} />
<SWHeaderTextStyle value={figmaArgs.textStyle!} onClick={callbacks.onTextStyleOpen} />
<SWHeaderDimensionStyle value={figmaArgs.dimensionStyle!} onClick={callbacks.onDimensionStyleOpen} />
</SWTheme>
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="Composed Controls" 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>
<SWTheme>
<div className="sw-story-composed" role="group" aria-label="조합 컨트롤" 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>
</StorySection>
<StorySection title="그룹 필드 Hover (고정)" description="기존 노란 Gradient Border에 텍스트·지정 미리보기 강조만 더합니다. 색상 견본과 레이어 상태는 원래 색을 유지하며, 상세보기는 기존 텍스트 전용 Hover 그대로입니다.">
<div className="sw-story-scroller">
<PinHover selectors={[".hm-sw-header__field"]}>
<SWTheme className="sw-story-composed">
<SWHeaderLayerControl value={figmaArgs.layer!} />
<SWHeaderColorControl value={figmaArgs.color!} />
<SWHeaderLineStyle value={figmaArgs.lineStyle!} />
<SWHeaderTextStyle value={figmaArgs.textStyle!} />
<SWHeaderDimensionStyle value={figmaArgs.dimensionStyle!} />
</SWTheme>
</PinHover>
</div>
</StorySection>
</StoryPage>
)
</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>
<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). */
@@ -558,16 +637,7 @@ const MANY_LAYERS: SWHeaderLayer[] = Array.from({ length: 20 }, (_, index) => ({
frozen: index % 5 === 2,
locked: index % 6 === 3,
}))
/**
* Icon variant matrix: rows 1-2 colored (visible, thawed, unlocked), rows 3-4 gray (hidden, frozen,
* locked); rows 2 and 4 get pinned icon hover.
*/
const ICON_LAYERS: SWHeaderLayer[] = [
{ id: "icon-on", name: "컬러 (Default)", visible: true, frozen: false, locked: false },
{ id: "icon-on-hover", name: "컬러 Hover (Variant2)", visible: true, frozen: false, locked: false },
{ id: "icon-off", name: "회색 (Variant3)", visible: false, frozen: true, locked: true },
{ id: "icon-off-hover", name: "회색 Hover (Variant4)", visible: false, frozen: true, locked: true },
]
const noop = () => {}
type LayerHostProps = Partial<SWHeaderProps> & { onLog?: (line: string) => void; showState?: boolean }
@@ -618,94 +688,6 @@ function LayerDemo({ width, ...props }: LayerHostProps & { width: number }) {
)
}
/** Standalone open layer menu: the 12 icon variants (rows 2 and 4 pin the icon hover). */
function IconMatrix() {
return (
<PinHover selectors={[".hm-sw-header__layer-row:nth-child(2) .hm-sw-header__layer-toggle", ".hm-sw-header__layer-row:nth-child(4) .hm-sw-header__layer-toggle"]}>
<div className="sw-story-zoom sw-story-layer-room">
<SWTheme className="sw-story-composed">
<SWHeaderLayerMenu
value={{ name: "아이콘 Variant" }}
layers={ICON_LAYERS}
open
onVisibilityChange={noop}
onFreezeChange={noop}
onLockChange={noop}
/>
</SWTheme>
</div>
</PinHover>
)
}
const NARROW_LAYER_WIDTHS = [1440, 1080, 900, 768, 640, 390]
export const LayerDropdown: Story = {
name: "Layer Dropdown",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Layer Dropdown"
description="레이어 필드를 클릭하면 Figma header/menu/레이어(26:10940)의 레이어 목록이 필드 아래로 겹쳐 열립니다. Hover만으로는 열리지 않습니다. 행은 header/dropdown/brown/row(26:10687), 아이콘은 icon_전구(26:10810)·icon_눈꽃(26:10855)·icon_자물쇠(26:10912)입니다. 레이어 목록과 각 상태는 호스트 데이터이며 아래 레이어명은 테스트용 예시입니다."
/>
<StorySection title="Layer Default" description="layers를 전달한 기본 상태. 필드 234 × 38, 기존 테두리·그림자, 아이콘 3개 + 라벨/레이어명. layers를 전달하지 않으면 기존 필드 버튼(onLayerOpen) 그대로입니다.">
<Frame width={1920}><LayerHeader layers={FIGMA_LAYERS} /></Frame>
</StorySection>
<StorySection title="Layer Hover (고정)" description="기존 노란 Gradient Border와 라벨·레이어명 #FFC600. 목록은 열리지 않습니다.">
<Frame width={1920}><PinHover selectors={[".hm-sw-header__field--layer"]}><LayerHeader layers={FIGMA_LAYERS} /></PinHover></Frame>
</StorySection>
<StorySection title="Layer Dropdown Open (Figma 8개 행)" description="Variant3: 라벨·레이어명 #FFC600, 필드 테두리 #081813·하단 각진 모서리, 필드+목록을 감싸는 노란 Gradient 1px 외곽선(236 × 320). 목록 234 × 280(행 30 × 8 + 하단 40), 배경 #27241D, 검정 1px, 하단 radius 3px, Shadow 0 4px 8px rgba(16,48,38,.25). 이 예제는 onLayerDetail이 없어 클릭은 무동작이며 상세 버튼의 공통 Hover는 유지합니다.">
<MenuFrame width={1920}><LayerHeader layers={FIGMA_LAYERS} layerMenuOpen onLayerDetail={undefined} /></MenuFrame>
</StorySection>
<StorySection title="Layer Row Default · Row Hover (고정)" description="두 번째 행 Hover 고정: 배경 #62533D, 레이어명 #FF5C00(26:10695). 행 크기·아이콘 위치는 그대로입니다. 다른 필드·문서 목록의 Hover 색과 무관한 이 목록 전용 색입니다.">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__layer-row:nth-child(2)"]}><LayerHeader layers={FIGMA_LAYERS} layerMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="전구 · 눈꽃 · 자물쇠 On / Hover / Off / Off Hover (3배 확대)" description="1행 컬러(Default: visible=true · frozen=false · locked=false) · 2행 컬러 Hover(Variant2, 고정) · 3행 회색(Variant3: visible=false · frozen=true · locked=true) · 4행 회색 Hover(Variant4, 고정). 전구 Hover는 검정 25% 배경, 눈꽃·자물쇠 Hover는 Figma Hover SVG(같은 아이콘 + 검정 25% 타일)입니다. Off → Off Hover는 Figma Smart Animate에 맞춰 300ms ease-out으로 전환됩니다.">
<IconMatrix />
</StorySection>
<StorySection title="레이어 선택 · 개별 아이콘 Toggle · Callback 기록" description="필드 클릭으로 열고, 레이어명(색상 견본 포함)을 누르면 onLayerSelect만 호출되고 필드에 그 레이어가 표시되며 목록이 닫힙니다. 전구·눈꽃·자물쇠는 각각 onLayerVisibilityChange(layer, !visible) / onLayerFreezeChange(layer, !frozen) / onLayerLockChange(layer, !locked)만 한 번 호출하고 목록은 열린 채 유지됩니다(전구 컬러 = visible=true, 눈꽃 컬러 = frozen=false 동결 해제, 자물쇠 컬러 = locked=false 잠금 해제, 회색은 반대). 위 상태표로 해당 레이어만 바뀌는 것을 확인합니다. 키보드: Enter/Space 열기, ↓↑ 행 이동(같은 열 유지), ←→ 행 안 이동, Escape 닫기, Tab으로 벗어나면 닫힘.">
<LayerDemo width={1920} activeLayerId="layer-0" />
</StorySection>
<StorySection title="여러 레이어의 서로 다른 상태 · 현재 레이어" description="현재 레이어(activeLayerId)는 Hover와 같은 갈색 배경·주황 글자이고 필드 아이콘도 그 레이어 상태를 따릅니다. Sample-Defpoints는 disabled 예시입니다.">
<MenuFrame width={1920}><LayerHeader activeLayerId="layer-5" layerMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="현재 레이어 Hover (고정)" description="현재 레이어의 갈색 배경·주황 글자는 Hover 및 Mouse Leave 뒤에도 유지됩니다.">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__layer-row[data-active]"]}><LayerHeader activeLayerId="layer-1" layerMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="긴 레이어명" description="필드와 행 모두 말줄임으로 처리합니다. 전체 이름은 title로 확인합니다.">
<MenuFrame width={1920}><LayerHeader layers={LONG_LAYERS} activeLayerId="long-1" layerMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="많은 레이어 (20개)" description="목록 부분만 세로 스크롤(최대 8행, 240px)하고 레이어 상세 영역은 고정입니다. 헤더에는 스크롤이 생기지 않습니다.">
<MenuFrame width={1920}><LayerHeader layers={MANY_LAYERS} layerMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="레이어가 없는 상태" description="layers=[]이면 목록 없이 레이어 상세 영역만 표시하고 필드는 layer.name을 표시합니다.">
<MenuFrame width={1920}><LayerHeader layers={[]} layerMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="레이어 상세 버튼" description="onLayerDetail을 전달하면 Callback 호출 후 목록을 닫습니다(아래 기록). 전달하지 않아도 공통 Default/Hover(텍스트 35%)를 유지하며 클릭은 안전한 무동작입니다(위 Figma 8개 행 예제). Disabled는 명시적으로 지정합니다.">
<LayerDemo width={1920} layers={FIGMA_LAYERS} />
</StorySection>
<StorySection title="상태 표시 전용(Toggle Callback 없음)" description="onLayerVisibilityChange 등을 전달하지 않으면 해당 아이콘은 버튼이 아닌 상태 표시(role=img)이며 Hover·클릭 반응이 없습니다.">
<MenuFrame width={1920}>
<SWHeader {...figmaArgs} {...callbacks} layers={SAMPLE_LAYERS} activeLayerId="layer-0" layerMenuOpen />
</MenuFrame>
</StorySection>
{NARROW_LAYER_WIDTHS.map(width => (
<StorySection
key={width}
title={`좁은 화면 (헤더 ${width}px)`}
description={width < 520
? "520 미만에서는 필드의 상태 아이콘이 숨고 레이어명이 말줄임됩니다. 목록은 234px 그대로이며 헤더 밖으로 넘치지 않도록 왼쪽으로 이동합니다(이때 노란 외곽선은 목록만 감쌉니다). 필드를 눌러 열고 조작합니다."
: "필드 위치와 헤더 49px를 유지하고 목록은 필드 왼쪽에 맞춰 열립니다."}
>
<LayerDemo width={width} activeLayerId="layer-1" layerMenuOpen={width < 520 ? undefined : true} />
</StorySection>
))}
</StoryPage>
),
}
/* ------------------------------------------------------------------------------------------------
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.
@@ -843,127 +825,6 @@ function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
)
}
/** Latest explicit hover exceptions: mode pair and five detail buttons. */
const MENU_BUTTON_HOVER = [".hm-sw-header__color-mode", ".hm-sw-header__color-option", ".hm-sw-header__color-pick", ".hm-sw-header__menu-detail"]
export const ColorDropdown: Story = {
name: "Color Dropdown",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Color Dropdown"
description="색상 필드를 클릭하면 Figma header/menu/색상(26:10986)의 목록이 필드 아래로 겹쳐 열립니다(Hover만으로는 열리지 않음). ByLayer/ByBlock(26:12666 / 26:12659), 22px 기본 팔레트, 사용자 색상(26:11036), 색상 추출, RGB 입력, 색상 상세 버튼으로 구성됩니다. 팔레트·사용자 색상·현재 색상은 호스트 데이터이며 아래 값은 Figma 예시입니다."
/>
<StorySection title="Default" description="colors를 전달하면 색상 필드가 Dropdown 버튼이 됩니다. 192 × 38, 견본은 현재 색상(color.swatch).">
<Frame width={1920}><StyleHeader /></Frame>
</StorySection>
<StorySection title="Hover (고정)" description="기존 노란 Gradient Border + 라벨·값 #FFC600. 색상 견본은 실제 색 그대로입니다.">
<Frame width={1920}><PinHover selectors={[".hm-sw-header__field--color"]}><StyleHeader /></PinHover></Frame>
</StorySection>
<StorySection title="Expanded (Figma 26:11005)" description="192 × 158: ByLayer/ByBlock 167 × 30 · 팔레트 168 × 22(칩 22px 7개) · 색상 추출 24 + 입력 130 · 하단 40(색상 상세). 필드 테두리 #081813·아래 모서리 각짐, 필드+목록 노란 1px 외곽선(194 × 198).">
<MenuFrame width={1920}><StyleHeader userColors={[]} colorMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="사용자 추가 색상 (Figma 26:11036)" description="userColors 2개: 24px 행이 추가되어 본문 182px. 개수는 가변이며 2px 간격으로 줄바꿈합니다.">
<MenuFrame width={1920}><StyleHeader colorMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="사용자 색상 40개" description="팔레트 + 사용자 색상 4행(118px)을 넘으면 팔레트 영역만 세로 스크롤합니다. 헤더 높이·다른 필드 위치는 그대로입니다.">
<MenuFrame width={1920}><StyleHeader userColors={MANY_USER_COLORS} colorMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="ByLayer On (Figma on 26:12671)" description="colorMode='byLayer': #62533D 배경 + 밝은 inner shadow. 텍스트 #D4CBBD + 검정 Stroke. ByBlock은 Default(rgba(98,83,61,.3) + 어두운 inner shadow).">
<MenuFrame width={1920}><StyleHeader initialColorMode="byLayer" colorMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="내부 버튼 Hover (고정) — 2026-10-16 추가 수정 정책" description="ByLayer / ByBlock 글자 #FF5C00. 선택 배경과 Inset Shadow는 유지합니다. 색상 상세는 배경 .25·radius 25·Hover 아이콘으로 변합니다. 색상 칩은 Hover가 없고, 색상 추출은 btn_colorize Hover(26:11305)입니다.">
<MenuFrame width={1920}><PinHover selectors={MENU_BUTTON_HOVER}><StyleHeader colorMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="RGB 입력 Hover (고정)" description="입력은 수정 가능함을 알리는 Figma Hover를 유지합니다(header/menu/색상/input hover: 1px rgba(197,177,158,.2)). 편집 중(Focus)에는 값 영역이 검정이 되고 커서는 #E60403입니다 — 아래 실제 예제에서 클릭해 확인합니다.">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__color-input", ".hm-sw-header__color-input .hm-sw-header__input"]}><StyleHeader colorMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="선택 · 입력 · Callback 기록" description="칩 → onColorSelect, ByLayer/ByBlock → onColorModeChange(서로 배타: 이 호스트는 선택한 쪽만 On), 입력(255,0,0 · #00ff00 등, Enter 또는 Focus 이동) → 유효하면 onColorInput, 잘못된 값(256,0,0 · abc)은 복원. 색상 추출 → onColorPick 후 닫힘, 색상 상세 → onColorDetail 후 닫힘. 선택·입력 후에도 목록은 열려 있고 다른 속성(레이어·선스타일 등)은 그대로입니다. Escape·바깥 클릭·Tab으로 닫힘.">
<StyleDemo width={1920} />
</StorySection>
</StoryPage>
),
}
export const LineStyleDropdown: Story = {
name: "Line Style Dropdown",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Line Style Dropdown"
description="선스타일 필드를 클릭하면 Figma header/menu/선스타일(26:11104)의 3열 목록(선 종류 182 · 가중치 142 · 스케일 142, 468 × 192)이 필드 가운데 아래로 열립니다. 선 종류 행은 header/dropdown/brown/row(26:10702). 각 열은 자기 속성만 바꾸며 목록은 열린 채 유지됩니다."
/>
<StorySection title="Default" description="284 × 38. 선 미리보기 10 × 4 × 5, 선스타일 / 가중치 / 스케일.">
<Frame width={1920}><StyleHeader /></Frame>
</StorySection>
<StorySection title="Hover (고정)" description="라벨·값 #FFC600, 선 미리보기 채움 #FFC600(검정 1px 유지), 노란 Gradient Border.">
<Frame width={1920}><PinHover selectors={[".hm-sw-header__field--line-style"]}><StyleHeader /></PinHover></Frame>
</StorySection>
<StorySection title="Expanded (Figma 26:11143)" description="필드 링(286 × 39) + 468 × 192 drop_wrap(1px 노란 링, x −92, y 37). 선 종류 5행 + 하단 40(선스타일 상세), 가중치 4행(ByDefault 0.25, User 입력), 스케일 입력. 현재 선 종류·가중치는 Hover와 같은 갈색 배경·주황 텍스트·선입니다.">
<MenuFrame width={1920}><StyleHeader lineStyleMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="선 종류 행 Default · Hover (Figma 26:10703 / 26:10706, 고정)" description="1·4번째 행 Hover 고정: 배경 #62533D, 선 미리보기·텍스트 #FF5C00, 검정 Stroke 유지, 크기 182 × 30·위치 그대로. CENTER·HIDDEN은 각자의 패턴(22·7 / 33·33)을 유지합니다. 가중치 2번째 행·스케일 행도 Figma 행 Hover를 고정했습니다.">
<MenuFrame width={1920}>
<PinHover selectors={[".hm-sw-header__line-column--type .hm-sw-header__menu-row:nth-child(1)", ".hm-sw-header__line-column--type .hm-sw-header__menu-row:nth-child(4)", ".hm-sw-header__line-column:nth-child(2) .hm-sw-header__menu-row:nth-child(2)", ".hm-sw-header__line-column:nth-child(3) .hm-sw-header__menu-row"]}>
<StyleHeader lineStyleMenuOpen />
</PinHover>
</MenuFrame>
</StorySection>
<StorySection title="현재 행 Hover (고정)" description="현재 선 종류(Continuous)는 Hover 및 Mouse Leave 뒤에도 갈색 배경·주황 선·이름을 유지합니다.">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__menu-row[data-active]"]}><StyleHeader lineStyleMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="상세 버튼 Hover (고정)" description="2026-10-16 추가 수정 기준으로 선스타일 상세의 Figma 배경 .25·radius 25·Hover 아이콘을 적용합니다.">
<MenuFrame width={1920}><PinHover selectors={MENU_BUTTON_HOVER}><StyleHeader lineStyleMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="선 종류 · 가중치 · 스케일 선택 Callback" description="선 종류 → onLineTypeSelect(가중치·스케일 유지), 가중치 → onLineWeightSelect(선 종류·스케일 유지), User 입력 → onLineWeightValueChange(0 이상), 스케일 입력 → onLineScaleChange(0 초과). 입력은 Enter/Focus 이동으로 확정, Escape는 취소. ↓↑로 항목 이동.">
<StyleDemo width={1920} />
</StorySection>
<StorySection title="좁은 헤더 390px" description="468px 목록이 헤더보다 넓으면 3열을 세로로 쌓아(필요 시 세로 스크롤) 화면 밖 이탈·가로 스크롤 없이 모든 항목에 접근합니다. 더보기 → 선스타일로 엽니다.">
<StyleDemo width={390} />
</StorySection>
</StoryPage>
),
}
export const TextDimensionStyleDropdown: Story = {
name: "Text · Dimension Style Dropdown",
parameters: { controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={KICKER}
title="Text · Dimension Style Dropdown"
description="문자스타일(26:11181)과 치수스타일(26:11215) 필드의 목록입니다. 164px, 행 30px, 하단 40px(상세 버튼). 행을 고르면 Callback 후 닫힙니다. 목록은 호스트 데이터이며 아래 값은 Figma 예시입니다."
/>
<StorySection title="Default · Hover (고정)" description="위: Default. 아래: Hover — 라벨·값·글꼴 미리보기 #FFC600, 치수 미리보기는 Figma Hover SVG, 검정 외곽선 유지.">
<div className="sw-story-stack">
<Frame width={1920}><StyleHeader /></Frame>
<Frame width={1920}><PinHover selectors={[".hm-sw-header__field--text-style", ".hm-sw-header__field--dimension-style"]}><StyleHeader /></PinHover></Frame>
</div>
</StorySection>
<StorySection title="문자스타일 Expanded (Figma 26:11200)" description="Arial / Standard, 돋움 / Dotum. 글꼴 16px + 이름 14px(모두 검정 Stroke). 하단 '문자스타일 상세'는 Figma처럼 왼쪽 정렬(테두리 .25).">
<MenuFrame width={1920}><StyleHeader textStyleMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="문자스타일 행·상세 버튼 Hover (고정)" description="두 번째 행과 현재 스타일 모두 #62533D / 글꼴·이름 #FF5C00입니다. 상세 버튼도 Figma Hover입니다.">
<MenuFrame width={1920}><PinHover selectors={[".hm-sw-header__list-menu--text-style .hm-sw-header__menu-row:nth-child(2)", ".hm-sw-header__menu-detail"]}><StyleHeader textStyleMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="치수스타일 Expanded (Figma 26:11242)" description="Standard, ISO - 25. 치수 미리보기 60 × 15(Figma 행 자산 #F6F4F2). 하단 '치수스타일 상세' 오른쪽 정렬.">
<MenuFrame width={1920} fixedDimension><StyleHeader dimensionStyleMenuOpen /></MenuFrame>
</StorySection>
<StorySection title="치수스타일 행·상세 버튼 Hover (고정)" description="두 번째 행: #62533D, 이름·치수 미리보기 #FF5C00(Figma Hover 자산). 상세 버튼 Hover는 26:10799(기본 아이콘·텍스트 100%)이며 Variant3와 혼합하지 않습니다.">
<MenuFrame width={1920} fixedDimension><PinHover selectors={[".hm-sw-header__list-menu--dimension-style .hm-sw-header__menu-row:nth-child(2)", ".hm-sw-header__menu-detail"]}><StyleHeader dimensionStyleMenuOpen /></PinHover></MenuFrame>
</StorySection>
<StorySection title="선택 Callback" description="행 → onTextStyleSelect / onDimensionStyleSelect 1회 후 닫힘, 상세 → on*Detail 1회 후 닫힘. 필드 값이 호스트 상태로 갱신됩니다.">
<StyleDemo width={1920} />
</StorySection>
</StoryPage>
),
}
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
export const ResponsiveMore: Story = {
@@ -1348,8 +1209,6 @@ export const Colorize: Story = {
render: () => <StoryPage><StoryPageHeader kicker={KICKER} title="btn_colorize" description="Figma 26:11302 / 26:11305의 원본 SVG, 24px 버튼과 18px 아이콘. 클릭은 기존 onColorPick 계약입니다. 색상 돋보기 Overlay 26:11338은 호스트 연결 대상입니다." /><StyleDemo width={1920} /></StoryPage>,
}
/* 2026-10-16 composition review: every part except the logo can be removed (hiddenControls), the five
similar menus can be reordered (menuOrder). Untouched props keep the approved default header. */
const HIDEABLE_PARTS: { id: SWHeaderHideableControlId; name: string }[] = [