chore(sw): improve interactive header story preview
This commit is contained in:
1 parent
50bf5bda8e
commit
bf16578226
58 files changed
+6028
-15
No files matched your search
@@ -0,0 +1,199 @@
|
||||
import { useId, useLayoutEffect, useRef, useState, type CSSProperties } from "react"
|
||||
import {
|
||||
SWHeader, parseSWHeaderColor, type SWHeaderProps, type SWHeaderProperty, type SWHeaderPropertyGroup,
|
||||
type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderColorMode, type SWHeaderLayer, type SWHeaderLineWeight,
|
||||
} from "@hm-design/sw"
|
||||
import { StoryPage, StoryPageHeader } from "@/components/story-page"
|
||||
|
||||
type Selection = "none" | "all" | "circle" | "expanded"
|
||||
interface OverviewProps {
|
||||
initial: SWHeaderProps
|
||||
fixtures: Required<Pick<SWHeaderProps, "documents" | "layers" | "colors" | "userColors" | "lineTypes" | "lineWeights" | "textStyles" | "dimensionStyles">>
|
||||
propertyGroups: SWHeaderPropertyGroup[]
|
||||
propertyFixture: (selection: Selection) => SWHeaderProperty[]
|
||||
propertySelection: (selection: Selection) => SWHeaderPropertySelection
|
||||
barFixture: SWHeaderProperty[]
|
||||
}
|
||||
|
||||
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
|
||||
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture }: OverviewProps) {
|
||||
const controlId = useId()
|
||||
const slotRef = useRef<HTMLDivElement>(null)
|
||||
const headerRef = useRef<HTMLElement>(null)
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
const [panelLeft, setPanelLeft] = useState(4)
|
||||
const [example, setExample] = useState<Selection>("circle")
|
||||
const getProperties = (selection: Selection) => propertyFixture(selection).map(property => ({
|
||||
...property, value: barFixture.find(item => item.id === property.id)?.value ?? property.value,
|
||||
}))
|
||||
const [properties, setProperties] = useState(() => getProperties("circle"))
|
||||
const [documents, setDocuments] = useState(fixtures.documents)
|
||||
const [documentId, setDocumentId] = useState(fixtures.documents[0]?.id)
|
||||
const [layers, setLayers] = useState(fixtures.layers)
|
||||
const [weights, setWeights] = useState(fixtures.lineWeights)
|
||||
const [weightId, setWeightId] = useState("w-default")
|
||||
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
|
||||
const [textStyle, setTextStyle] = useState(fixtures.textStyles[0])
|
||||
const [dimensionStyle, setDimensionStyle] = useState(fixtures.dimensionStyles[0])
|
||||
const [barOpen, setBarOpen] = useState(true)
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string[]>([])
|
||||
const [readOnly, setReadOnly] = useState(false)
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const [emptyShape, setEmptyShape] = useState(false)
|
||||
const [callbackLog, setCallbackLog] = useState<string[]>([])
|
||||
const counter = useRef(0)
|
||||
const record = (line: string) => { setCallbackLog(lines => [...lines.slice(-5), line]); console.info(`[SWHeader Overview] ${line}`) }
|
||||
const value = (id: string) => properties.find(property => property.id === id)?.value ?? ""
|
||||
const change = (id: string, next: string, event = "onPropertyChange") => {
|
||||
setProperties(items => items.map(property => property.id === id ? {
|
||||
...property, value: next, mixed: false, ...(id === "color" ? { swatch: parseSWHeaderColor(next)?.hex } : {}),
|
||||
} : property))
|
||||
if (id === "color") setColorMode(null)
|
||||
record(`${event}(${id}, ${next})`)
|
||||
}
|
||||
const onPropertyChange = (property: SWHeaderProperty, next: string) => change(property.id, next)
|
||||
const changeExample = (next: Selection) => {
|
||||
setExample(next); setProperties(getProperties(next)); setExpanded(next === "expanded" ? ["common"] : [])
|
||||
record(`selection(${next})`)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const slot = slotRef.current
|
||||
if (!slot) return
|
||||
const measure = () => {
|
||||
const bounds = slot.getBoundingClientRect()
|
||||
const width = Math.max(0, Math.min(bounds.width, bounds.height * 16 / 9))
|
||||
setSize(previous => previous.width === width ? previous : { width, height: width * 9 / 16 })
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(slot)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const header = headerRef.current
|
||||
if (!header) return
|
||||
const measure = () => {
|
||||
const bounds = header.getBoundingClientRect()
|
||||
const trigger = header.querySelector<HTMLElement>("[data-property-panel-trigger]")
|
||||
const anchor = trigger?.getClientRects().length ? trigger : header.querySelector<HTMLElement>(".hm-sw-header__more-trigger")
|
||||
setPanelLeft(Math.max(4, Math.min((anchor?.getBoundingClientRect().left ?? bounds.left) - bounds.left, bounds.width - 318)))
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(header)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const toggleLayer = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => {
|
||||
setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item))
|
||||
record(`layer.${key}(${layer.id}, ${next})`)
|
||||
}
|
||||
const chooseWeight = (weight: SWHeaderLineWeight) => {
|
||||
setWeightId(weight.id)
|
||||
// ByLayer/ByBlock select a mode, not a numeric property value (same policy as usePropertyMenus).
|
||||
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 changeMode = (mode: SWHeaderColorMode) => { setColorMode(mode); record(`onColorModeChange(${mode})`) }
|
||||
const scale = value("scale")
|
||||
const menus: SWHeaderPropertyMenus = {
|
||||
layers, onLayerVisibilityChange: toggleLayer("visible"), onLayerFreezeChange: toggleLayer("frozen"), onLayerLockChange: toggleLayer("locked"),
|
||||
onLayerDetail: () => record("onLayerDetail"), lineTypes: fixtures.lineTypes, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale,
|
||||
onLineWeightSelect: chooseWeight, onLineWeightValueChange: changeWeight,
|
||||
onLineScaleChange: next => change("scale", Number(next).toFixed(4), "onLineScaleChange"), onLineStyleDetail: () => record("onLineStyleDetail"),
|
||||
colors: fixtures.colors, userColors: fixtures.userColors, colorMode, onColorModeChange: changeMode,
|
||||
onColorPick: () => record("onColorPick"), onColorDetail: () => record("onColorDetail"),
|
||||
}
|
||||
const guardedProperties = properties.map(property => ({ ...property, disabled: disabled || property.disabled, readOnly: readOnly || property.readOnly }))
|
||||
const barProperties = guardedProperties.flatMap(property => {
|
||||
const bar = barFixture.find(item => item.id === property.id)
|
||||
return bar ? [{ ...bar, value: property.value, disabled: property.disabled, readOnly: property.readOnly }]
|
||||
: property.id.startsWith("preview-extra-") ? [property] : []
|
||||
})
|
||||
const layer = layers.find(item => item.id === value("layer"))
|
||||
const line = fixtures.lineTypes.find(item => item.id === value("line"))
|
||||
const color = parseSWHeaderColor(value("color"))
|
||||
const weight = weights.find(item => item.id === weightId)
|
||||
|
||||
return <StoryPage className="sw-story-overview">
|
||||
<StoryPageHeader kicker="HM DESIGN SYSTEM / SW" title="SW Header" />
|
||||
<div className="sw-overview-toolbar" aria-label="Storybook 전용 미리보기 설정">
|
||||
<label htmlFor={`${controlId}-selection`}>선택 상태</label>
|
||||
<select id={`${controlId}-selection`} value={example} onChange={event => changeExample(event.target.value as Selection)}>
|
||||
<option value="none">선택 없음</option><option value="all">전체 객체</option><option value="circle">원 선택</option><option value="expanded">전체 펼침</option>
|
||||
</select>
|
||||
<details><summary>시연 데이터</summary><div className="sw-overview-data">
|
||||
<button onClick={() => setProperties(items => items.map(property => barFixture.some(item => item.id === property.id) ? { ...property, value: "12.5" } : property))}>외부 값 12.5</button>
|
||||
<button onClick={() => {
|
||||
const number = ++counter.current
|
||||
setProperties(items => [...items, { id: `preview-extra-${number}`, name: `추가 속성 ${number}`, value: "10", type: "number", groupId: "shape", min: 0 }])
|
||||
}}>필드 추가</button>
|
||||
<button onClick={() => setProperties(items => items.filter(item => !item.id.startsWith("preview-extra-")))}>추가 필드 제거</button>
|
||||
<label><input type="checkbox" checked={readOnly} onChange={event => setReadOnly(event.target.checked)} />속성 ReadOnly</label>
|
||||
<label><input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} />속성 Disabled</label>
|
||||
<label><input type="checkbox" checked={emptyShape} onChange={event => setEmptyShape(event.target.checked)} />형상 Slot 비우기</label>
|
||||
</div></details>
|
||||
</div>
|
||||
<div ref={slotRef} className="sw-overview-slot" data-preview-slot="">
|
||||
<div className="sw-overview-stage" data-preview-stage="" role="region" tabIndex={0} aria-label="SW Header 16:9 미리보기, 작은 화면에서는 내부 스크롤"
|
||||
style={{ ...size, "--sw-preview-panel-left": `${panelLeft}px`, "--sw-preview-panel-top": barOpen ? "77px" : "49px" } as CSSProperties}>
|
||||
<div className="sw-overview-world">
|
||||
<SWHeader ref={headerRef} {...initial}
|
||||
documents={documents} activeDocumentId={documentId} documentTitle={documents.find(document => document.id === documentId)?.name ?? "열린 문서 없음"}
|
||||
onDocumentClick={() => record("onDocumentClick")} onDocumentMenuOpenChange={open => record(`documentOpen(${open})`)}
|
||||
onDocumentSelect={document => { setDocumentId(document.id); record(`onDocumentSelect(${document.id})`) }}
|
||||
onDocumentClose={document => {
|
||||
const next = documents.filter(item => item.id !== document.id); setDocuments(next)
|
||||
if (documentId === document.id) setDocumentId(next[0]?.id)
|
||||
record(`onDocumentClose(${document.id})`)
|
||||
}}
|
||||
onDocumentNew={() => {
|
||||
const id = `preview-document-${++counter.current}`
|
||||
setDocuments(items => [...items, { id, name: `새 문서 ${counter.current}.dwg` }]); setDocumentId(id); record("onDocumentNew")
|
||||
}} onDocumentOpen={() => record("onDocumentOpen")}
|
||||
layers={layers} layer={{ name: layer?.name ?? value("layer") }} activeLayerId={layer?.id}
|
||||
onLayerSelect={item => change("layer", item.id, "onLayerSelect")}
|
||||
onLayerVisibilityChange={menus.onLayerVisibilityChange} onLayerFreezeChange={menus.onLayerFreezeChange} onLayerLockChange={menus.onLayerLockChange}
|
||||
onLayerOpen={() => record("onLayerOpen")} onLayerDetail={menus.onLayerDetail}
|
||||
colors={fixtures.colors} userColors={fixtures.userColors}
|
||||
color={{ value: colorMode === "byLayer" ? "ByLayer" : colorMode === "byBlock" ? "ByBlock" : value("color"), swatch: color?.hex }} colorMode={colorMode}
|
||||
onColorSelect={item => change("color", item.value ?? parseSWHeaderColor(item.color)?.value ?? item.color, "onColorSelect")}
|
||||
onColorInput={next => change("color", next.value, "onColorInput")} onColorModeChange={changeMode}
|
||||
onColorOpen={() => record("onColorOpen")} onColorDetail={menus.onColorDetail} onColorPick={() => record("onColorPick")}
|
||||
lineTypes={fixtures.lineTypes} lineWeights={weights} activeLineTypeId={line?.id} activeLineWeightId={weightId}
|
||||
lineStyle={{ name: line?.name ?? value("line"), weight: weight && weight.value === undefined ? weight.name : value("weight"), scale }}
|
||||
onLineTypeSelect={item => change("line", item.id, "onLineTypeSelect")} onLineWeightSelect={chooseWeight} onLineWeightValueChange={changeWeight}
|
||||
onLineScaleChange={menus.onLineScaleChange} onLineStyleOpen={() => record("onLineStyleOpen")} onLineStyleDetail={menus.onLineStyleDetail}
|
||||
textStyles={fixtures.textStyles} activeTextStyleId={textStyle?.id} textStyle={textStyle}
|
||||
onTextStyleSelect={item => { setTextStyle(item); record(`onTextStyleSelect(${item.id})`) }}
|
||||
onTextStyleOpen={() => record("onTextStyleOpen")} onTextStyleDetail={() => record("onTextStyleDetail")}
|
||||
dimensionStyles={fixtures.dimensionStyles} activeDimensionStyleId={dimensionStyle?.id} dimensionStyle={dimensionStyle}
|
||||
onDimensionStyleSelect={item => { setDimensionStyle(item); record(`onDimensionStyleSelect(${item.id})`) }}
|
||||
onDimensionStyleOpen={() => record("onDimensionStyleOpen")} onDimensionStyleDetail={() => record("onDimensionStyleDetail")}
|
||||
propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }}
|
||||
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }}
|
||||
propertyBar={{ selection: propertySelection(example), properties: barProperties, onPropertyChange, readOnly, disabled }}
|
||||
propertyPanel={{ selection: propertySelection(example), properties: guardedProperties, groups: propertyGroups,
|
||||
expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus,
|
||||
shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"),
|
||||
}}
|
||||
onHomeClick={() => record("onHomeClick")} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
||||
onMinimize={() => record("onMinimize")} onClose={() => record("onClose")} onWindowMoveStart={() => record("onWindowMoveStart")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sw-overview-status">
|
||||
<span data-preview-size="">{Math.round(size.width)} × {Math.round(size.height)} · 16:9 · 내부 스크롤</span>
|
||||
<output data-preview-callback="" aria-live="polite" title={callbackLog.join(" → ")}>{callbackLog.join(" → ") || "Callback 없음"}</output>
|
||||
</div>
|
||||
</StoryPage>
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
import figmaLayerDetailReference from "./assets/figma-reference/btn-detail-layer-26-10767.png"
|
||||
import figmaInputReference from "./assets/figma-reference/input-26-11656.png"
|
||||
@@ -181,21 +182,10 @@ function InteractiveHeader(props: Partial<SWHeaderProps>) {
|
||||
|
||||
export const Overview: Story = {
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={KICKER}
|
||||
title="SW Header"
|
||||
description="Figma node 26:8944(header_green, 1920 × 49)를 @hm-design/sw 독립 패키지로 구현한 헤더입니다. 파일명·객체 수정·속성 입력 면은 사용자 확정 CSS를 우선하며, @hm-design/ui 없이 동작합니다."
|
||||
/>
|
||||
<StorySection title="Figma 기준 1920 × 49" description="홈 / 파일명 / 객체 수정(그리기모드 · 레이어 · 색상 · 선스타일 · 문자스타일 · 치수스타일 · 상세보기) / 인쇄 · 팝업 가이드 · 창 이동 · 최소화 · 종료">
|
||||
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} /></Frame>
|
||||
</StorySection>
|
||||
<StorySection title="선택 상태" description="선택된 텍스트와 화살표는 #FFC600으로만 바뀝니다. 선택 전용 배경·테두리·그림자는 없습니다.">
|
||||
<Frame width={1920}><SWHeader {...figmaArgs} {...callbacks} activeMenu="layer" propertyPanelOpen /></Frame>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
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 = {
|
||||
|
||||
@@ -51,3 +51,26 @@
|
||||
/* Property fields (2026-10-19): Figma node table under the specimens. */
|
||||
.sw-story-node-table { margin-top: 16px; border-collapse: collapse; font-size: 12px; color: #3d4a45; }
|
||||
.sw-story-node-table :is(th, td) { padding: 4px 10px; border-bottom: 1px solid #d5ddd9; text-align: left; }
|
||||
|
||||
/* Overview host layout only. Native controls below are Storybook data controls, outside the product. */
|
||||
.sw-story-overview.sb-page { height: 100dvh; min-height: 0; padding-block: 16px; }
|
||||
.sw-story-overview > .sb-page-inner { height: 100%; display: flex; flex-direction: column; gap: 12px; }
|
||||
.sw-story-overview .sb-page-header { flex: 0 0 auto; padding-bottom: 8px; }
|
||||
.sw-story-overview .sb-page-header .sb-page-kicker { margin-bottom: 4px; }
|
||||
.sw-story-overview .sb-page-header .sb-page-title { margin-block: 0 4px; }
|
||||
.sw-overview-toolbar { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.sw-overview-toolbar select, .sw-overview-data button { font: inherit; padding: 3px 8px; color: inherit; background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-input-default); border-radius: 4px; }
|
||||
.sw-overview-toolbar details { flex-basis: 100%; }
|
||||
.sw-overview-toolbar summary { width: max-content; cursor: pointer; }
|
||||
.sw-overview-data { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding-block: 8px; }
|
||||
.sw-overview-data label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.sw-overview-slot { flex: 1 1 0; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.sw-overview-stage { flex: 0 0 auto; overflow: auto; overscroll-behavior: contain; background: var(--hm-sem-bg-surface); outline: 1px solid var(--hm-sem-stroke-input-default); }
|
||||
.sw-overview-world { min-width: 390px; min-height: 100%; position: relative; }
|
||||
/* Placement-only Storybook exception: keep the original panel size/content in the Stage scroll area.
|
||||
Header's public API deliberately excludes panel style; package CSS and other Stories stay untouched. */
|
||||
.sw-overview-stage .hm-sw-property { position: absolute !important; left: var(--sw-preview-panel-left) !important; top: var(--sw-preview-panel-top) !important; max-height: none !important; }
|
||||
/* The document wrapper otherwise stacks below the independently positioned property Bar. */
|
||||
.sw-overview-stage .hm-sw-header__document:has(.hm-sw-header__document-menu) { z-index: 3; }
|
||||
.sw-overview-status { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; min-width: 0; }
|
||||
.sw-overview-status output { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
Reference in new issue
Block a user