feat(sw): add SW Footer component and Storybook stories

This commit is contained in:
shkim4 committed 2026-10-20 09:41:12 +09:00
1 parent 77a809baa3
commit 6cf885412e
105 files changed
+3288 -3

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"
import {
swFooterAuxIcons, swFooterToolIcons,
type SWFooterAuxItem, type SWFooterCategory, type SWFooterLayout, type SWFooterTool,
} from "@hm-design/sw"
import { PenLine, SquareDashed } from "lucide-react"
import guideSheet from "./assets/figma-reference/footer/26-9096.png"
// SW/Footer fixture: the Figma 26:10097 footer content as public-API data, like a consuming program builds it.
/** Figma 도형 commands (26:10111): Line … Hatch, 9 slots. */
export const DRAW_TOOLS: SWFooterTool[] = [
{ id: "line", label: "Line", ...swFooterToolIcons.line },
{ id: "pline", label: "PLine", ariaLabel: "Polyline", ...swFooterToolIcons.pline },
{ id: "arc", label: "Arc", ...swFooterToolIcons.arc },
{ id: "circle", label: "Circle", ...swFooterToolIcons.circle },
{ id: "ellipse", label: "Ellipse", ...swFooterToolIcons.ellipse },
{ id: "rect", label: "Rect...", ariaLabel: "Rectangle", ...swFooterToolIcons.rect },
{ id: "poly", label: "Poly", ariaLabel: "Polygon", ...swFooterToolIcons.poly },
{ id: "point", label: "Point", ...swFooterToolIcons.point },
{ id: "hatch", label: "Hatch", ...swFooterToolIcons.hatch },
]
/** Figma category row (text_도형 … text_구조배근). Only 도형 has Figma commands; 수정 shows the NCopy component (26:9739). */
export const CATEGORIES: SWFooterCategory[] = [
{ id: "shape", label: "도형", tools: DRAW_TOOLS },
{ id: "modify", label: "수정", tools: [{ id: "ncopy", label: "NCopy", ariaLabel: "Multiple copy", ...swFooterToolIcons.ncopy }] },
{ id: "edit", label: "편집", tools: [] },
{ id: "text", label: "문자", tools: [] },
{ id: "layer", label: "레이어", tools: [] },
{ id: "block", label: "블록", tools: [] },
{ id: "view", label: "뷰", tools: [] },
{ id: "select", label: "선택/조회", tools: [] },
{ id: "road", label: "도로/횡단", tools: [] },
{ id: "structure", label: "구조/배근", tools: [] },
]
/** Figma 26:10104 (3 × 2). */
export const AUX_ITEMS: SWFooterAuxItem[] = [
{ id: "objectSnap", label: "객체스냅", icon: swFooterAuxIcons.objectSnap, pressed: false },
{ id: "selectionEffect", label: "선택효과", icon: swFooterAuxIcons.selectionEffect, pressed: false },
{ id: "orthogonal", label: "직각교차", icon: swFooterAuxIcons.orthogonal, pressed: false },
{ id: "gridSnap", label: "격자스냅", icon: swFooterAuxIcons.gridSnap, pressed: false },
{ id: "gridBackground", label: "격자배경", icon: swFooterAuxIcons.gridBackground, pressed: false },
{ id: "lineWeight", label: "선가중치", icon: swFooterAuxIcons.lineWeight, pressed: false },
]
/**
* Reference screen (2026-10-20, src/sw/assets/footer-reference): the program's own content in the same footer.
* Labels, categories and the extra command are program data; WIPEOUT and 객체스냅추적 have no Figma icon, so the
* Story passes its own (lucide) icons the way a program would.
*/
const lucide = (node: ReactNode) => <span className="sw-footer-story-lucide">{node}</span>
export const REFERENCE_TOOLS: SWFooterTool[] = [
{ id: "line", label: "LINE", ...swFooterToolIcons.line },
{ id: "pline", label: "PLINE", ariaLabel: "Polyline", ...swFooterToolIcons.pline },
{ id: "arc", label: "ARC", ...swFooterToolIcons.arc },
{ id: "circle", label: "CIRCLE", ...swFooterToolIcons.circle },
{ id: "ellipse", label: "ELLIPSE", ...swFooterToolIcons.ellipse },
{ id: "rect", label: "RECTA...", ariaLabel: "Rectangle", ...swFooterToolIcons.rect },
{ id: "poly", label: "POLYGON", ...swFooterToolIcons.poly },
{ id: "point", label: "POINT", ...swFooterToolIcons.point },
{ id: "hatch", label: "HATCH", ...swFooterToolIcons.hatch },
{ id: "wipeout", label: "WIPEOUT", icon: lucide(<SquareDashed size={16} />) },
]
export const REFERENCE_CATEGORIES: SWFooterCategory[] = [
{ id: "shape", label: "도형", tools: REFERENCE_TOOLS },
...["수정", "편집", "문자", "치수/연산", "블록", "레이어", "뷰", "선택/조회", "모델링", "선형/횡단", "구조/배근"]
.map((label, index) => ({ id: `c${index}`, label, tools: index === 0 ? CATEGORIES[1].tools : [] })),
]
export const REFERENCE_AUX: SWFooterAuxItem[] = [
{ id: "objectSnap", label: "객체스냅", icon: swFooterAuxIcons.objectSnap, pressed: false },
{ id: "orthogonal", label: "직각교차", icon: swFooterAuxIcons.orthogonal, pressed: false },
{ id: "lineWeight", label: "선가중치", icon: swFooterAuxIcons.lineWeight, pressed: false },
{ id: "gridBackground", label: "격자배경", icon: swFooterAuxIcons.gridBackground, pressed: true },
{ id: "gridSnap", label: "격자스냅", icon: swFooterAuxIcons.gridSnap, pressed: false },
{ id: "objectTrack", label: "객체스냅추적", icon: lucide(<PenLine size={14} />), pressed: true },
]
export const REFERENCE_HISTORY = [
"Raster image support loaded.",
"Command: PSLTSCALE",
"Enter new value for PSLTSCALE <ON>: _ON",
"Command: VSBACKGROUNDS",
"Enter new value for VSBACKGROUNDS <1>: 1",
]
export const LAYOUTS: SWFooterLayout[] = [
{ id: "layout3", label: "Layout 03" },
{ id: "layout2", label: "Layout 02" },
{ id: "layout1", label: "Layout 01" },
]
export const HISTORY = [
"Fillet: [Polyline/Radius/Trim/Settings/Multiple]/<Select first entity>: R",
"Fillet radius <0.0000>: 6",
"Current settings: mode = NOTRIM, radius = 6.0000",
]
/**
* The six frames of guide_바로가기 등록 (26:9096 Variant5 → 6 → 4 → 9 → 8 → 9): the guide "video" of the empty slot,
* cut from the Figma export of the set (each frame's 194 × 111 preview at +6, +6).
*/
const GUIDE_FRAMES = [[20, 52], [20, 237], [23, 422], [23, 599], [19, 807], [22, 1001]] as const
export function GuideFrame({ index }: { index: number }) {
const [x, y] = GUIDE_FRAMES[index % GUIDE_FRAMES.length]
return <span className="sw-footer-story-guide-frame" style={{ backgroundImage: `url(${guideSheet})`, backgroundPosition: `-${x + 6}px -${y + 6}px` }} aria-hidden="true" />
}
/** Cycles the frames like the guide video (Storybook only; a program passes its own <video>). */
export function GuideMovie({ interval = 900 }: { interval?: number }) {
const [frame, setFrame] = useState(0)
useEffect(() => { const timer = window.setInterval(() => setFrame(value => value + 1), interval); return () => window.clearInterval(timer) }, [interval])
return <GuideFrame index={frame} />
}
/** Pins :hover visuals on descendants matching `hover` (data-sb-hover, mirrored by .storybook/pseudo-states.ts). */
export function Pin({ hover, children }: { hover?: string; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (!hover) return
const nodes = Array.from(ref.current?.querySelectorAll(hover) ?? [])
nodes.forEach(node => node.setAttribute("data-sb-hover", ""))
return () => nodes.forEach(node => node.removeAttribute("data-sb-hover"))
})
return <div ref={ref} className="sw-footer-story-pin">{children}</div>
}
/** Footer demo state shared by the interactive Stories. */
export function useFooterDemo({ aux: initialAux = AUX_ITEMS, layouts: initialLayouts = LAYOUTS, history: initialHistory = HISTORY } = {}) {
const [categoryId, setCategoryId] = useState("shape")
const [activeToolId, setActiveToolId] = useState<string | null>(null)
const [aux, setAux] = useState(initialAux)
const [layouts, setLayouts] = useState(initialLayouts)
const [space, setSpace] = useState("model")
const [history, setHistory] = useState(initialHistory)
const [log, setLog] = useState<string[]>([])
const note = (line: string) => setLog(lines => [...lines.slice(-5), line])
return {
categoryId, activeToolId, aux, layouts, space, history, log,
onCategoryChange: (id: string) => { setCategoryId(id); setActiveToolId(null); note(`category:${id}`) },
onToolSelect: (id: string) => {
setActiveToolId(current => (current === id ? null : id))
setHistory(lines => [...lines, `Command: ${id.toUpperCase()}`])
note(`tool:${id}`)
},
onSlotAdd: (index: number, category: string) => note(`slotAdd:${category}#${index}`),
onCategoryAdd: () => note("categoryAdd"),
onAuxPressedChange: (id: string, pressed: boolean) => { setAux(items => items.map(item => (item.id === id ? { ...item, pressed } : item))); note(`aux:${id}=${pressed}`) },
onSpaceChange: (value: string) => { setSpace(value); note(`space:${value}`) },
onLayoutAdd: () => {
setLayouts(items => {
const next = Math.max(0, ...items.map(item => Number(item.id.replace("layout", "")) || 0)) + 1
return [{ id: `layout${next}`, label: `Layout ${String(next).padStart(2, "0")}` }, ...items]
})
note("layoutAdd")
},
onLayoutRemove: (id: string) => { setLayouts(items => items.filter(item => item.id !== id)); setSpace(value => (value === id ? "model" : value)); note(`layoutRemove:${id}`) },
onLayoutRename: (id: string, label: string) => { setLayouts(items => items.map(item => (item.id === id ? { ...item, label } : item))); note(`layoutRename:${id}=${label}`) },
onCommand: (command: string) => { if (command.trim()) setHistory(lines => [...lines, `Command: ${command}`]); note(`command:${command}`) },
onSide: (name: string) => () => note(name),
}
}
export function Log({ lines }: { lines: string[] }) {
return <p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {lines.length ? lines.join(" → ") : "없음"}</p>
}
+37
View File
@@ -0,0 +1,37 @@
/* Story-owned layout for SW/Footer. Like a consuming program it arranges public components; it never styles
hm-sw-* classes. Surfaces behind standalone parts read the public --hm-sw-footer-* tokens. */
.sw-footer-story-screen { position: relative; display: flex; flex-direction: column; background: #060606; }
.sw-footer-story-canvas { position: relative; background: var(--sw-story-canvas); }
.sw-footer-story-workspace { display: flex; flex-direction: column; height: 720px; background: #060606; }
.sw-footer-story-canvas--fill { flex: 1 1 auto; min-height: 0; }
.sw-footer-story-reference { display: block; width: 1915px; max-width: none; }
.sw-footer-story-figma-bottom { width: 1920px; height: 120px; background: 0 -960px / 1920px 1080px no-repeat; }
.sw-footer-story-nodes { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px; }
.sw-footer-story-node { margin: 0; padding: 12px; border: 1px solid #dedbd2; border-radius: 6px; background: #fff; }
.sw-footer-story-node figcaption { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; font-size: 12px; color: #3d4a45; }
.sw-footer-story-node figcaption code { font-size: 11px; color: #6e6759; }
.sw-footer-story-node figcaption small { color: #6e6759; }
.sw-footer-story-node__pair { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
.sw-footer-story-node__pair > div { min-width: 0; }
.sw-footer-story-node__pair img { display: block; max-width: 100%; }
.sw-footer-story-node__label { margin: 0 0 4px; font-size: 11px; color: #6e6759; }
.sw-footer-story-node__live { display: inline-block; padding: 20px; border-radius: 4px; background: #fff; }
.sw-footer-story-node__wide { width: min(1920px, calc(100vw - 160px)); overflow-x: auto; padding-top: 24px; background: #060606; }
.sw-footer-story-node[data-node-id="26:10097"] .sw-footer-story-node__pair { flex-direction: column; }
.sw-footer-story-node[data-node-id="26:10097"] img { width: min(1920px, calc(100vw - 160px)); }
.sw-footer-story-column { display: flex; flex-direction: column; align-items: flex-start; gap: 20px; }
.sw-footer-story-pin { display: contents; }
.sw-footer-story-group-surface { padding: 6px; border-radius: 4px; background: var(--hm-sw-footer-group-background); }
.sw-footer-story-guide-room { gap: 160px; padding: 170px 80px 6px; }
.sw-footer-story-guide-frame { display: block; width: 194px; height: 111px; background-repeat: no-repeat; background-size: 250px 1179px; }
.sw-footer-story-guide-text { display: grid; place-items: center; height: 100%; padding: 8px; font-size: 12px; color: #d4cbbd; text-align: center; }
.sw-footer-story-states { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px; }
.sw-footer-story-state { margin: 0; }
.sw-footer-story-state figcaption { margin-bottom: 6px; font-size: 12px; color: #3d4a45; }
.sw-footer-story-lucide { display: grid; place-items: center; color: #d4cbbd; }
.sw-footer-story-status { padding: 0 12px; font-size: 12px; color: #d4cbbd; font-family: ui-monospace, monospace; }
.sw-footer-story-row { display: flex; align-items: flex-start; gap: 16px; }
.sw-footer-story-row--tight { gap: 0; }
.sw-footer-story-inline { display: inline-flex; flex-direction: column; padding: 0; }
.sw-footer-story-width + .sw-footer-story-width { margin-top: 16px; }
.sw-footer-story-reference-narrow { display: block; width: 952px; max-width: 100%; }
+484
View File
@@ -0,0 +1,484 @@
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
SWFooter, SWFooterAddSlot, SWFooterAuxButton, SWFooterAuxGroup, SWFooterCommandLine, SWFooterGuide, SWFooterLayoutTabs, SWFooterMenu,
SWFooterSideButton, SWFooterToolButton, SWHeader, SWTheme, SW_FOOTER_MODEL, swFooterToolIcons,
type SWFooterAuxItem, type SWFooterCategory, type SWFooterLayout, type SWFooterTool,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { Crosshair, Grid3x3, Magnet, Move, RotateCw, Scissors, Spline, Square } from "lucide-react"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import {
AUX_ITEMS, CATEGORIES, DRAW_TOOLS, GuideFrame, GuideMovie, HISTORY, LAYOUTS, Log, Pin, REFERENCE_AUX, REFERENCE_CATEGORIES, REFERENCE_HISTORY,
useFooterDemo,
} from "./sw-footer-fixture"
import referenceScreen from "./assets/footer-reference/composition-2026-10-20.png"
import referenceNarrow from "./assets/footer-reference/narrow-2026-10-20.png"
import "./sw-stories.css"
import "./sw-footer-stories.css"
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
// SW/Footer (2026-10-20): Figma 이지빔-SW 26:10097 bottom bar and its 34 component sets as @hm-design/sw components.
// Stories use the public API only, like a consuming program. No CAD engine: commands are logged.
const KICKER = "HM DESIGN SYSTEM / SW"
const ref = (id: string) => new URL(`./assets/figma-reference/footer/${id}.png`, import.meta.url).href
const meta = {
title: "SW/Footer",
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
/** A program screen bottom: drawing area above the footer (the layout tabs sit on its lower edge). */
function Screen({ width = 1920, canvas = 160, children }: { width?: number | string; canvas?: number; children: ReactNode }) {
return (
<div className="sw-story-scroller">
<div className="sw-footer-story-screen" style={{ width }}>
<div className="sw-footer-story-canvas" style={{ height: canvas }} aria-label="도면 영역 (예시)" />
{children}
</div>
</div>
)
}
function FigmaFooter() {
return (
<SWFooter categories={CATEGORIES} auxItems={AUX_ITEMS} onCategoryAdd={() => {}}
layoutTabs={<SWFooterLayoutTabs layouts={[{ id: "layout1", label: "Layout 01" }]} value={SW_FOOTER_MODEL} modelLabel={false} />} />
)
}
// ---------- 1. 기본 Footer ----------
export const Default: Story = {
name: "기본 Footer",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Footer" description={<>Figma <code>레이아웃 분리</code> 26:10097의 하단 72px 영역. 기본 데이터(도형 9개 명령, 10개 분류, 보조기능 6개, Layout 01)만 넣은 <code>&lt;SWFooter /&gt;</code>입니다. 아래는 같은 폭의 Figma 렌더입니다.</>} />
<StorySection title="구현 (1920px)" description="Hover: 명령·분류·빈 칸(+, 가이드)·Layout 탭. 클릭 동작은 Callback이 없으므로 아무 것도 실행하지 않습니다.">
<Screen><FigmaFooter /></Screen>
</StorySection>
<StorySection title="Figma 26:10097 (하단 120px)">
<div className="sw-story-scroller"><div className="sw-footer-story-figma-bottom" style={{ backgroundImage: `url(${ref("26-10097")})` }} role="img" aria-label="Figma 26:10097 하단" /></div>
</StorySection>
</StoryPage>
),
}
// ---------- 2. 전체 Footer · 실제 조합 ----------
const LAYOUTS_2: SWFooterLayout[] = [{ id: "layout2", label: "Layout 02" }, { id: "layout1", label: "Layout 01" }]
/** The reference screen (2026-10-20): SWHeader, drawing area, footer with the command line inside the bar. */
function Workspace({ width = "100%" }: { width?: number | string }) {
const demo = useFooterDemo({ aux: REFERENCE_AUX, layouts: LAYOUTS_2, history: REFERENCE_HISTORY })
const [commandOpen, setCommandOpen] = useState(false)
return (
<>
<Log lines={demo.log} />
<div className="sw-story-scroller">
<div className="sw-footer-story-workspace" style={{ width }}>
<SWHeader documentTitle="Drawing1.grm" logoSrc={placeholderLogo} logoAlt="예제 SW" />
<div className="sw-footer-story-canvas sw-footer-story-canvas--fill" aria-label="도면 영역 (예시)" />
<SWFooter categories={REFERENCE_CATEGORIES} categoryId={demo.categoryId} onCategoryChange={demo.onCategoryChange}
status="7.4177, -0.2807, 0.0000"
command={<SWFooterCommandLine history={demo.history} onSubmit={demo.onCommand} expanded={commandOpen} onExpandedChange={setCommandOpen} />}
activeToolId={demo.activeToolId} onToolSelect={demo.onToolSelect} onSlotAdd={demo.onSlotAdd} onCategoryAdd={demo.onCategoryAdd}
slotGuide={{ media: <GuideMovie /> }}
auxItems={demo.aux} onAuxPressedChange={demo.onAuxPressedChange}
auxAll={{ onClick: demo.onSide("auxAll") }} commandAll={{ onClick: () => { setCommandOpen(open => !open); demo.onSide("commandAll")() }, pressed: commandOpen }}
layoutTabs={<SWFooterLayoutTabs layouts={demo.layouts} value={demo.space} onValueChange={demo.onSpaceChange}
onAdd={demo.onLayoutAdd} onRemove={demo.onLayoutRemove} onRename={demo.onLayoutRename} />} />
</div>
</div>
</>
)
}
export const FullFooter: Story = {
name: "전체 Footer · 실제 조합",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="전체 Footer · 실제 Footer 조합" description="사용자 제공 실제 화면(2026-10-20) 구성: SWHeader + 도면 영역 + SWFooter(좌표 status, 바 안의 명령창 command, 오른쪽 명령 칸, Layout 탭). 모든 상태는 호스트(Story)가 Props로 소유하고, 컴포넌트는 Callback만 보냅니다." />
<StorySection title="동작" description="명령 클릭 = 선택(다시 클릭 해제)·명령 기록 추가, 분류 탭(←/→), 빈 칸 + 클릭·Hover 가이드(영상 대신 Figma 6프레임 순환), 보조기능 토글(켜짐 = 주황 테두리·글자), 명령어 전체보기·▲ = 명령창 위로 펼치기, Layout 탭 선택·+·×·더블클릭/F2 이름 변경·Delete, 명령창 Enter 실행.">
<Workspace width={1920} />
</StorySection>
<StorySection title="참고: 실제 화면 (사용자 제공, 1915 × 1030)" description="Model 탭: 실제 화면은 선택 탭이 주황이지만 Figma model On(26:9925)은 갈색이므로 Figma 값을 유지합니다. 뷰 큐브(평면도)와 도면 왼쪽 위 표시는 Footer 범위 밖입니다.">
<div className="sw-story-scroller"><img className="sw-footer-story-reference" src={referenceScreen} alt="실제 SW 화면 참고 (Footer 조합)" /></div>
</StorySection>
</StoryPage>
),
}
// ---------- 3. 도구 그룹별 표시 ----------
export const ToolGroups: Story = {
name: "도구 그룹별 표시",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="도구 그룹별 표시" description="분류(categoryId)마다 다른 명령. 명령 뒤의 칸은 빈 칸(+)이고, 마지막 명령 뒤부터 bg_vacant(어두운 면)입니다. 1440px." />
{CATEGORIES.slice(0, 3).map(category => (
<StorySection key={category.id} title={`${category.label} — 명령 ${category.tools.length}개`}>
<Screen width={1440} canvas={40}><SWFooter categories={CATEGORIES} categoryId={category.id} auxItems={AUX_ITEMS} onSlotAdd={() => {}} /></Screen>
</StorySection>
))}
<StorySection title="보조기능 없음 · 측면 버튼 없음" description="auxItems 생략, auxAll={false}, commandAll={false}: 명령 영역만.">
<Screen width={1440} canvas={40}><SWFooter categories={CATEGORIES} auxAll={false} commandAll={false} /></Screen>
</StorySection>
</StoryPage>
),
}
// ---------- 4. Figma 디자인별 Variant (35 노드) ----------
function Node({ id, name, impl, states, children }: { id: string; name: string; impl: string; states: string; children: ReactNode }) {
return (
<figure className="sw-footer-story-node" data-node-id={id}>
<figcaption>
<strong>{id}</strong> <span>{name}</span>
<code>{impl}</code>
<small>{states}</small>
</figcaption>
<div className="sw-footer-story-node__pair">
<div><p className="sw-footer-story-node__label">Figma</p><img src={ref(id.replace(":", "-"))} alt={`Figma ${id} ${name}`} /></div>
<div><p className="sw-footer-story-node__label">구현</p><SWTheme className="sw-footer-story-node__live">{children}</SWTheme></div>
</div>
</figure>
)
}
const one = (layout: SWFooterLayout): SWFooterLayout[] => [layout]
const noop = () => {}
function TabSpecimens({ layout, editing = true }: { layout: SWFooterLayout; editing?: boolean }) {
const tabs = (value: string, edit?: boolean) => (
<SWFooterLayoutTabs layouts={one(layout)} value={value} modelLabel={false} onRemove={noop} onRename={noop} editingId={edit ? layout.id : null} />
)
return (
<div className="sw-footer-story-column">
{tabs("x")}
<Pin hover=".hm-sw-footer-tab">{tabs("x")}</Pin>
{tabs(layout.id)}
<Pin hover=".hm-sw-footer-tab">{tabs(layout.id)}</Pin>
{editing && tabs("x", true)}
{editing && <Pin hover=".hm-sw-footer-tab">{tabs("x", true)}</Pin>}
</div>
)
}
function ToolSpecimen({ tool }: { tool: SWFooterTool }) {
return (
<div className="sw-footer-story-column sw-footer-story-group-surface">
<SWFooterToolButton {...toolProps(tool)} />
<Pin hover=".hm-sw-footer-tool"><SWFooterToolButton {...toolProps(tool)} /></Pin>
<SWFooterToolButton {...toolProps(tool)} pressed />
</div>
)
}
function MenuSpecimen({ label }: { label: string }) {
const items = [{ id: "a", label }]
return (
<div className="sw-footer-story-column sw-footer-story-group-surface">
<SWFooterMenu items={items} value="" />
<Pin hover=".hm-sw-footer-menu__tab"><SWFooterMenu items={items} value="" /></Pin>
<SWFooterMenu items={items} value="a" />
</div>
)
}
const NCOPY = CATEGORIES[1].tools[0] as SWFooterTool
const TOOL_NODES: [string, SWFooterTool][] = [
["26:9529", DRAW_TOOLS[0]], ["26:9551", DRAW_TOOLS[1]], ["26:9576", DRAW_TOOLS[2]], ["26:9601", DRAW_TOOLS[3]], ["26:9629", DRAW_TOOLS[4]],
["26:9651", DRAW_TOOLS[5]], ["26:9673", DRAW_TOOLS[6]], ["26:9689", DRAW_TOOLS[7]], ["26:9702", DRAW_TOOLS[8]], ["26:9739", NCOPY],
]
const MENU_NODES: [string, string][] = [
["26:9459", "도형"], ["26:9466", "수정"], ["26:9473", "편집"], ["26:9480", "문자"], ["26:9487", "블록"],
["26:9494", "레이어"], ["26:9501", "뷰"], ["26:9508", "선택/조회"], ["26:9515", "도로/횡단"], ["26:9522", "구조/배근"],
]
const toolProps = (tool: SWFooterTool) => ({ label: tool.label, icon: tool.icon, activeIcon: tool.activeIcon, "aria-label": tool.ariaLabel })
const AUX0 = { label: AUX_ITEMS[0].label, icon: AUX_ITEMS[0].icon }
const SMALL_COMMAND = { width: 120, ["--hm-sw-footer-command-expanded-height" as string]: "71px" }
export const FigmaVariants: Story = {
name: "Figma 디자인별 Variant (35 노드)",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="Figma 디자인별 Variant" description={<>요청된 35개 노드 전체. 왼쪽 = Figma 렌더(get_screenshot), 오른쪽 = 같은 순서의 구현 상태(Hover는 Story 전용 <code>data-sb-hover</code>로 고정). 노드 → 컴포넌트·Props 매핑은 <code>docs/sw/sw-footer-figma-mapping-2026-10-20.md</code>.</>} />
<StorySection title="Footer 전체" description="26:10097 레이아웃 분리 (1920 × 1080 중 하단 Footer)">
<Node id="26:10097" name="레이아웃 분리" impl="<SWFooter />" states="하단 72px + Layout 01 탭">
<div className="sw-footer-story-node__wide"><FigmaFooter /></div>
</Node>
</StorySection>
<StorySection title="명령 (도구 버튼)" description="Default / Variant2(Hover) / Variant3(Selected). Figma에서 Variant2와 Variant3는 같은 모양입니다.">
<div className="sw-footer-story-nodes">
{TOOL_NODES.map(([id, tool]) => (
<Node key={id} id={id} name={tool.id === "ncopy" ? "Ncopy" : tool.id} impl={`SWFooterToolButton (SWFooterTool "${tool.label}")`} states="Default · Hover · pressed"><ToolSpecimen tool={tool} /></Node>
))}
</div>
</StorySection>
<StorySection title="분류 탭" description="Default(#D4CBBD) / Variant2(Hover) / Variant3(현재 분류, 열린 Pocket).">
<div className="sw-footer-story-nodes">
{MENU_NODES.map(([id, label]) => (
<Node key={id} id={id} name={`text_${label.replace("/", "")}`} impl={`SWFooterMenu (item "${label}")`} states="Default · Hover · 선택"><MenuSpecimen label={label} /></Node>
))}
</div>
</StorySection>
<StorySection title="빈 칸 · 가이드">
<div className="sw-footer-story-nodes">
<Node id="26:9971" name="btn_add_menu" impl="SWFooterAddSlot" states="Default · Hover">
<div className="sw-footer-story-column sw-footer-story-group-surface">
<SWFooterAddSlot guide={false} />
<Pin hover=".hm-sw-footer-add"><SWFooterAddSlot guide={false} /></Pin>
</div>
</Node>
<Node id="26:9242" name="가이드 영상 보기" impl="SWFooterAddSlot guide (SWFooter slotGuide)" states="Default · Hover(가이드 표시)">
<div className="sw-footer-story-row sw-footer-story-group-surface sw-footer-story-guide-room">
<SWFooterAddSlot guide={{ media: <GuideFrame index={0} /> }} />
<SWFooterAddSlot guide={{ media: <GuideFrame index={0} /> }} guideOpen />
</div>
</Node>
<Node id="26:9096" name="guide_바로가기 등록" impl="SWFooterGuide media" states="Variant5 · 6 · 4 · 9 · 8 · 9 (가이드 영상 프레임)">
<div className="sw-footer-story-column">
{[0, 1, 2, 3, 4, 5].map(index => <SWFooterGuide key={index} data-standalone="" media={<GuideFrame index={index} />} />)}
</div>
</Node>
</div>
</StorySection>
<StorySection title="Layout 탭" description="Default · Hover · On · On hover · 이름 편집(Variant5) · 이름 편집 + Hover(Variant6).">
<div className="sw-footer-story-nodes">
<Node id="26:9794" name="layout01" impl='SWFooterLayoutTabs (마지막 탭, shape "slant")' states="6 상태"><TabSpecimens layout={{ id: "l1", label: "Layout 01" }} /></Node>
<Node id="26:9837" name="layout02" impl='SWFooterLayoutTabs (shape "rounded")' states="6 상태"><TabSpecimens layout={{ id: "l2", label: "Layout 02", shape: "rounded" }} /></Node>
<Node id="26:9885" name="layout03" impl='SWFooterLayoutTabs (shape "rounded")' states="Default · Hover · On · On hover"><TabSpecimens layout={{ id: "l3", label: "Layout 03", shape: "rounded" }} editing={false} /></Node>
<Node id="26:9914" name="model" impl="SWFooterLayoutTabs modelLabel" states="Default · Hover · On">
<div className="sw-footer-story-column">
<SWFooterLayoutTabs layouts={[]} value="x" />
<Pin hover=".hm-sw-footer-tab--model"><SWFooterLayoutTabs layouts={[]} value="x" /></Pin>
<SWFooterLayoutTabs layouts={[]} value={SW_FOOTER_MODEL} />
</div>
</Node>
<Node id="26:9776" name="btn_add_layout" impl="SWFooterLayoutTabs onAdd" states="Default · Hover">
<div className="sw-footer-story-column">
<SWFooterLayoutTabs layouts={[]} value="x" modelLabel={false} onAdd={noop} />
<Pin hover=".hm-sw-footer-tabs__add"><SWFooterLayoutTabs layouts={[]} value="x" modelLabel={false} onAdd={noop} /></Pin>
</div>
</Node>
<Node id="26:9785" name="btn_delete" impl='SWFooterLayoutTabs onRemove (shape "rounded")' states="Default · Hover">
<div className="sw-footer-story-column">
<Pin hover=".hm-sw-footer-tab"><SWFooterLayoutTabs layouts={[{ id: "a", label: "Layout 02", shape: "rounded" }]} value="x" modelLabel={false} onRemove={noop} /></Pin>
<Pin hover=".hm-sw-footer-tab, .hm-sw-footer-tab__remove"><SWFooterLayoutTabs layouts={[{ id: "a", label: "Layout 02", shape: "rounded" }]} value="x" modelLabel={false} onRemove={noop} /></Pin>
</div>
</Node>
<Node id="26:9931" name="btn_close2" impl='SWFooterLayoutTabs onRemove (shape "slant")' states="Default · Hover">
<div className="sw-footer-story-column">
<Pin hover=".hm-sw-footer-tab"><SWFooterLayoutTabs layouts={[{ id: "a", label: "Layout 01" }]} value="a" modelLabel={false} onRemove={noop} /></Pin>
<Pin hover=".hm-sw-footer-tab, .hm-sw-footer-tab__remove"><SWFooterLayoutTabs layouts={[{ id: "a", label: "Layout 01" }]} value="a" modelLabel={false} onRemove={noop} /></Pin>
</div>
</Node>
<Node id="26:9942" name="예시) 탭 상태변화" impl="SWFooterLayoutTabs value" states="Model · Layout 01 · 02 · 03 선택">
<div className="sw-footer-story-column">
{[SW_FOOTER_MODEL, "layout1", "layout2", "layout3"].map(value => <SWFooterLayoutTabs key={value} layouts={LAYOUTS} value={value} onAdd={noop} />)}
</div>
</Node>
</div>
</StorySection>
<StorySection title="명령창">
<div className="sw-footer-story-nodes">
<Node id="26:10023" name="ico_arrow" impl="SWFooterCommandLine 펼치기 버튼" states="Default · Hover · On(펼침) · On + Hover">
<div className="sw-footer-story-column">
<SWFooterCommandLine history={[]} style={SMALL_COMMAND} />
<Pin hover=".hm-sw-footer-command__toggle"><SWFooterCommandLine history={[]} style={SMALL_COMMAND} /></Pin>
<SWFooterCommandLine history={[]} expanded style={SMALL_COMMAND} />
<Pin hover=".hm-sw-footer-command__toggle"><SWFooterCommandLine history={[]} expanded style={SMALL_COMMAND} /></Pin>
</div>
</Node>
<Node id="26:10048" name="command (접힘)" impl="SWFooterCommandLine" states="71px, 최근 3줄"><SWFooterCommandLine history={HISTORY} value="TEXT" /></Node>
<Node id="26:10056" name="command (펼침)" impl="SWFooterCommandLine expanded" states="270px"><SWFooterCommandLine history={HISTORY} value="TEXT" expanded /></Node>
</div>
</StorySection>
</StoryPage>
),
}
// ---------- 5. Hover / Active / Selected / Disabled ----------
function StateCell({ title, children }: { title: string; children: ReactNode }) {
return <figure className="sw-footer-story-state"><figcaption>{title}</figcaption><SWTheme className="sw-footer-story-group-surface">{children}</SWTheme></figure>
}
export const States: Story = {
name: "Hover / Active / Selected / Disabled",
render: () => {
const line = DRAW_TOOLS[0]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="상태" description="Hover·Selected는 Figma 값. Active(누르는 중)와 Disabled는 Figma에 없어 기존 SW 규칙(누름 = Hover와 같음, Disabled = 40%)을 따릅니다. Focus는 SW 공통 2px #F0F7F5." />
<StorySection title="명령 버튼">
<div className="sw-footer-story-states">
<StateCell title="Default"><SWFooterToolButton {...toolProps(line)} /></StateCell>
<StateCell title="Hover"><Pin hover=".hm-sw-footer-tool"><SWFooterToolButton {...toolProps(line)} /></Pin></StateCell>
<StateCell title="Active (누름 = Hover)"><Pin hover=".hm-sw-footer-tool"><SWFooterToolButton {...toolProps(line)} /></Pin></StateCell>
<StateCell title="Selected (pressed)"><SWFooterToolButton {...toolProps(line)} pressed /></StateCell>
<StateCell title="Disabled"><SWFooterToolButton {...toolProps(line)} disabled /></StateCell>
</div>
</StorySection>
<StorySection title="빈 칸 · 분류 탭">
<div className="sw-footer-story-states">
<StateCell title="빈 칸 Default"><SWFooterAddSlot guide={false} /></StateCell>
<StateCell title="빈 칸 Hover"><Pin hover=".hm-sw-footer-add"><SWFooterAddSlot guide={false} /></Pin></StateCell>
<StateCell title="빈 칸 Disabled"><SWFooterAddSlot guide={false} disabled /></StateCell>
<StateCell title="분류 Default · Hover · Selected · Disabled">
<Pin hover="#sw-footer-state-b">
<SWFooterMenu items={[{ id: "a", label: "도형" }, { id: "b", label: "수정" }, { id: "c", label: "편집" }, { id: "d", label: "문자", disabled: true }]} value="a" tabIdPrefix="sw-footer-state" />
</Pin>
</StateCell>
</div>
<p className="sw-story-log">분류 Hover 고정: 두 번째 탭(수정)에 data-sb-hover.</p>
</StorySection>
<StorySection title="보조기능 · 측면 버튼 (Figma Default만 있음)" description="Hover·pressed는 Footer의 #FF5C00 글자 규칙을 재사용(Figma 미정의).">
<div className="sw-footer-story-states">
<StateCell title="보조 Default"><SWFooterAuxButton {...AUX0} /></StateCell>
<StateCell title="보조 Hover"><Pin hover=".hm-sw-footer-aux__button"><SWFooterAuxButton {...AUX0} /></Pin></StateCell>
<StateCell title="보조 pressed"><SWFooterAuxButton {...AUX0} pressed /></StateCell>
<StateCell title="보조 Disabled"><SWFooterAuxButton {...AUX0} disabled /></StateCell>
<StateCell title="측면 Default"><SWFooterSideButton title="보조기능" subtitle="전체보기" /></StateCell>
<StateCell title="측면 Hover"><Pin hover=".hm-sw-footer-side"><SWFooterSideButton title="명령어" subtitle="전체보기" side="end" /></Pin></StateCell>
<StateCell title="측면 Disabled"><SWFooterSideButton title="명령어" subtitle="전체보기" side="end" disabled /></StateCell>
</div>
</StorySection>
</StoryPage>
)
},
}
// ---------- 6. 도구 및 메뉴 변경 예제 ----------
const icon = (node: ReactNode) => <span className="sw-footer-story-lucide">{node}</span>
const CUSTOM: SWFooterCategory[] = [
{ id: "survey", label: "측량", tools: [
{ id: "move", label: "Move", icon: icon(<Move size={16} />) },
{ id: "rotate", label: "Rotate", icon: icon(<RotateCw size={16} />) },
{ id: "trim", label: "Trim", icon: icon(<Scissors size={16} />) },
{ id: "spline", label: "Spline", icon: icon(<Spline size={16} />), disabled: true },
] },
{ id: "draw", label: "그리기", tools: DRAW_TOOLS.slice(0, 4) },
{ id: "custom", label: "사용자", tools: [] },
]
const CUSTOM_AUX: SWFooterAuxItem[] = [
{ id: "snap", label: "스냅", icon: icon(<Magnet size={14} />), pressed: true },
{ id: "grid", label: "격자", icon: icon(<Grid3x3 size={14} />), pressed: false },
{ id: "ortho", label: "직교", icon: icon(<Square size={14} />), pressed: false },
{ id: "track", label: "추적", icon: icon(<Crosshair size={14} />) },
]
function CustomFooter() {
const [categories, setCategories] = useState(CUSTOM)
const [categoryId, setCategoryId] = useState("survey")
const [active, setActive] = useState<string | null>("move")
const [aux, setAux] = useState(CUSTOM_AUX)
const [log, setLog] = useState<string[]>([])
const note = (line: string) => setLog(lines => [...lines.slice(-5), line])
return (
<>
<Log lines={log} />
<Screen width={1440} canvas={60}>
<SWFooter categories={categories} categoryId={categoryId} onCategoryChange={setCategoryId} slotCount={10}
activeToolId={active} onToolSelect={id => { setActive(id); note(`tool:${id}`) }}
onSlotAdd={(index, category) => {
setCategories(list => list.map(item => (item.id === category ? { ...item, tools: [...item.tools, { id: `pt${index}`, label: `Pt ${index + 1}`, ...swFooterToolIcons.point }] } : item)))
note(`slotAdd:${category}#${index}`)
}}
slotGuide={{ title: "명령 추가", caption: "클릭", media: <span className="sw-footer-story-guide-text">빈 칸을 눌러 Point 명령 추가</span> }}
onCategoryAdd={() => { setCategories(list => [...list, { id: `c${list.length}`, label: `분류 ${list.length + 1}`, tools: [] }]); note("categoryAdd") }}
auxItems={aux} onAuxPressedChange={(id, pressed) => setAux(items => items.map(item => (item.id === id ? { ...item, pressed } : item)))}
onAuxItemClick={id => note(`aux:${id}`)}
auxAll={{ title: "설정", subtitle: "열기", onClick: () => note("auxAll") }} commandAll={{ title: "명령", subtitle: "목록", onClick: () => note("commandAll") }}
trailing={<span className="sw-footer-story-status">X 1024.50 · Y 512.25</span>}
layoutTabs={<SWFooterLayoutTabs layouts={[{ id: "plan", label: "평면도", locked: true }, { id: "section", label: "종단면" }]} value="plan" modelLabel="모델" />} />
</Screen>
</>
)
}
export const CustomContent: Story = {
name: "도구 및 메뉴 변경 예제",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="도구 및 메뉴 변경" description="공통 디자인(크기·배경·상태)은 그대로, 내용만 데이터로 변경: 분류·명령 텍스트·아이콘(lucide), slotCount 10, 빈 칸 클릭으로 명령 추가, 분류 추가, 보조기능 4개(columns 3 → 2행), 측면 버튼 문구, trailing 좌표, Layout 이름·잠금·Model 문구." />
<StorySection title="사용자 구성"><CustomFooter /></StorySection>
</StoryPage>
),
}
// ---------- 7. 화면 폭 ----------
function ReferenceFooter() {
const [open, setOpen] = useState(false)
const [aux, setAux] = useState(REFERENCE_AUX)
const [active, setActive] = useState<string | null>(null)
return (
<SWFooter categories={REFERENCE_CATEGORIES} auxItems={aux} onAuxPressedChange={(id, pressed) => setAux(items => items.map(item => (item.id === id ? { ...item, pressed } : item)))}
activeToolId={active} onToolSelect={id => setActive(id)} onSlotAdd={noop} onCategoryAdd={noop} status="10.5939, 6.0728, 0.0000"
command={<SWFooterCommandLine history={REFERENCE_HISTORY} expanded={open} onExpandedChange={setOpen} />}
layoutTabs={<SWFooterLayoutTabs layouts={LAYOUTS_2} value={SW_FOOTER_MODEL} onAdd={noop} />} />
)
}
export const Responsive: Story = {
name: "화면 폭 변화",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="화면 폭 변화" description="Figma에는 좁은 폭 정의가 없어 사용자 제공 실제 화면(2026-10-20)을 따릅니다: 빈 칸(+)이 먼저 줄고, 그래도 넘치는 명령은 마지막 칸의 ▲(ico_arrow)로 칸 위 패널에 펼침(▼ = 열림, Escape·바깥 클릭 닫힘). 분류 탭이 넘치면 ◀ ▶로 스크롤. 바 폭 1279px 이하에서 보조기능은 아이콘만(45 × 33). 바 안 명령창은 최소 280px." />
<StorySection title="실제 조합 (바 안 명령창, command)" description="1920 → 12칸(명령 10 + 빈 칸 2), 1440 · 1280 → 빈 칸 축소, 950 → 명령 4개 + ▲, 보조기능 아이콘만.">
{[1920, 1440, 1280, 950].map(width => (
<div key={width} className="sw-footer-story-width">
<p className="sw-story-log">{width}px</p>
<Screen width={width} canvas={90}><ReferenceFooter /></Screen>
</div>
))}
</StorySection>
<StorySection title="참고: 실제 좁은 화면 (사용자 제공, 952px)">
<img className="sw-footer-story-reference-narrow" src={referenceNarrow} alt="실제 SW 좁은 화면 참고" />
</StorySection>
<StorySection title="Figma 기본 구성 (명령창 없음)" description="빈 칸이 slotCount(17)까지 채우고 폭에 맞춰 줄어듦. 명령이 넘치면 같은 ▲ 패널.">
{[1920, 1280, 1024, 820].map(width => (
<div key={width} className="sw-footer-story-width">
<p className="sw-story-log">{width}px</p>
<Screen width={width} canvas={60}><SWFooter categories={CATEGORIES} auxItems={AUX_ITEMS} onSlotAdd={noop}
layoutTabs={<SWFooterLayoutTabs layouts={LAYOUTS} value="layout1" onAdd={noop} />} /></Screen>
</div>
))}
</StorySection>
</StoryPage>
),
}
// ---------- 8. 개별 부품 ----------
export const Parts: Story = {
name: "개별 부품 (조합용)",
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="개별 부품" description="SWFooter 없이 부품만 쓸 때는 <SWTheme> 안에 둡니다(SWFooter·SWFooterCommandLine은 루트에 hm-sw-theme이 있어 필요 없음)." />
<StorySection title="SWFooterAuxGroup (columns 3 / 2)">
<SWTheme className="sw-footer-story-row"><SWFooterAuxGroup items={AUX_ITEMS} /><SWFooterAuxGroup items={AUX_ITEMS.slice(0, 4)} columns={2} /></SWTheme>
</StorySection>
<StorySection title="SWFooterMenu + SWFooterToolButton">
<SWTheme className="sw-footer-story-group-surface sw-footer-story-inline">
<div className="sw-footer-story-row sw-footer-story-row--tight">{DRAW_TOOLS.slice(0, 3).map(tool => <SWFooterToolButton key={tool.id} {...toolProps(tool)} />)}<SWFooterAddSlot guide={false} /></div>
<SWFooterMenu items={CATEGORIES.slice(0, 4)} value="modify" onAdd={noop} style={{ width: 4 * 68 + 1 }} />
</SWTheme>
</StorySection>
<StorySection title="SWFooterCommandLine (Controlled 펼침)">
<CommandDemo />
</StorySection>
</StoryPage>
),
}
function CommandDemo() {
const [history, setHistory] = useState(HISTORY)
const [open, setOpen] = useState(false)
return <SWFooterCommandLine history={history} expanded={open} onExpandedChange={setOpen} onSubmit={command => setHistory(lines => [...lines, `Command: ${command}`])} />
}