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>
|
||||
}
|
||||
Reference in new issue
Block a user