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>
953 lines
66 KiB
TypeScript
953 lines
66 KiB
TypeScript
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>,
|
||
}
|