Files
HMDesign/src/sw/sw-header-overview.tsx
T
shkim4 da0e647b49 docs(storybook): simplify sidebar Story pages around the UI
Drop the category kicker from the 83 sidebar Stories (kicker is now optional;
hidden verification Stories keep it) and shorten or remove repeated, obvious
or internal page/section descriptions. Required specs, limits, virtual-data
and undecided-value notices stay. Story IDs, exports, fixtures, shared state
notes and package code are unchanged. Record: docs/reports/storybook-ui-simplification-2026-10-22.md
2026-10-22 16:13:58 +09:00

207 lines
15 KiB
TypeScript

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,
type SWHomeMenuSectionData,
} from "@hm-design/sw"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { showVerificationControls } from "./sw-story-flags"
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[]
/** Object text of the property Bar (2026-10-21: the object type, e.g. 원). Default: the selection title. */
barTitle?: string
/** Logo (home) menu of the SW normal mode: the logo opens and closes it (SWHeader homeMenu). */
homeMenuSections?: readonly SWHomeMenuSectionData[]
}
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture, barTitle, homeMenuSections }: 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 }))
// Bar order = barFixture order (2026-10-21: 반지름 / 지름 / 원주 / 면적), then the preview fields.
const barProperties = [
...barFixture.flatMap(bar => guardedProperties.filter(property => property.id === bar.id)
.map(property => ({ ...bar, value: property.value, disabled: property.disabled, readOnly: property.readOnly }))),
...guardedProperties.filter(property => property.id.startsWith("preview-extra-")),
]
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 title="SW Header" />
{showVerificationControls() && <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), title: (propertySelection(example).kind === "single" && barTitle) || propertySelection(example).title }, 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")}
homeMenu={homeMenuSections && { sections: homeMenuSections, onItemSelect: event => record(`onItemSelect(${event.item.id}, ${event.active})`) }}
onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
onMinimize={() => record("onMinimize")} onClose={() => record("onClose")} onWindowMoveStart={() => record("onWindowMoveStart")}
/>
</div>
</div>
</div>
{/* Callback record for the browser tests only; not shown on the page. */}
<output hidden data-preview-callback="">{callbackLog.join(" → ")}</output>
</StoryPage>
}