feat(sw): implement SW header design system

This commit is contained in:
shkim4 committed 2026-10-16 18:08:53 +09:00
1 parent 9407c8567f
commit fc26afd440
107 files changed
+40063 -1

No files matched your search

@@ -0,0 +1,461 @@
import { forwardRef, useEffect, useLayoutEffect, Fragment, type ReactNode, useId, useRef, useState } from "react"
import { SWHeaderActions } from "./components/SWHeaderActions"
import { SWHeaderColorMenu } from "./components/SWHeaderColorMenu"
import { SWHeaderDocumentTitle } from "./components/SWHeaderDocumentTitle"
import {
SWHeaderColorControl, SWHeaderDimensionStyle, SWHeaderLayerControl,
SWHeaderLineStyle, SWHeaderTextStyle,
} from "./components/SWHeaderFields"
import { SWHeaderHome } from "./components/SWHeaderHome"
import { SWHeaderLayerMenu } from "./components/SWHeaderLayerMenu"
import { SWHeaderLineStyleMenu } from "./components/SWHeaderLineStyleMenu"
import { SWHeaderMore, type SWHeaderMoreItem } from "./components/SWHeaderMore"
import { SWHeaderProperties } from "./components/SWHeaderProperties"
import { SWHeaderPropertyBar } from "./components/SWHeaderPropertyBar"
import { SWHeaderCommitPresence } from "./components/SWHeaderInput"
import { SWHeaderKGuide } from "./components/SWHeaderKGuide"
import printIcon from "../../assets/print.svg"
import printHover from "../../assets/print-hover.svg"
import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps } from "./types"
const defaults = {
layer: { name: "0" },
color: { value: "ByLayer", swatch: "#ffffff" },
lineStyle: { name: "ByLayer", weight: "ByLayer", scale: "1.00" },
textStyle: { font: "Arial", name: "Standard" },
dimensionStyle: { name: "Standard" },
}
/** `data-menu` of each field (its Figma variant name). */
const menuSlot: Record<SWHeaderMenuId, string> = {
layer: "layer", color: "color", lineStyle: "line-style", textStyle: "text-style", dimensionStyle: "dimension-style",
}
const defaultMenuOrder = Object.keys(menuSlot) as SWHeaderMenuId[]
const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ...defaultMenuOrder, "details", "propertyBar", "propertyPanel", "print", "popupGuide"] as const).map(id => ({ id }))
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/**
* Menu order: listed known ids first (duplicates and unknown ids ignored), missing ids after them in
* the default order. Menus only swap places among the slots menus already take; other parts never move.
*/
function orderMenus(items: SWHeaderControlConfig[], menuOrder?: readonly SWHeaderMenuId[]) {
if (!menuOrder) return items
const listed = menuOrder.filter((id, index) => isMenu(id) && menuOrder.indexOf(id) === index)
const rank = [...listed, ...defaultMenuOrder.filter(id => !listed.includes(id))]
const menus = items.filter(item => isMenu(item.id)).sort((a, b) => rank.indexOf(a.id as SWHeaderMenuId) - rank.indexOf(b.id as SWHeaderMenuId))
let next = 0
return items.map(item => (isMenu(item.id) ? menus[next++] : item))
}
/**
* SW Header (Figma node 26:8944, 1920 x 49). Pure presentation: values are props,
* user actions are callbacks, window behavior belongs to the host program.
*/
export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader(
{
documentTitle,
controls,
hiddenControls,
menuOrder,
documents,
documentMenuOpen,
onDocumentMenuOpenChange,
onDocumentSelect,
activeDocumentId,
onDocumentClose,
onDocumentNew,
onDocumentOpen,
documentIcon,
documentNewLabel,
documentOpenLabel,
drawingModeLabel = "그리기모드",
layer = defaults.layer,
layers,
activeLayerId,
layerMenuOpen,
onLayerMenuOpenChange,
onLayerSelect,
onLayerVisibilityChange,
onLayerFreezeChange,
onLayerLockChange,
onLayerDetail,
color = defaults.color,
lineStyle = defaults.lineStyle,
textStyle = defaults.textStyle,
dimensionStyle = defaults.dimensionStyle,
colors,
userColors,
colorMode,
colorMenuOpen,
onColorMenuOpenChange,
onColorSelect,
onColorModeChange,
onColorInput,
onColorPick,
onColorDetail,
lineTypes,
lineWeights,
activeLineTypeId,
activeLineWeightId,
lineStyleMenuOpen,
onLineStyleMenuOpenChange,
onLineTypeSelect,
onLineWeightSelect,
onLineWeightValueChange,
onLineScaleChange,
onLineStyleDetail,
textStyles,
activeTextStyleId,
textStyleMenuOpen,
onTextStyleMenuOpenChange,
onTextStyleSelect,
onTextStyleDetail,
dimensionStyles,
activeDimensionStyleId,
dimensionStyleMenuOpen,
onDimensionStyleMenuOpenChange,
onDimensionStyleSelect,
onDimensionStyleDetail,
activeMenu = null,
propertyBarOpen,
defaultPropertyBarOpen = false,
propertyBar,
onPropertyBarOpenChange,
propertyPanelOpen,
propertyPanel,
onPropertyPanelOpenChange,
disabled = false,
tools,
logo,
logoSrc,
logoAlt,
onHomeClick,
onDocumentClick,
onLayerOpen,
onColorOpen,
onLineStyleOpen,
onTextStyleOpen,
onDimensionStyleOpen,
onPropertyBarToggle,
onPropertyPanelToggle,
onPrint,
onPopupGuide,
onWindowMoveStart,
onMinimize,
onClose,
className,
...rest
},
ref,
) {
// The logo is never in this list: it is always shown. Hidden parts are unmounted (no space, no popup, no callback).
const removed = new Set<string>(hiddenControls ?? [])
const shown = (id: SWHeaderHideableControlId) => !removed.has(id)
const seen = new Set<SWHeaderControlId>()
const configuration = orderMenus((controls ?? defaultControls).filter(item => item.visible !== false && shown(item.id) && !seen.has(item.id) && !!seen.add(item.id)), menuOrder)
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
// Default design with removed object parts: same structure and breakpoints, but the script decides what
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId))
const presentRef = useRef(new Set(configuration.map(item => item.id)))
presentRef.current = new Set(configuration.map(item => item.id))
const label = (id: SWHeaderControlId, fallback: string) => configuration.find(item => item.id === id)?.label ?? fallback
const [internalBarOpen, setInternalBarOpen] = useState(defaultPropertyBarOpen)
const barOpen = !disabled && enabled("propertyBar") && (propertyBarOpen ?? internalBarOpen)
const toggleBar = () => {
if (!enabled("propertyBar") || disabled) return
const next = !barOpen
if (propertyBarOpen === undefined) setInternalBarOpen(next)
onPropertyBarToggle?.()
onPropertyBarOpenChange?.(next)
}
// Uncontrolled open dropdown (one at a time). A controlled `*MenuOpen` prop takes precedence.
const [openMenu, setOpenMenu] = useState<SWHeaderMenuId | null>(null)
// Representative slot (UI state only, never program data): a field chosen in More takes the layer
// field's place while it is in More at the current width (CSS decides, see sw.css "Representative slot").
const [promoted, setPromoted] = useState<SWHeaderMenuId | null>(null)
const focusRef = useRef<SWHeaderMenuId | null>(null)
const fieldsRef = useRef<HTMLDivElement>(null)
const panelAnchorRef = useRef<HTMLDivElement>(null)
const panelId = useId()
const barId = useId()
const [internalPanelOpen, setInternalPanelOpen] = useState(false)
const panelOpen = !disabled && enabled("propertyPanel") && (propertyPanelOpen ?? internalPanelOpen)
const setPanelOpen = (next: boolean) => {
if (propertyPanelOpen === undefined) setInternalPanelOpen(next)
if (next !== panelOpen) onPropertyPanelOpenChange?.(next)
if (next) setOpenMenu(null)
}
const togglePanel = () => { onPropertyPanelToggle?.(); setPanelOpen(!panelOpen) }
const closePanel = () => {
if (propertyPanelOpen !== undefined && !onPropertyPanelOpenChange) onPropertyPanelToggle?.()
setPanelOpen(false)
const header = panelAnchorRef.current?.closest(".hm-sw-header")
const trigger = header?.querySelector<HTMLButtonElement>('[data-property-panel-trigger]')
const more = header?.querySelector<HTMLButtonElement>('.hm-sw-header__more-trigger')
;(trigger?.getClientRects().length ? trigger : more)?.focus()
}
const [headerWidth, setHeaderWidth] = useState(1920)
const [barOffset, setBarOffset] = useState(0)
useLayoutEffect(() => {
const header = fieldsRef.current?.closest(".hm-sw-header")
if (!header) return
const update = () => {
const bounds = header.getBoundingClientRect()
setHeaderWidth(bounds.width)
setBarOffset((panelAnchorRef.current?.getBoundingClientRect().left ?? bounds.left) - bounds.left)
}
const observer = new ResizeObserver(update)
update()
observer.observe(header)
if (panelAnchorRef.current) observer.observe(panelAnchorRef.current)
return () => observer.disconnect()
}, [])
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
const isOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : false
const representative = validPromoted && isOverflow(validPromoted) ? validPromoted : baseMenu
const fieldHidden = (id: SWHeaderMenuId) => id !== representative && (isOverflow(id) || (id === baseMenu && representative !== baseMenu))
// Removal is structural: close stale internal dropdown state silently, never send data callbacks.
const controlKey = configuration.map(item => item.id).join(",")
useEffect(() => {
if (openMenu && !enabled(openMenu)) setOpenMenu(null)
if (promoted && !enabled(promoted)) setPromoted(null)
if (!enabled("propertyPanel")) setInternalPanelOpen(false)
if (!enabled("propertyBar")) setInternalBarOpen(false)
}, [controlKey, openMenu, promoted])
const hasMenu: Record<SWHeaderMenuId, boolean> = {
layer: layers !== undefined,
color: colors !== undefined,
lineStyle: lineTypes !== undefined || lineWeights !== undefined,
textStyle: textStyles !== undefined,
dimensionStyle: dimensionStyles !== undefined,
}
const openChange = (id: SWHeaderMenuId, callback?: (open: boolean) => void) => (next: boolean) => {
if (next && panelOpen) setPanelOpen(false)
setOpenMenu(current => (next ? id : current === id ? null : current))
callback?.(next)
}
const menuOpenProps = (id: SWHeaderMenuId, controlled: boolean | undefined, callback?: (open: boolean) => void) => ({
open: enabled(id) && (controlled ?? openMenu === id),
onOpenChange: openChange(id, callback),
})
const layerOpen = menuOpenProps("layer", layerMenuOpen, onLayerMenuOpenChange)
const colorOpen = menuOpenProps("color", colorMenuOpen, onColorMenuOpenChange)
const lineStyleOpen = menuOpenProps("lineStyle", lineStyleMenuOpen, onLineStyleMenuOpenChange)
const textStyleOpen = menuOpenProps("textStyle", textStyleMenuOpen, onTextStyleMenuOpenChange)
const dimensionStyleOpen = menuOpenProps("dimensionStyle", dimensionStyleMenuOpen, onDimensionStyleMenuOpenChange)
const openers: Record<SWHeaderMenuId, { onOpenChange: (open: boolean) => void; onOpen?: () => void }> = {
layer: { onOpenChange: layerOpen.onOpenChange, onOpen: onLayerOpen },
color: { onOpenChange: colorOpen.onOpenChange, onOpen: onColorOpen },
lineStyle: { onOpenChange: lineStyleOpen.onOpenChange, onOpen: onLineStyleOpen },
textStyle: { onOpenChange: textStyleOpen.onOpenChange, onOpen: onTextStyleOpen },
dimensionStyle: { onOpenChange: dimensionStyleOpen.onOpenChange, onOpen: onDimensionStyleOpen },
}
/**
* More → field: show the field in the representative slot (choosing the layer gives the slot back),
* open its dropdown right below and move focus to the field. Calls the field's open callback once,
* exactly like a click on the field; no data callback is called.
*/
const promote = (id: SWHeaderMenuId) => () => {
setPromoted(id === baseMenu ? null : id)
focusRef.current = id
if (hasMenu[id]) openers[id].onOpenChange(true)
openers[id].onOpen?.()
}
useEffect(() => {
const id = focusRef.current
if (!id) return
focusRef.current = null
// The slot is the field itself, or the wrapper of a field with a dropdown (layer: .hm-sw-header__layer).
const slot = fieldsRef.current?.querySelector<HTMLElement>(scripted ? `[data-control="${id}"]` : id === "layer" ? ":scope > :is(.hm-sw-header__layer, [data-menu='layer'])" : `:scope > [data-menu="${menuSlot[id]}"]`)
const field = slot?.matches(".hm-sw-header__field") ? slot : slot?.querySelector<HTMLElement>(".hm-sw-header__field")
field?.focus()
})
const currentLayerName = layers?.find(item => item.id === activeLayerId)?.name ?? layer.name
const allMoreItems: SWHeaderMoreItem[] = [
{ id: "layer", menu: "layer", label: "레이어", value: currentLayerName, tier: "slot", selected: activeMenu === "layer", disabled, onSelect: promote("layer") },
{ id: "color", menu: "color", label: "색상", value: color.value, tier: "narrow", selected: activeMenu === "color", disabled, onSelect: promote("color") },
{ id: "lineStyle", menu: "line-style", label: "선스타일", value: lineStyle.name, tier: "aux", selected: activeMenu === "lineStyle", disabled, onSelect: promote("lineStyle") },
{ id: "textStyle", menu: "text-style", label: "문자스타일", value: textStyle.name, tier: "aux", selected: activeMenu === "textStyle", disabled, onSelect: promote("textStyle") },
{ id: "dimensionStyle", menu: "dimension-style", label: "치수스타일", value: dimensionStyle.name, tier: "aux", selected: activeMenu === "dimensionStyle", disabled, onSelect: promote("dimensionStyle") },
{ id: "propertyBar", label: "속성Bar", tier: "panel", checked: barOpen, disabled, onSelect: toggleBar },
{ id: "propertyPanel", label: "속성창", tier: "panel", checked: panelOpen, disabled, onSelect: togglePanel },
{ id: "print", label: "인쇄", tier: "action", disabled, onSelect: onPrint },
{ id: "popupGuide", label: "팝업 가이드", tier: "action", disabled, guide: true, onSelect: onPopupGuide },
]
const moreItems = configuration.flatMap(config => {
const item = allMoreItems.find(item => item.id === config.id)
if (!item) return []
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
return [{ ...item, label: config.label ?? item.label, visible: scripted ? hidden : undefined }]
})
const fieldNodes: Record<SWHeaderMenuId, ReactNode> = {
layer: (layers ? (
<SWHeaderLayerMenu label={label("layer", "레이어")}
value={layer}
layers={layers}
activeLayerId={activeLayerId}
{...layerOpen}
onClick={onLayerOpen}
selected={activeMenu === "layer"}
disabled={disabled}
onLayerSelect={onLayerSelect}
onVisibilityChange={onLayerVisibilityChange}
onFreezeChange={onLayerFreezeChange}
onLockChange={onLayerLockChange}
onDetail={onLayerDetail}
/>
) : (
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} />
)),
color: (colors ? (
<SWHeaderColorMenu label={label("color", "색상")}
value={color}
colors={colors}
userColors={userColors}
mode={colorMode}
{...colorOpen}
onClick={onColorOpen}
selected={activeMenu === "color"}
disabled={disabled}
onColorSelect={onColorSelect}
onModeChange={onColorModeChange}
onColorInput={onColorInput}
onPick={onColorPick}
onDetail={onColorDetail}
/>
) : (
<SWHeaderColorControl label={label("color", "색상")} value={color} selected={activeMenu === "color"} disabled={disabled} onClick={onColorOpen} />
)),
lineStyle: (hasMenu.lineStyle ? (
<SWHeaderLineStyleMenu label={label("lineStyle", "선스타일")}
value={lineStyle}
lineTypes={lineTypes}
lineWeights={lineWeights}
activeLineTypeId={activeLineTypeId}
activeLineWeightId={activeLineWeightId}
{...lineStyleOpen}
onClick={onLineStyleOpen}
selected={activeMenu === "lineStyle"}
disabled={disabled}
onLineTypeSelect={onLineTypeSelect}
onLineWeightSelect={onLineWeightSelect}
onLineWeightValueChange={onLineWeightValueChange}
onScaleChange={onLineScaleChange}
onDetail={onLineStyleDetail}
/>
) : (
<SWHeaderLineStyle label={label("lineStyle", "선스타일")} value={lineStyle} selected={activeMenu === "lineStyle"} disabled={disabled} onClick={onLineStyleOpen} />
)),
textStyle: (textStyles ? (
<SWHeaderTextStyleMenu label={label("textStyle", "문자스타일")}
value={textStyle}
textStyles={textStyles}
activeTextStyleId={activeTextStyleId}
{...textStyleOpen}
onClick={onTextStyleOpen}
selected={activeMenu === "textStyle"}
disabled={disabled}
onTextStyleSelect={onTextStyleSelect}
onDetail={onTextStyleDetail}
/>
) : (
<SWHeaderTextStyle label={label("textStyle", "문자스타일")} value={textStyle} selected={activeMenu === "textStyle"} disabled={disabled} onClick={onTextStyleOpen} />
)),
dimensionStyle: (dimensionStyles ? (
<SWHeaderDimensionStyleMenu label={label("dimensionStyle", "치수스타일")}
value={dimensionStyle}
dimensionStyles={dimensionStyles}
activeDimensionStyleId={activeDimensionStyleId}
{...dimensionStyleOpen}
onClick={onDimensionStyleOpen}
selected={activeMenu === "dimensionStyle"}
disabled={disabled}
onDimensionStyleSelect={onDimensionStyleSelect}
onDetail={onDimensionStyleDetail}
/>
) : (
<SWHeaderDimensionStyle label={label("dimensionStyle", "치수스타일")} value={dimensionStyle} selected={activeMenu === "dimensionStyle"} disabled={disabled} onClick={onDimensionStyleOpen} />
)),
}
const documentNode = (<SWHeaderDocumentTitle
label={label("document", "문서")}
title={documentTitle}
onClick={onDocumentClick}
documents={documents}
menuOpen={documentMenuOpen}
onMenuOpenChange={onDocumentMenuOpenChange}
onDocumentSelect={onDocumentSelect}
activeDocumentId={activeDocumentId}
onDocumentClose={onDocumentClose}
onNew={onDocumentNew}
onOpen={onDocumentOpen}
documentIcon={documentIcon}
newLabel={documentNewLabel}
openLabel={documentOpenLabel}
/>)
const configuredNodes: Partial<Record<SWHeaderControlId, ReactNode>> = {
...fieldNodes,
document: documentNode,
drawingMode: <span className="hm-sw-header__mode">{label("drawingMode", drawingModeLabel)}</span>,
details: <span className="hm-sw-header__properties-caption">{label("details", "상세보기")}</span>,
propertyBar: <button type="button" className="hm-sw-header__toggle" aria-pressed={barOpen} aria-controls={barOpen ? barId : undefined} disabled={disabled} onClick={toggleBar}>{label("propertyBar", "속성Bar")}</button>,
propertyPanel: <button type="button" className="hm-sw-header__toggle" data-property-panel-trigger="" aria-pressed={panelOpen} aria-haspopup={propertyPanel ? "dialog" : undefined} aria-expanded={panelOpen} aria-controls={panelOpen ? panelId : undefined} disabled={disabled} onClick={togglePanel}>{label("propertyPanel", "속성창")}</button>,
print: <button type="button" className="hm-sw-header__action hm-sw-header__action--print" aria-label={label("print", "인쇄")} disabled={disabled} onClick={onPrint}><img className="hm-sw-header__icon" src={printIcon} alt="" /><img className="hm-sw-header__icon-hover" src={printHover} alt="" /></button>,
popupGuide: <SWHeaderKGuide label={label("popupGuide", "팝업 가이드")} disabled={disabled} onClick={onPopupGuide} />,
}
const presence = (id: SWHeaderControlId, node: ReactNode) => <SWHeaderCommitPresence.Provider value={() => presentRef.current.has(id)}>{node}</SWHeaderCommitPresence.Provider>
const actionsShown = controls === undefined && (enabled("print") || enabled("popupGuide"))
const windowShown = actionsShown || frameControls.slice(1).some(shown)
const windowReduced = removed.size > 0 && (frameControls.some(id => removed.has(id)) || (controls === undefined && !(enabled("print") && enabled("popupGuide"))))
// CSS-only default layout: a field holding the first slot instead of the removed layer is styled as promoted.
const promotedSlot = validPromoted ?? (!scripted && baseMenu && baseMenu !== "layer" ? baseMenu : null)
const barData = propertyBar ?? (propertyPanel && {
selection: propertyPanel.selection,
properties: propertyPanel.properties,
onPropertyChange: propertyPanel.onPropertyChange,
})
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
{controls === undefined && enabled("document") && documentNode}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
<div ref={fieldsRef} className="hm-sw-header__fields" data-configured={controls !== undefined || undefined}>
{controls ? configuration.map(config => {
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id) || (config.id === "drawingMode" && headerWidth < 1180)
return <div key={config.id} className="hm-sw-header__control" data-control={config.id} data-hidden={hidden || undefined}>{presence(config.id, configuredNodes[config.id])}</div>
}) : scripted ? menuIds.map(id => (
<div key={id} className="hm-sw-header__control" data-control={id} data-hidden={fieldHidden(id) || undefined} data-representative={(id === representative && id !== baseMenu) || undefined}>{presence(id, fieldNodes[id])}</div>
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
{tools}
</div>
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} />}
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
</div>
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
{actionsShown && <SWHeaderActions disabled={disabled} onPrint={onPrint} onPopupGuide={onPopupGuide} showPrint={enabled("print")} showPopupGuide={enabled("popupGuide")} />}
<SWHeaderWindowControls onWindowMoveStart={onWindowMoveStart} onMinimize={onMinimize} onClose={onClose} showWindowMove={shown("windowMove")} showMinimize={shown("minimize")} showClose={shown("close")} />
</div>}
</div>
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, width: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)
})
@@ -0,0 +1,26 @@
import printIcon from "../../../assets/print.svg"
import printIconHover from "../../../assets/print-hover.svg"
import { SWHeaderKGuide } from "./SWHeaderKGuide"
export interface SWHeaderActionsProps {
disabled?: boolean
onPrint?: () => void
onPopupGuide?: () => void
/** Individual buttons (default true). The pill shrinks to the remaining button; none renders nothing. */
showPrint?: boolean
showPopupGuide?: boolean
}
/** Print and popup-guide buttons inside the dark rounded pill. */
export function SWHeaderActions({ disabled, onPrint, onPopupGuide, showPrint = true, showPopupGuide = true }: SWHeaderActionsProps) {
if (!showPrint && !showPopupGuide) return null
return (
<div className="hm-sw-header__actions" role="group" aria-label="바로가기" data-partial={showPrint && showPopupGuide ? undefined : ""}>
{showPrint && <button type="button" className="hm-sw-header__action hm-sw-header__action--print" aria-label="인쇄" disabled={disabled} onClick={onPrint}>
<img className="hm-sw-header__icon" src={printIcon} alt="" aria-hidden="true" />
<img className="hm-sw-header__icon-hover" src={printIconHover} alt="" aria-hidden="true" />
</button>}
{showPopupGuide && <SWHeaderKGuide disabled={disabled} onClick={onPopupGuide} />}
</div>
)
}
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react"
import colorPickIcon from "../../../assets/colorize-default.svg"
import colorPickHover from "../../../assets/colorize-hover.svg"
import type { SWHeaderColorInputValue, SWHeaderColorMode, SWHeaderColorOption, SWHeaderColorValue } from "../types"
import { SWHeaderColorChip, SWHeaderMenuDetail, useSWHeaderDropdown } from "./SWHeaderDropdown"
import { SWHeaderColorControl } from "./SWHeaderFields"
import { SWHeaderInput, parseSWHeaderColor } from "./SWHeaderInput"
export interface SWHeaderColorMenuProps {
/** Current color shown in the field, the input and its chip. */
label?: string
value: SWHeaderColorValue
/** Basic palette (Figma: white, red, green, yellow, blue, magenta, cyan). Host data. */
colors: SWHeaderColorOption[]
/** User colors: extra rows below the palette, any count. */
userColors?: SWHeaderColorOption[]
/** Color mode; null still means explicit color. The required mode indicator remembers its last mode. */
mode?: SWHeaderColorMode | null
/** Controlled open state. Leave undefined to let the component manage it. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** Called on every click of the field (also when the dropdown toggles). */
onClick?: () => void
/** Keeps the field in its selected (#FFC600) look while closed. */
selected?: boolean
disabled?: boolean
onColorSelect?: (option: SWHeaderColorOption) => void
onModeChange?: (mode: SWHeaderColorMode) => void
onColorInput?: (value: SWHeaderColorInputValue) => void
/** Color pick (색상 추출). The dropdown closes so the host can pick from the drawing. */
onPick?: () => void
/** "색상 상세". Optional host action; omitting it keeps hover and performs no CAD action. */
onDetail?: () => void
}
const modes: { mode: SWHeaderColorMode; label: string }[] = [
{ mode: "byLayer", label: "ByLayer" },
{ mode: "byBlock", label: "ByBlock" },
]
/** Value text of an option: its `value`, else the RGB text of its color. */
const optionValue = (option: SWHeaderColorOption) => option.value ?? parseSWHeaderColor(option.color)?.value ?? option.color
/**
* Color field with its dropdown (Figma header/menu/색상 26:10986, user colors 26:11036): ByLayer /
* ByBlock (26:12666 / 26:12659), 22px palette, user color rows, color pick + RGB input, "색상 상세".
* Every control only reports through its callback; the host updates `value` / `mode`. Choosing a
* color or a mode keeps the dropdown open (several properties can be set in a row).
* Latest request restores Figma hover for the mode pair and detail pill; chips/pick remain unchanged.
*/
export function SWHeaderColorMenu({
value, label, colors, userColors, mode, open: openProp, onOpenChange, onClick, selected = false, disabled = false,
onColorSelect, onModeChange, onColorInput, onPick, onDetail,
}: SWHeaderColorMenuProps) {
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, shifted, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled })
const swatch = value.swatch ?? "#ffe500"
const current = parseSWHeaderColor(value.value) ?? parseSWHeaderColor(swatch)
const inputValue = current?.value ?? value.value
const [lastMode, setLastMode] = useState<SWHeaderColorMode>(mode ?? "byLayer")
useEffect(() => { if (mode) setLastMode(mode) }, [mode])
const selectedMode = mode ?? lastMode
const chip = (option: SWHeaderColorOption) => {
const pressed = !mode && current !== null && parseSWHeaderColor(option.color)?.hex === current.hex
const label = option.label ?? optionValue(option)
return (
<button
key={option.id}
type="button"
className="hm-sw-header__color-option"
title={label}
aria-label={label}
aria-pressed={pressed}
disabled={option.disabled}
onClick={() => onColorSelect?.(option)}
>
<SWHeaderColorChip color={option.color} />
</button>
)
}
return (
<div {...rootProps} className="hm-sw-header__menu hm-sw-header__menu--color" data-menu="color">
<SWHeaderColorControl
buttonRef={triggerRef}
label={label}
value={value}
selected={selected || open}
expanded={open}
controls={open ? menuId : undefined}
disabled={disabled}
onClick={() => {
onClick?.()
setOpen(!open)
}}
/>
{open && (
<div
ref={panelRef}
id={menuId}
className="hm-sw-header__menu-panel hm-sw-header__color-menu"
role="group"
aria-label="색상"
data-shifted={shifted ? "true" : undefined}
style={panelStyle}
>
<div className="hm-sw-header__color-modes" role="group" aria-label="색상 기준">
{modes.map(item => (
<button
key={item.mode}
type="button"
className={`hm-sw-header__color-mode hm-sw-header__color-mode--${item.mode === "byLayer" ? "layer" : "block"}`}
aria-pressed={selectedMode === item.mode}
onClick={() => {
if (selectedMode === item.mode) return
setLastMode(item.mode)
onModeChange?.(item.mode)
}}
>
<span className="hm-sw-header__menu-text">{item.label}</span>
</button>
))}
</div>
<div className="hm-sw-header__color-palette">
{colors.length > 0 && <div className="hm-sw-header__color-row" role="group" aria-label="기본 색상">{colors.map(chip)}</div>}
{userColors && userColors.length > 0 && (
<div className="hm-sw-header__color-row hm-sw-header__color-row--user" role="group" aria-label="사용자 색상">{userColors.map(chip)}</div>
)}
</div>
<div className="hm-sw-header__color-tools">
<button
type="button"
className="hm-sw-header__color-pick"
aria-label="색상 추출"
title="색상 추출"
disabled={!onPick}
onClick={() => {
onPick?.()
close(true)
}}
>
<img className="hm-sw-header__colorize-default" src={colorPickIcon} alt="" />
<img className="hm-sw-header__colorize-hover" src={colorPickHover} alt="" />
</button>
<span className="hm-sw-header__color-input">
<SWHeaderColorChip color={swatch} />
<SWHeaderInput
aria-label="색상값 (R,G,B)"
inputMode="text"
value={inputValue}
parse={draft => parseSWHeaderColor(draft)?.value ?? null}
onCommit={onColorInput ? next => {
const parsed = parseSWHeaderColor(next)
if (parsed) onColorInput(parsed)
} : undefined}
/>
</span>
</div>
<div className="hm-sw-header__menu-footer">
<SWHeaderMenuDetail label="색상 상세" onClick={onDetail && (() => { onDetail(); close(true) })} />
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,219 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
import documentIcon from "../../../assets/document-icon.svg"
import type { SWHeaderDocument } from "../types"
export interface SWHeaderDocumentTitleProps {
title: string
label?: string
/** Called on every click of the file name (also when the document menu toggles). */
onClick?: () => void
/**
* Documents shown in the dropdown below the file name. When omitted, the file name is a plain
* button without a dropdown (the 0.1.0 behavior). An empty array opens a menu with only New / Open.
*/
documents?: SWHeaderDocument[]
/** Controlled open state of the document menu. Leave undefined to let the component manage it. */
menuOpen?: boolean
onMenuOpenChange?: (open: boolean) => void
onDocumentSelect?: (document: SWHeaderDocument) => void
/**
* Id of the current document. Its name is shown in #FFC600; the list keeps the order of
* `documents` (selecting another document never moves rows).
*/
activeDocumentId?: string
/**
* Close Document. When given, every document row gets an X button ("문서 닫기"), hidden until
* the row is hovered or focused (space is always reserved). The host closes the document and
* updates `documents` / `activeDocumentId`; the header never picks the next document itself.
*/
onDocumentClose?: (document: SWHeaderDocument) => void
onNew?: () => void
onOpen?: () => void
/** Icon used for documents without their own `icon`. Defaults to the Figma document icon. */
documentIcon?: ReactNode
newLabel?: string
openLabel?: string
}
/**
* File name button. With `documents`, it is a disclosure button that opens the document menu
* (Figma header/document_tit 26:10662): click / Enter / Space toggle it, ArrowDown opens it and
* focuses the first entry, Escape or a click outside closes it.
*/
export function SWHeaderDocumentTitle({
title,
label = "문서",
onClick,
documents,
menuOpen,
onMenuOpenChange,
onDocumentSelect,
activeDocumentId,
onDocumentClose,
onNew,
onOpen,
documentIcon: iconOverride,
newLabel = "+ New",
openLabel = "Open",
}: SWHeaderDocumentTitleProps) {
const hasMenu = documents !== undefined
const [internalOpen, setInternalOpen] = useState(false)
const open = hasMenu && (menuOpen ?? internalOpen)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
// Set when the keyboard opens the menu or a document is closed, so the first entry gets focus
// once the menu (re)renders and focus is never lost with a removed row.
const focusFirstRef = useRef(false)
const [, rerender] = useState(0)
const setOpen = (next: boolean) => {
if (menuOpen === undefined) setInternalOpen(next)
onMenuOpenChange?.(next)
}
const close = (restoreFocus: boolean) => {
setOpen(false)
if (restoreFocus) triggerRef.current?.focus()
}
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
})
useEffect(() => {
if (open && focusFirstRef.current) {
focusFirstRef.current = false
entries()[0]?.focus()
}
})
const entries = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>(".hm-sw-header__document-menu button:not(:disabled)") ?? [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!hasMenu) return
if (event.key === "Escape" && open) {
event.stopPropagation()
close(true)
return
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
event.preventDefault()
if (!open) {
focusFirstRef.current = true
setOpen(true)
return
}
const list = entries()
const index = list.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === "ArrowDown" ? (index + 1) % list.length : (index - 1 + list.length) % list.length
list[next]?.focus()
}
const items = documents ?? []
const active = items.find(item => item.id === activeDocumentId)
const iconFor = (item: SWHeaderDocument) =>
item.icon ?? iconOverride ?? <img className="hm-sw-header__document-icon-default" src={documentIcon} alt="" />
return (
<div
ref={rootRef}
className="hm-sw-header__document"
onKeyDown={onKeyDown}
onBlur={event => {
// Tabbing out of the file name and its menu closes the menu; focus is never trapped.
if (open && !rootRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false)
}}
>
<button
ref={triggerRef}
type="button"
className="hm-sw-header__document-button"
title={title}
aria-label={`${label}: ${title}`}
aria-expanded={hasMenu ? open : undefined}
aria-controls={open ? menuId : undefined}
data-selected={open ? "true" : undefined}
onClick={() => {
onClick?.()
if (hasMenu) setOpen(!open)
}}
>
<span className="hm-sw-header__document-name">{title}</span>
<span className="hm-sw-header__arrow" aria-hidden="true" />
</button>
{open && (
<div id={menuId} className="hm-sw-header__document-menu" role="group" aria-label="문서 목록">
{items.length > 0 && (
<ul className="hm-sw-header__document-list">
{items.map(item => (
<SWHeaderDocumentItem
key={item.id}
document={item}
icon={iconFor(item)}
active={item === active}
onSelect={() => {
onDocumentSelect?.(item)
close(true)
}}
onClose={onDocumentClose ? () => {
// Park focus inside the header before the row may disappear, then refocus the
// first entry after the host updates the list. The menu stays open.
triggerRef.current?.focus()
focusFirstRef.current = true
onDocumentClose(item)
rerender(count => count + 1)
} : undefined}
/>
))}
</ul>
)}
<div className="hm-sw-header__document-footer">
<button type="button" className="hm-sw-header__document-action" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>
<button type="button" className="hm-sw-header__document-action" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>
</div>
</div>
)}
</div>
)
}
/**
* One document row (32px): select button (icon 29 x 12 + name) and an optional Close Document
* button for this document (shown on row hover / focus). Sibling buttons, so closing never selects.
*/
function SWHeaderDocumentItem({ document, icon, active, onSelect, onClose }: {
document: SWHeaderDocument
icon: ReactNode
active: boolean
onSelect: () => void
onClose?: () => void
}) {
return (
<li className="hm-sw-header__document-row" data-active={active ? "true" : undefined} data-disabled={document.disabled ? "true" : undefined}>
<button
type="button"
className="hm-sw-header__document-item"
title={document.title ?? document.name}
aria-current={active ? "true" : undefined}
disabled={document.disabled}
onClick={onSelect}
>
<span className="hm-sw-header__document-icon" aria-hidden="true">{icon}</span>
<span className="hm-sw-header__document-item-name">{document.name}</span>
</button>
{onClose && (
<button type="button" className="hm-sw-header__document-close" aria-label="문서 닫기" title={`${document.name} 닫기`} onClick={onClose}>
<svg width="8" height="8" viewBox="0 0 8 8" aria-hidden="true" focusable="false">
<path d="M1 1l6 6M7 1L1 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
</li>
)
}
@@ -0,0 +1,156 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"
import detailIcon from "../../../assets/layer-detail.svg"
import type { ButtonHTMLAttributes, ReactNode } from "react"
export interface SWHeaderDropdownOptions {
/** Controlled open state. Leave undefined to let the component manage it. */
open?: boolean
onOpenChange?: (open: boolean) => void
disabled?: boolean
/** `start`: left edge under the field (color, text, dimension). `center`: centered under it (line style). */
align?: "start" | "center"
}
/**
* Shared behavior of the field dropdowns added after the layer dropdown (color, line style, text style,
* dimension style), the same contract as SWHeaderLayerMenu: click / Enter / Space toggle (hover never
* opens), ArrowDown opens and focuses the current (or first) entry, ArrowUp / ArrowDown move, Escape /
* a click outside / Tab out close. The list overlays below the field and is shifted horizontally only
* when it would leave the header (or the screen).
*/
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start" }: SWHeaderDropdownOptions) {
const [internalOpen, setInternalOpen] = useState(false)
const open = !disabled && (openProp ?? internalOpen)
const rootRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
const focusEntryRef = useRef(false)
// Left offset of the panel from the field; `base` is the unshifted position for `align`.
const [position, setPosition] = useState({ left: 0, base: 0 })
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternalOpen(next)
onOpenChange?.(next)
}
const close = (restoreFocus: boolean) => {
setOpen(false)
if (restoreFocus) triggerRef.current?.focus()
}
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
})
useLayoutEffect(() => {
if (!open) return
const update = () => {
const root = rootRef.current
const panel = panelRef.current
if (!root || !panel) return
const bounds = (root.closest(".hm-sw-header") ?? document.documentElement).getBoundingClientRect()
// 1px inside the bounds so the outline ring (drawn 1px outside the panel) never overflows.
const min = Math.max(bounds.left, 0) + 1
const max = Math.min(bounds.right, document.documentElement.clientWidth) - 1
const field = root.getBoundingClientRect()
const base = align === "center" ? Math.round((field.width - panel.offsetWidth) / 2) : 0
let left = field.left + base
if (left + panel.offsetWidth > max) left = max - panel.offsetWidth
if (left < min) left = min
const next = Math.round(left - field.left)
setPosition(current => (current.left === next && current.base === base ? current : { left: next, base }))
}
update()
window.addEventListener("resize", update)
return () => window.removeEventListener("resize", update)
}) // Reposition after a host-driven control reorder as well as open/resize.
/** Focusable entries of the panel in DOM order (rows, inputs, footer). */
const entries = () => Array.from(panelRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)") ?? [])
useEffect(() => {
if (open && focusEntryRef.current) {
focusEntryRef.current = false
const current = panelRef.current?.querySelector<HTMLElement>('[aria-current="true"]:not(:disabled), [aria-pressed="true"]:not(:disabled)')
;(current ?? entries()[0])?.focus()
}
})
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape" && open) {
event.stopPropagation()
close(true)
return
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
if (!open) {
if (event.target !== triggerRef.current) return
event.preventDefault()
focusEntryRef.current = true
setOpen(true)
return
}
const list = entries()
if (list.length === 0) return
event.preventDefault()
const index = list.indexOf(document.activeElement as HTMLElement)
if (index < 0) {
list[event.key === "ArrowDown" ? 0 : list.length - 1]?.focus()
return
}
list[(index + (event.key === "ArrowDown" ? 1 : -1) + list.length) % list.length]?.focus()
}
const onBlur = (event: FocusEvent<HTMLDivElement>) => {
// Tabbing out of the field and its dropdown closes it; focus is never trapped.
if (open && !rootRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false)
}
return {
open,
setOpen,
close,
rootRef,
panelRef,
triggerRef,
menuId,
/** Panel `left` relative to the field (centered and / or shifted to stay on screen). */
panelStyle: position.left !== 0 ? { left: position.left } : undefined,
/** The panel no longer sits under the field (the outline then rings the panel only). */
shifted: position.left !== position.base,
rootProps: { ref: rootRef, onKeyDown, onBlur, "data-open": open ? "true" : undefined },
}
}
export interface SWHeaderMenuDetailProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
label: string
/** Optional replacement icon; decorative, with the shared 14px size and state opacity. */
icon?: ReactNode
}
/** All five footer buttons use the text-style detail design (26:10789 / 26:10792).
* Callback absence is a safe no-op; only an explicit disabled prop disables the button.
* The actual CAD detail popup remains the host's responsibility.
*/
export function SWHeaderMenuDetail({ label, icon, className, disabled = false, ...props }: SWHeaderMenuDetailProps) {
return (
<button {...props} type="button" className={["hm-sw-header__menu-detail", className].filter(Boolean).join(" ")} data-detail={label} disabled={disabled}>
{icon === undefined ? <img src={detailIcon} alt="" /> : <span className="hm-sw-header__detail-icon" aria-hidden="true">{icon}</span>}
<span data-detail-label="">{label}</span>
</button>
)
}
/** 22px color chip (Figma color chip 22px 26:11091): black 20px tile, 18px swatch with a #C9C9C9 border. */
export function SWHeaderColorChip({ color }: { color: string }) {
return (
<span className="hm-sw-header__color-chip" aria-hidden="true">
<span style={{ background: color }} />
</span>
)
}
@@ -0,0 +1,52 @@
import type { ReactNode, Ref } from "react"
export interface SWHeaderFieldProps {
/** Accessible name of the field button, e.g. `레이어`. */
label: string
selected?: boolean
disabled?: boolean
onClick?: () => void
/** Modifier for the Figma width. */
variant: "layer" | "color" | "line-style" | "text-style" | "dimension-style"
/** `aux` fields move into the More menu below the compact breakpoint. */
tier?: "main" | "aux"
/** Open state of a dropdown owned by the field itself (layer, color, line / text / dimension style). Defaults to `selected`. */
expanded?: boolean
/** Id of that dropdown while it is open. */
controls?: string
buttonRef?: Ref<HTMLButtonElement>
children: ReactNode
}
/** Shared SW property field surface (user-confirmed gradient, border and shadow). */
export function SWHeaderField({ label, selected = false, disabled, onClick, variant, tier = "main", expanded, controls, buttonRef, children }: SWHeaderFieldProps) {
return (
<button
ref={buttonRef}
type="button"
className={`hm-sw-header__field hm-sw-header__field--${variant}`}
data-menu={variant}
data-tier={tier}
data-selected={selected ? "true" : undefined}
data-expanded={expanded ? "true" : undefined}
aria-haspopup="true"
aria-expanded={expanded ?? selected}
aria-controls={controls}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
/** Small label (12px) above a bold value (14px) used by every field. */
export function SWHeaderFieldText({ label, value }: { label: string; value: string }) {
return (
<span className="hm-sw-header__field-text">
<span className="hm-sw-header__field-label">{label}</span>
<span className="hm-sw-header__field-value" title={value}>{value}</span>
</span>
)
}
@@ -0,0 +1,79 @@
import dimensionIcon from "../../../assets/menu-dimension-ico.svg"
import dimensionIconHover from "../../../assets/menu-dimension-ico-hover.svg"
import type {
SWHeaderColorValue, SWHeaderDimensionStyleValue, SWHeaderLayer, SWHeaderLayerValue,
SWHeaderLineStyleValue, SWHeaderTextStyleValue,
} from "../types"
import { SWHeaderField, SWHeaderFieldText, type SWHeaderFieldProps } from "./SWHeaderField"
import { SWHeaderLayerIconArt, layerIconOn } from "./SWHeaderLayerIcons"
interface FieldBase extends Pick<SWHeaderFieldProps, "expanded" | "controls" | "buttonRef"> {
label?: string
selected?: boolean
disabled?: boolean
onClick?: () => void
}
export interface SWHeaderLayerControlProps extends FieldBase {
value: SWHeaderLayerValue
/** State of the current layer for the three field icons (gray when off). Defaults to all colored. */
state?: Pick<SWHeaderLayer, "visible" | "frozen" | "locked">
}
export function SWHeaderLayerControl({ value, state, label = "레이어", ...rest }: SWHeaderLayerControlProps) {
return (
<SWHeaderField label={label} variant="layer" {...rest}>
<span className="hm-sw-header__layer-icons" aria-hidden="true">
<SWHeaderLayerIconArt kind="visibility" on={layerIconOn("visibility", state)} />
<SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} />
<SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} />
</span>
<SWHeaderFieldText label={label} value={value.name} />
</SWHeaderField>
)
}
export function SWHeaderColorControl({ value, label, ...rest }: FieldBase & { value: SWHeaderColorValue }) {
return (
<SWHeaderField label={label ?? "색상"} variant="color" {...rest}>
<span className="hm-sw-header__chip" aria-hidden="true">
<span style={{ background: value.swatch ?? "#ffe500" }} />
</span>
<SWHeaderFieldText label={label ?? "색상"} value={value.value} />
</SWHeaderField>
)
}
export function SWHeaderLineStyle({ value, label, ...rest }: FieldBase & { value: SWHeaderLineStyleValue }) {
return (
<SWHeaderField label={label ?? "선스타일"} variant="line-style" tier="aux" {...rest}>
<span className="hm-sw-header__linetype" aria-hidden="true"><i /><i /><i /><i /><i /></span>
<span className="hm-sw-header__line-values">
<SWHeaderFieldText label={label ?? "선스타일"} value={value.name} />
<SWHeaderFieldText label="가중치" value={value.weight} />
<SWHeaderFieldText label="스케일" value={value.scale} />
</span>
</SWHeaderField>
)
}
export function SWHeaderTextStyle({ value, label, ...rest }: FieldBase & { value: SWHeaderTextStyleValue }) {
return (
<SWHeaderField label={label ?? "문자스타일"} variant="text-style" tier="aux" {...rest}>
<span className="hm-sw-header__font-name" title={value.font}>{value.font}</span>
<SWHeaderFieldText label={label ?? "문자스타일"} value={value.name} />
</SWHeaderField>
)
}
export function SWHeaderDimensionStyle({ value, label, ...rest }: FieldBase & { value: SWHeaderDimensionStyleValue }) {
return (
<SWHeaderField label={label ?? "치수스타일"} variant="dimension-style" tier="aux" {...rest}>
<span className="hm-sw-header__dimension-icon" aria-hidden="true">
<img className="hm-sw-header__dimension-icon-default" src={dimensionIcon} alt="" />
<img className="hm-sw-header__dimension-icon-hover" src={dimensionIconHover} alt="" />
</span>
<SWHeaderFieldText label={label ?? "치수스타일"} value={value.name} />
</SWHeaderField>
)
}
@@ -0,0 +1,39 @@
import type { ReactNode } from "react"
import homeBackground from "../../../assets/home-bg.svg"
import homeBackgroundHover from "../../../assets/home-bg-hover.svg"
export interface SWHeaderHomeProps {
onClick?: () => void
/** Logo image URL, fitted (contain) into the 32 x 17 logo slot. */
logoSrc?: string
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc`. */
logo?: ReactNode
/** Accessible product name. The home button is announced as "<logoAlt> 홈". */
logoAlt?: string
/**
* false: the logo stays in the same slot as a static image (no home button, hover or callback).
* SWHeader always shows the logo; only the home action can be removed. Default true.
*/
interactive?: boolean
}
/**
* Home button. The slanted background and the logo slot position are the common SW design;
* the logo itself belongs to each SW program, so no logo is rendered unless one is passed.
*/
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true }: SWHeaderHomeProps) {
const content = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
if (!interactive) return (
<div className="hm-sw-header__home" data-static="" role={logoAlt ? "img" : undefined} aria-label={logoAlt}>
<img className="hm-sw-header__home-bg" src={homeBackground} alt="" aria-hidden="true" />
{content && <span className="hm-sw-header__logo" aria-hidden="true">{content}</span>}
</div>
)
return (
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} onClick={onClick}>
<img className="hm-sw-header__home-bg" src={homeBackground} alt="" aria-hidden="true" />
<img className="hm-sw-header__home-bg-hover" src={homeBackgroundHover} alt="" aria-hidden="true" />
{content && <span className="hm-sw-header__logo" aria-hidden="true">{content}</span>}
</button>
)
}
@@ -0,0 +1,163 @@
import { createContext, useContext, forwardRef, useEffect, useRef, useState, type InputHTMLAttributes } from "react"
/** Structural removal discards drafts; ordinary dropdown close retains the legacy commit contract. */
export const SWHeaderCommitPresence = createContext<() => boolean>(() => true)
/** Built-in validation of the draft. `parse` overrides it (any custom format). */
export type SWHeaderInputValueType = "text" | "number"
export interface SWHeaderInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type" | "size"> {
/** Current (committed) value from the host. A new value replaces the draft unless it is being edited. */
value: string
/** Every edit of the draft (temporary value, not committed). */
onValueChange?: (draft: string) => void
/**
* Committed value: Enter, leaving the input, or the dropdown around it closing. Called only when `parse`
* accepts the draft and the result differs from `value`; an invalid draft is discarded and `value` restored.
*/
onCommit?: (value: string) => void
/** Escape with an edited draft: the draft is restored to `value` (the event does not close the dropdown). */
onCancel?: () => void
/** `text` (default): trimmed, non-empty text. `number`: a decimal number (see `parseSWHeaderNumber`). */
valueType?: SWHeaderInputValueType
/** Validation + normalization, overriding `valueType`. Return the value to commit, or `null` for an invalid draft. */
parse?: (draft: string) => string | null
/** Default true (legacy dropdown behavior). False discards a draft when a dynamic property is removed. */
commitOnUnmount?: boolean
}
const parsers: Record<SWHeaderInputValueType, (draft: string) => string | null> = {
text: draft => draft.trim() || null,
number: draft => parseSWHeaderNumber(draft),
}
/**
* Editable SW input (Figma input 26:11656): red 14px value with the black text outline. Default has no
* surface; Hover shows a 1px rgba(212,203,189,.25) border (24px); editing (focus) shows #0F0D0A with a
* .3 border and the native caret in #E60403 (Figma cursor 26:11664, 1px; it blinks natively). The width
* follows the text. A real <input>: selection, IME (Enter while composing never commits) and the host
* value stay intact; values are never hard-coded here.
*/
export const SWHeaderInput = forwardRef<HTMLInputElement, SWHeaderInputProps>(function SWHeaderInput(
{ value, onValueChange, onCommit, onCancel, valueType = "text", parse = parsers[valueType], commitOnUnmount = true, readOnly, disabled, className, style, onBlur, onKeyDown, ...rest },
ref,
) {
const [draft, setDraft] = useState(value)
const [invalid, setInvalid] = useState(false)
const draftRef = useRef(value)
const isPresent = useContext(SWHeaderCommitPresence)
const pendingRef = useRef<string | null>(null)
const latest = useRef({ value, parse, onCommit, commitOnUnmount, readOnly, disabled })
latest.current = { value, parse, onCommit, commitOnUnmount, readOnly, disabled }
useEffect(() => {
draftRef.current = value
setDraft(value)
setInvalid(false)
pendingRef.current = null
}, [value, readOnly, disabled])
const update = (next: string) => {
draftRef.current = next
setDraft(next)
}
const commit = () => {
const { value: current, parse: parseDraft, onCommit: report } = latest.current
if (latest.current.readOnly || latest.current.disabled) return
if (draftRef.current === current) return
const next = parseDraft(draftRef.current)
setInvalid(false)
if (next === null) {
update(current)
return
}
update(next)
if (next !== current && next !== pendingRef.current) { pendingRef.current = next; report?.(next) }
}
// A click outside closes a dropdown before its input blurs: still commit what was typed.
useEffect(() => () => {
if (!isPresent() || !latest.current.commitOnUnmount || latest.current.readOnly || latest.current.disabled) return
const { value: current, parse: parseDraft, onCommit: report } = latest.current
if (draftRef.current === current) return
const next = parseDraft(draftRef.current)
if (next !== null && next !== current && next !== pendingRef.current) report?.(next)
}, [])
return (
<input
ref={ref}
type="text"
autoComplete="off"
spellCheck={false}
inputMode={valueType === "number" ? "decimal" : undefined}
{...rest}
className={`hm-sw-header__input${className ? ` ${className}` : ""}`}
// Width follows the draft (digits are 1ch); the paddings are added in CSS.
style={{ ["--hm-sw-input-chars" as string]: Math.max(draft.length, 1), ...style }}
aria-invalid={invalid || undefined}
data-invalid={invalid ? "true" : undefined}
value={draft}
// Without onCommit the value is shown read-only (same look, nothing is reported).
readOnly={readOnly || !onCommit}
disabled={disabled}
onChange={event => {
update(event.target.value)
setInvalid(latest.current.parse(event.target.value) === null)
onValueChange?.(event.target.value)
}}
onBlur={event => {
commit()
onBlur?.(event)
}}
onKeyDown={event => {
onKeyDown?.(event)
// IME composition (e.g. Hangul) owns Enter / Escape until it ends.
if (event.defaultPrevented || event.nativeEvent.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
commit()
} else if (event.key === "Escape" && draftRef.current !== latest.current.value) {
// The first Escape only cancels the edit; the next one closes the dropdown around it.
event.stopPropagation()
update(latest.current.value)
setInvalid(false)
onCancel?.()
}
}}
/>
)
})
/** `r,g,b` (0-255, spaces allowed) or `#rgb` / `#rrggbb` → normalized color, otherwise null. */
export function parseSWHeaderColor(text: string): { value: string; rgb: [number, number, number]; hex: string } | null {
const trimmed = text.trim()
let rgb: number[] | null = null
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(trimmed)
if (hex) {
const digits = hex[1].length === 3 ? hex[1].split("").map(digit => digit + digit).join("") : hex[1]
rgb = [0, 2, 4].map(index => parseInt(digits.slice(index, index + 2), 16))
} else {
const parts = trimmed.split(/\s*,\s*/)
if (parts.length === 3 && parts.every(part => /^\d{1,3}$/.test(part))) rgb = parts.map(Number)
}
if (!rgb || rgb.some(channel => channel > 255)) return null
const [r, g, b] = rgb
return { value: `${r},${g},${b}`, rgb: [r, g, b], hex: `#${rgb.map(channel => channel.toString(16).padStart(2, "0")).join("")}` }
}
/**
* Decimal number parser (`-1.5`, `0.25`, `.5`) → its text, otherwise null. `min` / `max` are inclusive;
* `minExclusive` makes `min` exclusive (e.g. a scale greater than zero).
*/
export function parseSWHeaderNumber(
text: string,
{ min = -Infinity, max = Infinity, minExclusive = false }: { min?: number; max?: number; minExclusive?: boolean } = {},
): string | null {
const trimmed = text.trim()
if (!/^-?(\d+\.?\d*|\.\d+)$/.test(trimmed)) return null
const number = Number(trimmed)
if (!Number.isFinite(number) || number > max || (minExclusive ? number <= min : number < min)) return null
return trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed
}
@@ -0,0 +1,77 @@
import { useEffect, useId, useRef, useState, type RefObject } from "react"
import popupGuide from "../../../assets/popup-guide.svg"
import popupGuideHover from "../../../assets/popup-guide-hover.svg"
import guideImage from "../../../assets/k-guide.png"
import defaultMask from "../../../assets/k-guide-mask-default.svg"
import panMask from "../../../assets/k-guide-mask-pan.svg"
import fitMask from "../../../assets/k-guide-mask-fit.svg"
import { useSWHeaderOverlay } from "./SWHeaderPropertyPanel"
/** Original K button. Its button and floating guide form one hover/focus region. */
export function SWHeaderKGuide({ disabled, onClick, label = "팝업 가이드" }: { disabled?: boolean; onClick?: () => void; label?: string }) {
const triggerRef = useRef<HTMLButtonElement>(null)
const regionRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [dismissed, setDismissed] = useState(false)
const guideId = useId()
const visible = open && !disabled && !dismissed
return (
<div ref={regionRef} className="hm-sw-k-guide" data-open={visible || undefined} onPointerEnter={() => { setDismissed(false); setOpen(true) }} onPointerLeave={() => { setOpen(false); setDismissed(false) }} onFocus={() => setOpen(true)} onBlur={event => {
if (!regionRef.current?.contains(event.relatedTarget as Node | null)) { setOpen(false); setDismissed(false) }
}} onKeyDown={event => { if (event.key === "Escape" && visible) { event.stopPropagation(); setDismissed(true) } }}>
<button ref={triggerRef} type="button" className="hm-sw-header__action hm-sw-header__action--guide" aria-label={label} aria-describedby={visible ? guideId : undefined} disabled={disabled} onClick={onClick}>
<img className="hm-sw-header__icon" src={popupGuide} alt="" aria-hidden="true" />
<img className="hm-sw-header__icon-hover" src={popupGuideHover} alt="" aria-hidden="true" />
</button>
{visible && <SWHeaderKGuidePreview anchorRef={triggerRef} id={guideId} />}
</div>
)
}
/** Same preview for the existing More entry on narrow headers; no duplicate K button. */
export function SWHeaderKGuidePreview({ anchorRef, id }: { anchorRef: RefObject<HTMLElement | null>; id?: string }) {
const [reduced, setReduced] = useState(false)
const [frame, setFrame] = useState(1)
const [duration, setDuration] = useState(0)
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 208, -84)
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduced(query.matches)
update(); query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
useEffect(() => {
setFrame(1); setDuration(0)
if (reduced) return
let timer: ReturnType<typeof setTimeout>
let phase = 0
// Delay + Figma transition, then a short natural return for the user-requested loop.
const phases = [
{ frame: 2, delay: 400, duration: 1022 },
{ frame: 3, delay: 1122, duration: 1022 },
{ frame: 4, delay: 1172, duration: 511 },
{ frame: 1, delay: 911, duration: 511 },
]
const schedule = () => {
const next = phases[phase]
timer = setTimeout(() => {
setDuration(next.duration); setFrame(next.frame)
phase = (phase + 1) % phases.length
if (phase === 0) {
// Complete the return transition before starting the next 400ms initial hold.
timer = setTimeout(schedule, 511)
} else schedule()
}, next.delay)
}
schedule()
return () => clearTimeout(timer)
}, [reduced])
return (
<div ref={panelRef} id={id} role="tooltip" className="hm-sw-k-guide__panel" data-frame={frame} data-reduced-motion={reduced || undefined} style={style}>
<div className="hm-sw-k-guide__viewport" style={{ maskImage: `url("${frame === 4 ? fitMask : frame === 1 ? defaultMask : panMask}")` }}>
<img src={guideImage} alt="" style={{ transitionDuration: `${duration}ms` }} />
</div>
<span>다중인쇄/도면관리</span>
</div>
)
}
@@ -0,0 +1,87 @@
import layerEllipse from "../../../assets/layer-ellipse.svg"
import layerEllipseOff from "../../../assets/layer-ellipse-off.svg"
import layerFreezeHover from "../../../assets/layer-freeze-hover.svg"
import layerFreezeOff from "../../../assets/layer-freeze-off.svg"
import layerFreezeOffHover from "../../../assets/layer-freeze-off-hover.svg"
import layerLockHover from "../../../assets/layer-lock-hover.svg"
import layerLockOff from "../../../assets/layer-lock-off.svg"
import layerLockOffHover from "../../../assets/layer-lock-off-hover.svg"
import layerStateA from "../../../assets/layer-state-a.svg"
import layerStateB from "../../../assets/layer-state-b.svg"
import layerUnion from "../../../assets/layer-union.svg"
import type { SWHeaderLayer } from "../types"
/** 전구 icon_전구 26:10810 · 눈꽃 icon_눈꽃 26:10855 · 자물쇠 icon_자물쇠 26:10912 (20 x 20, 4 variants each). */
export type SWHeaderLayerIconKind = "visibility" | "freeze" | "lock"
/** Figma Default / Variant2 (hover) = colored "on", Variant3 / Variant4 (hover) = gray "off". */
const art = {
freeze: { on: layerStateB, onHover: layerFreezeHover, off: layerFreezeOff, offHover: layerFreezeOffHover },
lock: { on: layerStateA, onHover: layerLockHover, off: layerLockOff, offHover: layerLockOffHover },
}
/**
* Colored ("on") variant per layer state (user-confirmed 2026-10-16, design spec §5.2.1):
* bulb visible, snowflake not frozen, lock not locked. Gray means hidden / frozen / locked.
*/
export function layerIconOn(kind: SWHeaderLayerIconKind, layer?: Pick<SWHeaderLayer, "visible" | "frozen" | "locked">) {
if (kind === "visibility") return layer?.visible !== false
if (kind === "freeze") return layer?.frozen !== true
return layer?.locked !== true
}
/** Static icon art. The bulb is the Figma composite (union + bars + ellipse); off swaps only the ellipse. */
export function SWHeaderLayerIconArt({ kind, on }: { kind: SWHeaderLayerIconKind; on: boolean }) {
if (kind === "visibility") {
return (
<span className="hm-sw-header__layer-icon hm-sw-header__layer-icon--composite">
<img className="hm-sw-header__layer-union" src={layerUnion} alt="" />
<span className="hm-sw-header__layer-bars"><i /><i /><i /><i /><i /></span>
<img className="hm-sw-header__layer-ellipse" src={on ? layerEllipse : layerEllipseOff} alt="" />
</span>
)
}
return <img className="hm-sw-header__layer-icon" src={on ? art[kind].on : art[kind].off} alt="" />
}
const labels: Record<SWHeaderLayerIconKind, string> = { visibility: "표시", freeze: "동결", lock: "잠금" }
/**
* One 20 x 20 state toggle of a layer row. The visual comes from the layer data only; hover is CSS
* (bulb: black 25% background, snowflake / lock: their Figma hover asset). Without `onChange` it is a
* status icon, so no click does nothing.
*/
export function SWHeaderLayerToggle({ kind, layer, onChange }: {
kind: SWHeaderLayerIconKind
layer: SWHeaderLayer
onChange?: (layer: SWHeaderLayer, next: boolean) => void
}) {
const on = layerIconOn(kind, layer)
// aria-pressed follows the data meaning (shown / frozen / locked), not the icon color.
const pressed = kind === "visibility" ? on : !on
const className = `hm-sw-header__layer-toggle hm-sw-header__layer-toggle--${kind}`
const label = `${layer.name} ${labels[kind]}`
if (!onChange) {
return (
<span className={className} data-on={on ? "true" : "false"} role="img" aria-label={`${label} ${pressed ? "켜짐" : "꺼짐"}`}>
<SWHeaderLayerIconArt kind={kind} on={on} />
</span>
)
}
return (
<button
type="button"
className={className}
data-on={on ? "true" : "false"}
aria-pressed={pressed}
aria-label={label}
disabled={layer.disabled}
onClick={() => onChange(layer, !pressed)}
>
<SWHeaderLayerIconArt kind={kind} on={on} />
{kind !== "visibility" && (
<img className="hm-sw-header__layer-toggle-hover" src={on ? art[kind].onHover : art[kind].offHover} alt="" />
)}
</button>
)
}
@@ -0,0 +1,232 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
import { SWHeaderMenuDetail } from "./SWHeaderDropdown"
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
import { SWHeaderLayerControl } from "./SWHeaderFields"
import { SWHeaderLayerToggle } from "./SWHeaderLayerIcons"
export interface SWHeaderLayerMenuProps {
/** Name shown in the field when no `layers` entry matches `activeLayerId`. */
label?: string
value: SWHeaderLayerValue
/** Rows of the dropdown, in this order. An empty array shows only the footer. */
layers: SWHeaderLayer[]
/** Current layer: shown in the field (name and state icons) and as a #FFC600 name in the list. */
activeLayerId?: string
/** Controlled open state. Leave undefined to let the component manage it. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** Called on every click of the field (also when the dropdown toggles). */
onClick?: () => void
/** Keeps the field in its selected (#FFC600) look while closed. */
selected?: boolean
disabled?: boolean
onLayerSelect?: (layer: SWHeaderLayer) => void
onVisibilityChange?: (layer: SWHeaderLayer, nextVisible: boolean) => void
onFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
onLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
/** "레이어 상세". Optional host action; omitting it does not disable hover or simulate CAD work. */
onDetail?: () => void
}
/**
* Layer field with its dropdown (Figma header/menu/레이어 26:10940, rows header/dropdown/brown/row
* 26:10687). Click / Enter / Space toggle it (hover never opens it), ArrowDown opens it and focuses
* the current row, Escape / a click outside / Tab out close it. Selecting a row closes it; the bulb,
* snowflake and lock are sibling buttons of the row selection, so they never select and keep it open.
*/
export function SWHeaderLayerMenu({
value,
label,
layers,
activeLayerId,
open: openProp,
onOpenChange,
onClick,
selected = false,
disabled = false,
onLayerSelect,
onVisibilityChange,
onFreezeChange,
onLockChange,
onDetail,
}: SWHeaderLayerMenuProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = !disabled && (openProp ?? internalOpen)
const rootRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
// Set when the keyboard opens the menu, so the current (or first) row gets focus once it renders.
const focusRowRef = useRef(false)
// Horizontal offset that keeps the 234px list inside the header (narrow screens only).
const [offset, setOffset] = useState(0)
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternalOpen(next)
onOpenChange?.(next)
}
const close = (restoreFocus: boolean) => {
setOpen(false)
if (restoreFocus) triggerRef.current?.focus()
}
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
})
useLayoutEffect(() => {
if (!open) return
const update = () => {
const root = rootRef.current
const menu = menuRef.current
if (!root || !menu) return
const bounds = (root.closest(".hm-sw-header") ?? document.documentElement).getBoundingClientRect()
// 1px inside the bounds so the outline ring (drawn 1px outside the list) never overflows.
const left = Math.max(bounds.left, 0) + 1
const right = Math.min(bounds.right, document.documentElement.clientWidth) - 1
const start = root.getBoundingClientRect().left
let next = Math.min(0, right - menu.offsetWidth - start)
if (start + next < left) next = left - start
setOffset(next > 0 ? Math.ceil(next) : Math.floor(next))
}
update()
window.addEventListener("resize", update)
return () => window.removeEventListener("resize", update)
}) // Host-driven reordering can move an open dropdown without a window resize.
useEffect(() => {
if (open && focusRowRef.current) {
focusRowRef.current = false
const current = rootRef.current?.querySelector<HTMLButtonElement>(".hm-sw-header__layer-row[data-active] .hm-sw-header__layer-item:not(:disabled)")
;(current ?? grid()[0]?.[0])?.focus()
}
})
/** Rows of focusable controls: each layer row (toggles + selection), then the footer. */
const grid = () => Array.from(rootRef.current?.querySelectorAll<HTMLElement>(".hm-sw-header__layer-row, .hm-sw-header__layer-footer") ?? [])
.map(row => Array.from(row.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")))
.filter(row => row.length > 0)
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape" && open) {
event.stopPropagation()
close(true)
return
}
const vertical = event.key === "ArrowDown" || event.key === "ArrowUp"
const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"
if (!vertical && !horizontal) return
if (!open) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
event.preventDefault()
focusRowRef.current = true
setOpen(true)
return
}
const rows = grid()
if (rows.length === 0) return
const active = document.activeElement as HTMLButtonElement
const rowIndex = rows.findIndex(row => row.includes(active))
event.preventDefault()
if (rowIndex < 0) {
// Focus on the field: enter the list.
if (vertical) rows[event.key === "ArrowDown" ? 0 : rows.length - 1][0]?.focus()
return
}
const row = rows[rowIndex]
const column = row.indexOf(active)
if (horizontal) {
row[(column + (event.key === "ArrowRight" ? 1 : -1) + row.length) % row.length]?.focus()
return
}
const target = rows[(rowIndex + (event.key === "ArrowDown" ? 1 : -1) + rows.length) % rows.length]
// Same column (bulb / snowflake / lock / name) in the next row; the footer has one button.
target[Math.min(column, target.length - 1)]?.focus()
}
const current = layers.find(item => item.id === activeLayerId)
return (
<div
ref={rootRef}
className="hm-sw-header__layer"
data-open={open ? "true" : undefined}
onKeyDown={onKeyDown}
onBlur={event => {
// Tabbing out of the field and its dropdown closes it; focus is never trapped.
if (open && !rootRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false)
}}
>
<SWHeaderLayerControl
buttonRef={triggerRef}
label={label}
value={current ? { name: current.name } : value}
state={current}
selected={selected || open}
expanded={open}
controls={open ? menuId : undefined}
disabled={disabled}
onClick={() => {
onClick?.()
setOpen(!open)
}}
/>
{open && (
<div
ref={menuRef}
id={menuId}
className="hm-sw-header__layer-menu"
role="group"
aria-label="레이어 목록"
data-shifted={offset !== 0 ? "true" : undefined}
style={offset !== 0 ? { left: offset } : undefined}
>
<div className="hm-sw-header__layer-panel">
{layers.length > 0 && (
<ul className="hm-sw-header__layer-list">
{layers.map(layer => (
<li
key={layer.id}
className="hm-sw-header__layer-row"
data-active={layer === current ? "true" : undefined}
data-disabled={layer.disabled ? "true" : undefined}
>
<span className="hm-sw-header__layer-toggles">
<SWHeaderLayerToggle kind="visibility" layer={layer} onChange={onVisibilityChange} />
<SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} />
<SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} />
</span>
<button
type="button"
className="hm-sw-header__layer-item"
title={layer.name}
aria-current={layer === current ? "true" : undefined}
disabled={layer.disabled}
onClick={() => {
onLayerSelect?.(layer)
close(true)
}}
>
<span className="hm-sw-header__layer-swatch" aria-hidden="true">
<span style={{ background: layer.color ?? "#ffffff" }} />
</span>
<span className="hm-sw-header__layer-name">{layer.name}</span>
</button>
</li>
))}
</ul>
)}
<div className="hm-sw-header__layer-footer">
<SWHeaderMenuDetail label="레이어 상세" className="hm-sw-header__layer-detail" disabled={disabled} onClick={onDetail && (() => { onDetail(); close(true) })} />
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,154 @@
import { useLayoutEffect, type CSSProperties } from "react"
import type { SWHeaderLineStyleValue, SWHeaderLineType, SWHeaderLineWeight } from "../types"
import { SWHeaderMenuDetail, useSWHeaderDropdown } from "./SWHeaderDropdown"
import { SWHeaderLineStyle } from "./SWHeaderFields"
import { SWHeaderInput, parseSWHeaderNumber } from "./SWHeaderInput"
/** Figma line type previews (header/menu/선스타일 26:11143): dash lengths of the 70px line, 4px gaps. */
export const swHeaderLinePatterns = {
/** CENTER: long 22 / short 7. */
center: [22, 7, 22, 7],
/** HIDDEN: two 33px dashes. */
hidden: [33, 33],
} as const satisfies Record<string, readonly number[]>
/** 70 x 2 line preview with a 1px black outline (Figma Rectangle 7532, stroke outside). */
export function SWHeaderLinePreview({ pattern }: { pattern?: readonly number[] }) {
const segments = pattern && pattern.length > 0 ? pattern : [1]
return (
<span className="hm-sw-header__line-preview" aria-hidden="true">
{segments.map((length, index) => <i key={index} style={{ flexGrow: length }} />)}
</span>
)
}
export interface SWHeaderLineStyleMenuProps {
/** Values shown in the field (line style / weight / scale) and in the scale input. */
label?: string
value: SWHeaderLineStyleValue
/** Line type column. Host data. */
lineTypes?: SWHeaderLineType[]
/** Line weight column. Host data. */
lineWeights?: SWHeaderLineWeight[]
/** Current line type; defaults to the row named `value.name`. */
activeLineTypeId?: string
/** Current line weight; defaults to the row named `value.weight`. */
activeLineWeightId?: string
/** Controlled open state. Leave undefined to let the component manage it. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** Called on every click of the field (also when the dropdown toggles). */
onClick?: () => void
/** Keeps the field in its selected (#FFC600) look while closed. */
selected?: boolean
disabled?: boolean
onLineTypeSelect?: (lineType: SWHeaderLineType) => void
onLineWeightSelect?: (weight: SWHeaderLineWeight) => void
onLineWeightValueChange?: (weight: SWHeaderLineWeight, value: string) => void
onScaleChange?: (value: string) => void
/** "선스타일 상세". Optional host action; omitting it keeps hover and performs no CAD action. */
onDetail?: () => void
}
/**
* Line style field with its 3-column dropdown (Figma header/menu/선스타일 26:11104; line type rows
* header/dropdown/brown/row 26:10702): line type 182 · weight 142 · scale 142, centered under the field.
* Each column changes only its own property and the dropdown stays open. Rows keep their Figma hover
* (#62533D, #FF5C00 text and line); selected rows retain the same emphasis after mouse leave.
*/
export function SWHeaderLineStyleMenu({
value, label, lineTypes = [], lineWeights = [], activeLineTypeId, activeLineWeightId, open: openProp, onOpenChange, onClick,
selected = false, disabled = false, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onScaleChange, onDetail,
}: SWHeaderLineStyleMenuProps) {
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
const currentType = lineTypes.find(item => (activeLineTypeId === undefined ? item.name === value.name : item.id === activeLineTypeId))
const currentWeight = lineWeights.find(item => (activeLineWeightId === undefined ? item.name === value.weight : item.id === activeLineWeightId))
// A promoted field can shrink below 284px. Paint only its real contact width, without changing
// the shared dropdown alignment or the rest of the yellow outline.
useLayoutEffect(() => {
const field = triggerRef.current
const panel = panelRef.current
if (!open || !field || !panel) return
const update = () => panel.style.setProperty("--hm-sw-line-seam-width", `${field.getBoundingClientRect().width}px`)
update()
const observer = new ResizeObserver(update)
observer.observe(field)
return () => observer.disconnect()
}, [open, triggerRef, panelRef])
return (
<div {...rootProps} className="hm-sw-header__menu hm-sw-header__menu--line-style" data-menu="line-style" data-tier="aux">
<SWHeaderLineStyle
buttonRef={triggerRef}
label={label}
value={value}
selected={selected || open}
expanded={open}
controls={open ? menuId : undefined}
disabled={disabled}
onClick={() => {
onClick?.()
setOpen(!open)
}}
/>
{open && (
<div ref={panelRef} id={menuId} className="hm-sw-header__menu-panel hm-sw-header__line-menu" role="group" aria-label="선스타일"
style={{ ...panelStyle, "--hm-sw-line-seam-left": `${-(panelStyle?.left ?? 0)}px` } as CSSProperties}>
<div className="hm-sw-header__line-drop">
<div className="hm-sw-header__line-column hm-sw-header__line-column--type">
<ul className="hm-sw-header__menu-list" aria-label="선 종류">
{lineTypes.map(item => (
<li key={item.id} className="hm-sw-header__menu-row hm-sw-header__menu-row--line" data-active={item === currentType ? "true" : undefined} data-disabled={item.disabled ? "true" : undefined}>
<button type="button" className="hm-sw-header__menu-item" title={item.name} aria-current={item === currentType ? "true" : undefined} disabled={item.disabled} onClick={() => onLineTypeSelect?.(item)}>
<SWHeaderLinePreview pattern={item.pattern} />
<span className="hm-sw-header__menu-text hm-sw-header__menu-name">{item.name}</span>
</button>
</li>
))}
</ul>
<div className="hm-sw-header__menu-footer">
<SWHeaderMenuDetail label="선스타일 상세" onClick={onDetail && (() => { onDetail(); close(true) })} />
</div>
</div>
<div className="hm-sw-header__line-column">
<ul className="hm-sw-header__menu-list" aria-label="가중치">
{lineWeights.map(item => (
<li key={item.id} className="hm-sw-header__menu-row hm-sw-header__menu-row--weight" data-active={item === currentWeight ? "true" : undefined} data-disabled={item.disabled ? "true" : undefined}>
<button type="button" className="hm-sw-header__menu-item" title={item.name} aria-current={item === currentWeight ? "true" : undefined} disabled={item.disabled} onClick={() => onLineWeightSelect?.(item)}>
<span className="hm-sw-header__menu-text hm-sw-header__menu-name">{item.name}</span>
{item.value !== undefined && !item.editable && <span className="hm-sw-header__menu-text hm-sw-header__menu-value">{item.value}</span>}
</button>
{item.editable && (
// Sibling of the row button (no nested controls): editing never selects the row.
<SWHeaderInput
aria-label={`${item.name} 가중치`}
valueType="number"
value={item.value ?? ""}
disabled={item.disabled}
parse={draft => parseSWHeaderNumber(draft, { min: 0 })}
onCommit={onLineWeightValueChange && (next => onLineWeightValueChange(item, next))}
/>
)}
</li>
))}
</ul>
</div>
<div className="hm-sw-header__line-column">
<div className="hm-sw-header__menu-row hm-sw-header__menu-row--weight">
<span className="hm-sw-header__menu-label hm-sw-header__menu-text hm-sw-header__menu-name">스케일</span>
<SWHeaderInput
aria-label="스케일"
valueType="number"
value={value.scale}
parse={draft => parseSWHeaderNumber(draft, { min: 0, minExclusive: true })}
onCommit={onScaleChange}
/>
</div>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,118 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
export interface SWHeaderMoreItem {
visible?: boolean
id: string
label: string
/** Current value shown at the right (e.g. the line style name). */
value?: string
/**
* Header width where the item appears: `aux` < 1800, `panel` < 1080, `narrow` < 900, `action` < 640.
* `slot`: only while another field has taken this field's place (the layer field, see SWHeader).
*/
tier: "aux" | "panel" | "narrow" | "action" | "slot"
/** Field this item stands for (`data-menu` of the field), used by the representative-slot CSS. */
menu?: string
/** Renders a checkable item. */
checked?: boolean
selected?: boolean
disabled?: boolean
onSelect?: () => void
/** Reuse the K guide when its original button has moved into More. */
guide?: boolean
}
export interface SWHeaderMoreProps {
items: SWHeaderMoreItem[]
}
/** Overflow menu that keeps every moved control reachable. */
export function SWHeaderMore({ items }: SWHeaderMoreProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
const guideId = useId()
const guideRef = useRef<HTMLButtonElement>(null)
const [guideOpen, setGuideOpen] = useState(false)
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [open])
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && open) {
event.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
} else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && open) {
event.preventDefault()
const entries = visibleItems()
const index = entries.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === "ArrowDown" ? (index + 1) % entries.length : (index - 1 + entries.length) % entries.length
entries[next]?.focus()
}
}
return (
<div ref={rootRef} className="hm-sw-header__more" onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
<button
ref={triggerRef}
type="button"
className="hm-sw-header__more-trigger"
aria-label="더보기"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => setOpen(value => !value)}
>
<svg width="16" height="4" viewBox="0 0 16 4" aria-hidden="true" focusable="false">
<circle cx="2" cy="2" r="1.6" fill="currentColor" />
<circle cx="8" cy="2" r="1.6" fill="currentColor" />
<circle cx="14" cy="2" r="1.6" fill="currentColor" />
</svg>
</button>
{open && (
<div id={menuId} role="menu" aria-label="추가 도구" className="hm-sw-header__more-menu">
{items.map(item => (
<button
key={item.id}
ref={item.guide ? guideRef : undefined}
type="button"
role={item.checked === undefined ? "menuitem" : "menuitemcheckbox"}
aria-checked={item.checked}
className="hm-sw-header__more-item"
data-tier={item.tier}
data-visible={item.visible}
hidden={item.visible === false}
data-menu={item.menu}
data-selected={item.selected || item.checked ? "true" : undefined}
disabled={item.disabled}
aria-describedby={item.guide && guideOpen ? guideId : undefined}
onPointerEnter={() => setGuideOpen(!!item.guide && !item.disabled)}
onFocus={() => setGuideOpen(!!item.guide && !item.disabled)}
onBlur={event => { if (!rootRef.current?.contains(event.relatedTarget as Node | null)) setGuideOpen(false) }}
onClick={() => {
item.onSelect?.()
setOpen(false)
}}
>
<span>{item.label}</span>
{item.value && <span className="hm-sw-header__more-value">{item.value}</span>}
</button>
))}
{guideOpen && <SWHeaderKGuidePreview anchorRef={guideRef} id={guideId} />}
</div>
)}
</div>
)
}
@@ -0,0 +1,30 @@
import { useId } from "react"
export interface SWHeaderPropertiesProps {
barOpen?: boolean
panelOpen?: boolean
panelId?: string
disabled?: boolean
onBarToggle?: () => void
onPanelToggle?: () => void
/** Individual parts (default true). Removed parts take no space; with one toggle the divider is removed. */
showCaption?: boolean
showBar?: boolean
showPanel?: boolean
}
/** `상세보기` caption above the 속성Bar / 속성창 toggle pair. */
export function SWHeaderProperties({ barOpen = false, panelOpen = false, panelId, disabled, onBarToggle, onPanelToggle, showCaption = true, showBar = true, showPanel = true }: SWHeaderPropertiesProps) {
const captionId = useId()
if (!showCaption && !showBar && !showPanel) return null
return (
<div className="hm-sw-header__properties" role="group" aria-labelledby={showCaption ? captionId : undefined} aria-label={showCaption ? undefined : "상세보기"} data-partial={showCaption && showBar && showPanel ? undefined : ""}>
{showCaption && <span id={captionId} className="hm-sw-header__properties-caption">상세보기</span>}
{(showBar || showPanel) && <div className="hm-sw-header__properties-row">
{showBar && <button type="button" className="hm-sw-header__toggle" aria-pressed={barOpen} data-selected={barOpen ? "true" : undefined} disabled={disabled} onClick={onBarToggle}>속성Bar</button>}
{showBar && showPanel && <span className="hm-sw-header__properties-divider" aria-hidden="true" />}
{showPanel && <button type="button" className="hm-sw-header__toggle" data-property-panel-trigger="" aria-pressed={panelOpen} aria-haspopup={panelId ? "dialog" : undefined} aria-expanded={panelId ? panelOpen : undefined} aria-controls={panelOpen ? panelId : undefined} data-selected={panelOpen ? "true" : undefined} disabled={disabled} onClick={onPanelToggle}>속성창</button>}
</div>}
</div>
)
}
@@ -0,0 +1,53 @@
import { useId, type HTMLAttributes } from "react"
import { SWHeaderInput, parseSWHeaderColor, parseSWHeaderNumber } from "./SWHeaderInput"
import type { SWHeaderProperty, SWHeaderPropertySelection } from "./SWHeaderPropertyPanel"
export interface SWHeaderPropertyBarProps extends HTMLAttributes<HTMLDivElement> {
selection: SWHeaderPropertySelection
/** Same host-owned records as the property panel; array order is retained unless order is set. */
properties: readonly SWHeaderProperty[]
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
readOnly?: boolean
disabled?: boolean
}
function BarField({ property, onPropertyChange, readOnly, disabled }: Pick<SWHeaderPropertyBarProps, "onPropertyChange" | "readOnly" | "disabled"> & { property: SWHeaderProperty }) {
const id = useId()
const editable = !readOnly && !disabled && !property.readOnly && !property.disabled && property.type !== "computed"
const parse = (draft: string) => {
if (property.validate && !property.validate(draft)) return null
if (property.type === "number" || property.type === "slider") return parseSWHeaderNumber(draft, { min: property.min, max: property.max })
if (property.type === "color") return parseSWHeaderColor(draft)?.value ?? null
if (property.type === "select") return property.options?.some(option => option.id === draft && !option.disabled) ? draft : null
return draft.trim() || null
}
return (
<div className="hm-sw-property-bar__field" data-property-id={property.id}>
<label htmlFor={id}>{property.name}</label>
<SWHeaderInput id={id} value={property.mixed ? "다양함" : property.value}
valueType={property.type === "number" || property.type === "slider" ? "number" : "text"}
disabled={disabled || property.disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable && (onPropertyChange || property.onCommit) ? next => {
property.onCommit?.(next, property)
onPropertyChange?.(property, next)
} : undefined} />
{property.unit && <span>{property.unit}</span>}
</div>
)
}
/** Figma 26:10492: a separate 28px editing strip; no CAD values or calculations are built in. */
export function SWHeaderPropertyBar({ selection, properties, onPropertyChange, readOnly, disabled, className, ...rest }: SWHeaderPropertyBarProps) {
return (
<div {...rest} className={`hm-sw-property-bar${className ? ` ${className}` : ""}`} role="region" aria-label={rest["aria-label"] ?? "속성Bar"} tabIndex={0}>
<div className="hm-sw-property-bar__content" key={selection.id}>
<span className="hm-sw-property-bar__object" title={selection.title}>{selection.title}</span>
<div className="hm-sw-property-bar__fields">
{[...properties].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map(property => (
<BarField key={property.id} property={property} onPropertyChange={onPropertyChange} readOnly={readOnly} disabled={disabled} />
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,257 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"
import circleGraphic from "../../../assets/property-circle.svg"
import titleGraphic from "../../../assets/property-title.svg"
import singleTitleGraphic from "../../../assets/property-title-single.svg"
import footerGraphic from "../../../assets/property-footer.svg"
import expandGraphic from "../../../assets/property-expand.svg"
import toolDefault from "../../../assets/property-tool.svg"
import toolHover from "../../../assets/property-tool-hover.svg"
import toolActiveGraphic from "../../../assets/property-tool-active.svg"
import closeDefault from "../../../assets/property-close.svg"
import closeHover from "../../../assets/property-close-hover.svg"
import { SWHeaderInput, parseSWHeaderColor, parseSWHeaderNumber } from "./SWHeaderInput"
import { SWHeaderLinePreview } from "./SWHeaderLineStyleMenu"
export type SWHeaderPropertyType = "text" | "number" | "select" | "color" | "slider" | "computed"
export interface SWHeaderProperty {
id: string
name: string
value: string
type: SWHeaderPropertyType
groupId: string
order?: number
readOnly?: boolean
disabled?: boolean
mixed?: boolean
unit?: string
annotation?: string
min?: number
max?: number
step?: number
options?: readonly { id: string; name: string; disabled?: boolean }[]
/** Optional color swatch / original line pattern, supplied by the host. */
swatch?: string
linePattern?: readonly number[]
linePreview?: boolean
graphicSlot?: "circumference" | "diameter" | "radius" | "area"
validate?: (draft: string) => boolean
onCommit?: (value: string, property: SWHeaderProperty) => void
}
export interface SWHeaderPropertyGroup {
id: string
name?: string
order?: number
layout?: "rows" | "circle" | "position"
/** Rows beyond this count are revealed by the expansion button. */
collapsedCount?: number
positionLabel?: string
}
export interface SWHeaderPropertySelection {
/** Change whenever the selected objects change, even if their property ids match. */
id: string
title: string
kind: "none" | "all" | "single"
counts?: readonly { id: string; name: string; count: number; row?: number; column?: number }[]
/** Host-owned return to the overall selection; no CAD selection is performed internally. */
overviewTitle?: string
}
/** What a shape slot receives: the selection, its group and that group's properties (values stay in the rows). */
export interface SWHeaderPropertyShapeContext {
selection: SWHeaderPropertySelection
group: SWHeaderPropertyGroup
properties: readonly SWHeaderProperty[]
}
/**
* Shape area of a `layout: "circle"` group (Figma 26:11932, 208 x 208 inside the 260px shape group).
* undefined: the Figma circle drawing. null / false: the empty area (size, value labels and panel height
* are kept). A node or render function: host content (e.g. a shape viewer), fitted into the area.
*/
export type SWHeaderPropertyShapeContent = ReactNode | ((context: SWHeaderPropertyShapeContext) => ReactNode)
export interface SWHeaderPropertyPanelProps {
selection: SWHeaderPropertySelection
properties: readonly SWHeaderProperty[]
groups: readonly SWHeaderPropertyGroup[]
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
expandedGroupIds?: readonly string[]
defaultExpandedGroupIds?: readonly string[]
onExpandedGroupsChange?: (ids: string[]) => void
toolActive?: boolean
onToolActiveChange?: (active: boolean) => void
toolLabel?: string
onOverviewClick?: () => void
/** Replaceable shape area; see SWHeaderPropertyShapeContent. Independent of the property values. */
shapeContent?: SWHeaderPropertyShapeContent
onClose?: () => void
id?: string
/** Supplied by SWHeader; standalone use can position the panel with style. */
anchorRef?: RefObject<HTMLElement | null>
style?: CSSProperties
}
const ordered = <T extends { order?: number }>(items: readonly T[]) => [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
/** Viewport-bound overlay position shared by the property tool window and K guide. No portal or dim. */
export function useSWHeaderOverlay(anchorRef: RefObject<HTMLElement | null> | undefined, width: number, offset = 0) {
const panelRef = useRef<HTMLDivElement>(null)
const [style, setStyle] = useState<CSSProperties>({ visibility: anchorRef ? "hidden" : undefined })
useLayoutEffect(() => {
if (!anchorRef) return
const update = () => {
const anchor = anchorRef.current
if (!anchor) return
const header = anchor.closest(".hm-sw-header")
const bounds = (header ?? document.documentElement).getBoundingClientRect()
const preferred = width === 314 ? anchor.querySelector<HTMLElement>('[data-property-panel-trigger]') : null
const more = width === 314 ? anchor.querySelector<HTMLElement>('.hm-sw-header__more-trigger') : null
const target = (preferred?.getClientRects().length ? preferred : more ?? anchor).getBoundingClientRect()
const viewportWidth = document.documentElement.clientWidth
const actualWidth = Math.min(width, viewportWidth - 8, bounds.width - 8)
const left = Math.max(Math.max(bounds.left, 0) + 4, Math.min(target.left + offset, Math.min(bounds.right, viewportWidth) - actualWidth - 4))
const top = Math.max(4, Math.min(target.bottom, window.innerHeight - (width === 208 ? 153 : 34)))
setStyle({ position: "fixed", left, top, width: actualWidth, maxHeight: Math.max(30, window.innerHeight - top - 4) })
}
update()
const observer = new ResizeObserver(update)
if (anchorRef.current) observer.observe(anchorRef.current.closest(".hm-sw-header") ?? anchorRef.current)
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => { observer.disconnect(); window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true) }
}, [anchorRef, width, offset])
return { panelRef, style }
}
function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; onChange?: SWHeaderPropertyPanelProps["onPropertyChange"] }) {
const inputId = useId()
const { type, value, disabled, mixed, readOnly, unit } = property
const [draft, setDraft] = useState(value)
useEffect(() => setDraft(value), [value, property.options, disabled, readOnly])
const pendingRef = useRef<string | null>(null)
useEffect(() => { pendingRef.current = null }, [value])
const editable = !disabled && !readOnly && type !== "computed" && (!!onChange || !!property.onCommit)
const parse = (draft: string) => {
if (property.validate && !property.validate(draft)) return null
if (type === "number" || type === "slider") return parseSWHeaderNumber(draft, { min: property.min, max: property.max })
if (type === "color") return parseSWHeaderColor(draft)?.value ?? null
if (type === "select") return property.options?.some(option => option.id === draft && !option.disabled) ? draft : null
return draft.trim()
}
const commit = (next: string) => {
if (!editable || next === value || next === pendingRef.current) return
if (parse(next) === null) { setDraft(value); return }
pendingRef.current = next
property.onCommit?.(next, property)
onChange?.(property, next)
}
const cancelDraft = () => setDraft(value)
const nativeKeys = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return
if (event.key === "Enter") { event.preventDefault(); commit(draft) }
if (event.key === "Escape" && draft !== value) { event.stopPropagation(); cancelDraft() }
}
return (
<div className="hm-sw-property__row" data-property-id={property.id} data-type={type} data-disabled={disabled || undefined}>
<label htmlFor={inputId} title={property.name}>{property.name}</label>
<div className="hm-sw-property__value">
{type === "select" ? (
<select id={inputId} aria-label={property.name} value={mixed && draft === value ? "__mixed" : draft} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys}>
{mixed && <option value="__mixed" disabled>다양함</option>}
{!mixed && !property.options?.some(option => option.id === value) && <option value={value}>{value}</option>}
{property.options?.map(option => <option key={option.id} value={option.id} disabled={option.disabled}>{option.name}</option>)}
</select>
) : (
<SWHeaderInput
id={inputId} aria-label={property.name} value={mixed ? "다양함" : value}
valueType={type === "number" || type === "slider" ? "number" : "text"}
disabled={disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable ? commit : undefined}
/>
)}
{property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
{unit && type !== "slider" && <span className="hm-sw-property__unit">{unit}</span>}
{type === "color" && <span className="hm-sw-property__swatch" aria-hidden="true"><i style={{ backgroundColor: property.swatch ?? parseSWHeaderColor(value)?.hex ?? "transparent" }} /></span>}
{property.linePreview && <SWHeaderLinePreview pattern={property.linePattern} />}
{type === "slider" && <input type="range" aria-label={`${property.name} 슬라이더`} min={property.min ?? 0} max={property.max ?? 100} step={property.step ?? 1} value={mixed ? property.min ?? 0 : draft} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys} />}
{unit && type === "slider" && <span className="hm-sw-property__unit">{unit}</span>}
</div>
</div>
)
}
/** A single data-driven, non-modal property tool window (Figma 26:11822). */
export function SWHeaderPropertyPanel({
selection, properties, groups, onPropertyChange, expandedGroupIds, defaultExpandedGroupIds = [],
onExpandedGroupsChange, toolActive: controlledTool, onToolActiveChange, toolLabel = "객체 선택 도구",
onOverviewClick, shapeContent, onClose, id, anchorRef, style: customStyle,
}: SWHeaderPropertyPanelProps) {
const titleId = useId()
const [expanded, setExpanded] = useState<string[]>([...defaultExpandedGroupIds])
const [internalTool, setInternalTool] = useState(false)
const active = controlledTool ?? internalTool
const expandedIds = expandedGroupIds ?? expanded
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
useLayoutEffect(() => { if (anchorRef) panelRef.current?.focus() }, [anchorRef, panelRef])
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented && !event.isComposing) onClose?.()
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [onClose])
const toggleGroup = (groupId: string) => {
const next = expandedIds.includes(groupId) ? expandedIds.filter(id => id !== groupId) : [...expandedIds, groupId]
if (expandedGroupIds === undefined) setExpanded(next)
onExpandedGroupsChange?.(next)
}
return (
<div ref={panelRef} id={id} tabIndex={-1} className="hm-sw-property" data-selection-kind={selection.kind} role="dialog" aria-modal="false" aria-labelledby={titleId} style={{ ...style, ...customStyle }} onKeyDown={event => {
if (event.key === "Escape" && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
}}>
<div className="hm-sw-property__titlebar">
<img className="hm-sw-property__title-art" src={selection.kind === "single" ? singleTitleGraphic : titleGraphic} alt="" />
<span id={titleId} className="hm-sw-property__title" title={selection.title}>{selection.title}</span>
{selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
if (controlledTool === undefined) setInternalTool(!active)
onToolActiveChange?.(!active)
}}>
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
</button>}
<button type="button" className="hm-sw-property__close" aria-label="속성창 닫기" onClick={onClose}>
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
</button>
</div>
<div className="hm-sw-property__body" key={selection.id}>
{ordered(groups).map(group => {
const items = ordered(properties.filter(property => property.groupId === group.id))
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
const isExpanded = expandedIds.includes(group.id)
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
return (
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
{group.name && <h3>{group.name}</h3>}
<div className="hm-sw-property__group-content">
{group.layout === "circle" && (() => {
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
const fallback = shapeContent === undefined
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
</div>
})()}
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
<div className="hm-sw-property__editors">
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} /></div>)}
</div>
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
</div>
</section>
)
})}
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
</div>
{selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}
</div>
)
}
@@ -0,0 +1,150 @@
import type { ReactNode, Ref } from "react"
import dimensionRowIcon from "../../../assets/menu-dimension-row.svg"
import dimensionRowIconHover from "../../../assets/menu-dimension-row-hover.svg"
import type {
SWHeaderDimensionStyleOption, SWHeaderDimensionStyleValue, SWHeaderTextStyleOption, SWHeaderTextStyleValue,
} from "../types"
import { SWHeaderMenuDetail, useSWHeaderDropdown, type SWHeaderDropdownOptions } from "./SWHeaderDropdown"
import { SWHeaderDimensionStyle, SWHeaderTextStyle } from "./SWHeaderFields"
interface ListMenuBase extends Pick<SWHeaderDropdownOptions, "open" | "onOpenChange" | "disabled"> {
/** Called on every click of the field (also when the dropdown toggles). */
onClick?: () => void
/** Keeps the field in its selected (#FFC600) look while closed. */
label?: string
selected?: boolean
/** Optional footer host action. All detail buttons remain hoverable without a host callback. */
onDetail?: () => void
}
interface StyleListMenuProps<T extends { id: string; name: string; disabled?: boolean }> extends ListMenuBase {
variant: "text-style" | "dimension-style"
label: string
items: T[]
current?: T
onSelect?: (item: T) => void
/** Field button (receives the dropdown wiring). */
field: (wiring: { buttonRef: Ref<HTMLButtonElement>; expanded: boolean; controls?: string; selected: boolean; disabled: boolean; onClick: () => void }) => ReactNode
/** Row content before the name. */
preview: (item: T) => ReactNode
}
/**
* Single-list style dropdown (text / dimension style): 164px rows of 30px with their Figma hover
* (#62533D, #FF5C00), a 40px footer with the "상세" pill. Choosing a row reports it and closes the list.
*/
function StyleListMenu<T extends { id: string; name: string; disabled?: boolean }>({
variant, label, items, current, onSelect, field, preview, open: openProp, onOpenChange, onClick, selected = false, disabled = false, onDetail,
}: StyleListMenuProps<T>) {
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, shifted, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled })
return (
<div {...rootProps} className={`hm-sw-header__menu hm-sw-header__menu--${variant}`} data-menu={variant} data-tier="aux">
{field({
buttonRef: triggerRef,
expanded: open,
controls: open ? menuId : undefined,
selected: selected || open,
disabled,
onClick: () => {
onClick?.()
setOpen(!open)
},
})}
{open && (
<div
ref={panelRef}
id={menuId}
className={`hm-sw-header__menu-panel hm-sw-header__list-menu hm-sw-header__list-menu--${variant}`}
role="group"
aria-label={label}
data-shifted={shifted ? "true" : undefined}
style={panelStyle}
>
{items.length > 0 && (
<ul className="hm-sw-header__menu-list">
{items.map(item => (
<li key={item.id} className="hm-sw-header__menu-row hm-sw-header__menu-row--style" data-active={item === current ? "true" : undefined} data-disabled={item.disabled ? "true" : undefined}>
<button
type="button"
className="hm-sw-header__menu-item"
title={item.name}
aria-current={item === current ? "true" : undefined}
disabled={item.disabled}
onClick={() => {
onSelect?.(item)
close(true)
}}
>
{preview(item)}
<span className="hm-sw-header__menu-text hm-sw-header__menu-name">{item.name}</span>
</button>
</li>
))}
</ul>
)}
<div className="hm-sw-header__menu-footer">
<SWHeaderMenuDetail label={`${label} 상세`} onClick={onDetail && (() => { onDetail(); close(true) })} />
</div>
</div>
)}
</div>
)
}
export interface SWHeaderTextStyleMenuProps extends ListMenuBase {
value: SWHeaderTextStyleValue
/** Rows (Figma: Arial / Standard, 돋움 / Dotum). Host data. */
textStyles: SWHeaderTextStyleOption[]
/** Current style; defaults to the row named `value.name`. */
activeTextStyleId?: string
onTextStyleSelect?: (style: SWHeaderTextStyleOption) => void
}
/** Text style field with its dropdown (Figma header/menu/문자스타일 26:11181): 16px font sample + style name. */
export function SWHeaderTextStyleMenu({ value, label = "문자스타일", textStyles, activeTextStyleId, onTextStyleSelect, ...rest }: SWHeaderTextStyleMenuProps) {
const current = textStyles.find(item => (activeTextStyleId === undefined ? item.name === value.name : item.id === activeTextStyleId))
return (
<StyleListMenu
{...rest}
variant="text-style"
label={label}
items={textStyles}
current={current}
onSelect={onTextStyleSelect}
field={wiring => <SWHeaderTextStyle label={label} value={value} {...wiring} />}
preview={item => <span className="hm-sw-header__menu-text hm-sw-header__menu-font" title={item.font}>{item.font}</span>}
/>
)
}
export interface SWHeaderDimensionStyleMenuProps extends ListMenuBase {
value: SWHeaderDimensionStyleValue
/** Rows (Figma: Standard, ISO - 25). Host data. */
dimensionStyles: SWHeaderDimensionStyleOption[]
/** Current style; defaults to the row named `value.name`. */
activeDimensionStyleId?: string
onDimensionStyleSelect?: (style: SWHeaderDimensionStyleOption) => void
}
/** Dimension style field with its dropdown (Figma header/menu/치수스타일 26:11215): dimension sample + style name. */
export function SWHeaderDimensionStyleMenu({ value, label = "치수스타일", dimensionStyles, activeDimensionStyleId, onDimensionStyleSelect, ...rest }: SWHeaderDimensionStyleMenuProps) {
const current = dimensionStyles.find(item => (activeDimensionStyleId === undefined ? item.name === value.name : item.id === activeDimensionStyleId))
return (
<StyleListMenu
{...rest}
variant="dimension-style"
label={label}
items={dimensionStyles}
current={current}
onSelect={onDimensionStyleSelect}
field={wiring => <SWHeaderDimensionStyle label={label} value={value} {...wiring} />}
preview={() => (
// Figma row asset (#F6F4F2) and its hover variant (#FF5C00): only the foreground fill differs.
<span className="hm-sw-header__menu-dimension" aria-hidden="true">
<img className="hm-sw-header__menu-dimension-default" src={dimensionRowIcon} alt="" />
<img className="hm-sw-header__menu-dimension-hover" src={dimensionRowIconHover} alt="" />
</span>
)}
/>
)
}
@@ -0,0 +1,43 @@
import type { PointerEventHandler } from "react"
import windowMotion from "../../../assets/window-motion.svg"
import windowMotionWindow from "../../../assets/window-motion-window.svg"
import windowMotionCursor from "../../../assets/window-motion-cursor.svg"
import closeIcon from "../../../assets/close.svg"
import closeIconHover from "../../../assets/close-hover.svg"
export interface SWHeaderWindowControlsProps {
onWindowMoveStart?: PointerEventHandler<HTMLDivElement>
onMinimize?: () => void
onClose?: () => void
/** Individual parts (default true). Removed parts take no space. */
showWindowMove?: boolean
showMinimize?: boolean
showClose?: boolean
}
/**
* Window-move area plus minimize and close. Window behavior is owned by the host.
* The move area plays the Figma "창이동 motion" hover (26:10220) in CSS only; its graphics and
* tooltip ignore the pointer, so pointer down always reaches the area.
*/
export function SWHeaderWindowControls({ onWindowMoveStart, onMinimize, onClose, showWindowMove = true, showMinimize = true, showClose = true }: SWHeaderWindowControlsProps) {
return (
<>
{showWindowMove && <div className="hm-sw-header__motion" data-sw-window-move="" role="presentation" onPointerDown={onWindowMoveStart}>
<img className="hm-sw-header__motion-icon" src={windowMotion} alt="" aria-hidden="true" />
<span className="hm-sw-header__motion-window" aria-hidden="true"><img src={windowMotionWindow} alt="" /></span>
<span className="hm-sw-header__motion-cursor" aria-hidden="true"><img src={windowMotionCursor} alt="" /></span>
<span className="hm-sw-header__motion-tooltip" role="tooltip">창 이동</span>
</div>}
{(showMinimize || showClose) && <div className="hm-sw-header__window-buttons" data-partial={showMinimize && showClose ? undefined : ""}>
{showMinimize && <button type="button" className="hm-sw-header__window-button hm-sw-header__window-button--minimize" aria-label="최소화" onClick={onMinimize}>
<span aria-hidden="true" />
</button>}
{showClose && <button type="button" className="hm-sw-header__window-button hm-sw-header__window-button--close" aria-label="종료" onClick={onClose}>
<img className="hm-sw-header__icon" src={closeIcon} alt="" aria-hidden="true" />
<img className="hm-sw-header__icon-hover" src={closeIconHover} alt="" aria-hidden="true" />
</button>}
</div>}
</>
)
}
@@ -0,0 +1,33 @@
export { SWHeader } from "./SWHeader"
export { SWHeaderHome, type SWHeaderHomeProps } from "./components/SWHeaderHome"
export { SWHeaderDocumentTitle, type SWHeaderDocumentTitleProps } from "./components/SWHeaderDocumentTitle"
export {
SWHeaderLayerControl, SWHeaderColorControl, SWHeaderLineStyle,
SWHeaderTextStyle, SWHeaderDimensionStyle, type SWHeaderLayerControlProps,
} from "./components/SWHeaderFields"
export { SWHeaderLayerMenu, type SWHeaderLayerMenuProps } from "./components/SWHeaderLayerMenu"
export { SWHeaderColorMenu, type SWHeaderColorMenuProps } from "./components/SWHeaderColorMenu"
export {
SWHeaderLineStyleMenu, SWHeaderLinePreview, swHeaderLinePatterns, type SWHeaderLineStyleMenuProps,
} from "./components/SWHeaderLineStyleMenu"
export {
SWHeaderTextStyleMenu, SWHeaderDimensionStyleMenu,
type SWHeaderTextStyleMenuProps, type SWHeaderDimensionStyleMenuProps,
} from "./components/SWHeaderStyleListMenus"
export {
SWHeaderInput, parseSWHeaderColor, parseSWHeaderNumber,
type SWHeaderInputProps, type SWHeaderInputValueType,
} from "./components/SWHeaderInput"
export { SWHeaderProperties, type SWHeaderPropertiesProps } from "./components/SWHeaderProperties"
export { SWHeaderPropertyPanel, type SWHeaderPropertyPanelProps, type SWHeaderProperty, type SWHeaderPropertyType, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyShapeContent, type SWHeaderPropertyShapeContext } from "./components/SWHeaderPropertyPanel"
export { SWHeaderActions, type SWHeaderActionsProps } from "./components/SWHeaderActions"
export { SWHeaderWindowControls, type SWHeaderWindowControlsProps } from "./components/SWHeaderWindowControls"
export type {
SWHeaderProps, SWHeaderMenuId, SWHeaderLayerValue, SWHeaderLayer, SWHeaderColorValue,
SWHeaderLineStyleValue, SWHeaderTextStyleValue, SWHeaderDimensionStyleValue, SWHeaderDocument,
SWHeaderColorMode, SWHeaderColorOption, SWHeaderColorInputValue, SWHeaderLineType, SWHeaderLineWeight,
SWHeaderTextStyleOption, SWHeaderDimensionStyleOption,
} from "./types"
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId } from "./types"
@@ -0,0 +1,329 @@
import type { HTMLAttributes, PointerEventHandler, ReactNode } from "react"
import type { SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
import type { SWHeaderPropertyPanelProps } from "./components/SWHeaderPropertyPanel"
/** Header controls that can be shown as selected (text and arrow turn #FFC600). */
export type SWHeaderMenuId = "layer" | "color" | "lineStyle" | "textStyle" | "dimensionStyle"
export interface SWHeaderLayerValue {
/** Current layer name. */
name: string
}
/**
* One row of the layer dropdown (Figma header/menu/레이어 26:10940). The host program owns the data:
* the toggles only report the next value through callbacks; the header never changes a layer itself.
*/
export interface SWHeaderLayer {
/** Stable key, returned with every callback. */
id: string
/** Layer name (ellipsis when long). */
name: string
/** CSS color of the 16 x 16 swatch. Defaults to `#ffffff`. */
color?: string
/** Shown: colored bulb when true, gray when false. A click reports `!visible`. Default `true`. */
visible?: boolean
/** Frozen: gray snowflake when true, colored (thawed) when false. A click reports `!frozen`. Default `false`. */
frozen?: boolean
/** Locked: gray lock when true, colored (unlocked) when false. A click reports `!locked`. Default `false`. */
locked?: boolean
/** Disables selecting the row and its three toggles. */
disabled?: boolean
}
export interface SWHeaderColorValue {
/** Display text, e.g. `255,229,0`. */
value: string
/** CSS color of the swatch. Defaults to `#ffe500`. */
swatch?: string
}
export interface SWHeaderLineStyleValue {
name: string
/** Line weight text, e.g. `0.25`. */
weight: string
/** Line scale text, e.g. `1.00`. */
scale: string
}
export interface SWHeaderTextStyleValue {
/** Font name shown at the left of the field, e.g. `Arial`. */
font: string
/** Text style name, e.g. `Standard`. */
name: string
}
/** One entry of the document dropdown (no file type or product assumed). */
export interface SWHeaderDocument {
/** Stable key, returned with the selection. */
id: string
/** Display name (ellipsis when long). */
name: string
/** Tooltip; defaults to `name` (e.g. a full path). */
title?: string
/** Icon in the 29 x 12 slot; defaults to `documentIcon` or the Figma document icon. */
icon?: ReactNode
disabled?: boolean
}
export interface SWHeaderDimensionStyleValue {
name: string
}
/** ByLayer / ByBlock pair of the color dropdown (Figma ByLayer 26:12666, ByBlock 26:12659). */
export type SWHeaderColorMode = "byLayer" | "byBlock"
/** One 22px chip of the color dropdown (Figma color chip 22px 26:11091). The host owns the palette. */
export interface SWHeaderColorOption {
/** Stable key, returned with the selection. */
id: string
/** CSS color of the chip. */
color: string
/** Value text reported with the selection, e.g. `255,0,0`. Defaults to the RGB text of `color`. */
value?: string
/** Accessible name, e.g. `빨강`. Defaults to `value`. */
label?: string
disabled?: boolean
}
/** A color typed into the color dropdown input, validated before it is reported. */
export interface SWHeaderColorInputValue {
/** Normalized `r,g,b` text, e.g. `255,229,0`. */
value: string
rgb: [number, number, number]
/** `#rrggbb`. */
hex: string
}
/**
* One row of the line type column (Figma header/dropdown/brown/row 26:10702). `pattern` lists the dash
* lengths of the 70px preview (4px gaps, scaled to fill 70px); omit it for a solid line.
* `swHeaderLinePatterns` holds the Figma CENTER / HIDDEN patterns.
*/
export interface SWHeaderLineType {
id: string
name: string
pattern?: readonly number[]
disabled?: boolean
}
/** One row of the line weight column. `value` is shown at the right (ByDefault); `editable` shows it in an input (User). */
export interface SWHeaderLineWeight {
id: string
name: string
value?: string
editable?: boolean
disabled?: boolean
}
/** One row of the text style dropdown (font sample 16px + style name). */
export interface SWHeaderTextStyleOption {
id: string
/** Style name, e.g. `Standard`. */
name: string
/** Font name shown at the left, e.g. `Arial`. */
font: string
disabled?: boolean
}
/** One row of the dimension style dropdown (dimension sample + style name). */
export interface SWHeaderDimensionStyleOption {
id: string
name: string
disabled?: boolean
}
/** Optional complete allowlist. Omitted => approved default layout; [] => no optional controls.
* Array order controls rendered order. Stable ids preserve data and component state on reordering. */
export type SWHeaderControlId = "document" | SWHeaderMenuId | "drawingMode" | "details" | "propertyBar" | "propertyPanel" | "print" | "popupGuide"
/** Common frame parts outside the object area. Their position is fixed; each can be removed. */
export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close"
/** Every Header part except the logo, which is always shown. */
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId
export interface SWHeaderControlConfig {
id: SWHeaderControlId
visible?: boolean
label?: string
}
export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title" | "children" | "color"> {
/** File name shown in the document area (ellipsis when narrow). */
documentTitle: string
controls?: readonly SWHeaderControlConfig[]
/**
* Parts to remove, in either layout (default design or `controls`). Works without passing a full
* `controls` list, so the default design and order of the remaining parts are kept. Removing
* "home" keeps the logo as a static image. Unknown ids are ignored. Default: none.
*/
hiddenControls?: readonly SWHeaderHideableControlId[]
/**
* Order of the similar menus (layer / color / lineStyle / textStyle / dimensionStyle). They swap
* places only among their own slots; other parts never move. Listed ids come first (unknown and
* duplicate ids are ignored), missing ids follow in the default order. Omitted: approved default
* order (or the `controls` order). Order never changes values or calls a callback.
*/
menuOrder?: readonly SWHeaderMenuId[]
/**
* Documents of the dropdown under the file name (Figma 26:10662). When omitted the file name
* stays a plain button (no dropdown); an empty array shows only New / Open.
*/
documents?: SWHeaderDocument[]
/** Controlled open state of the document dropdown; uncontrolled when undefined. */
documentMenuOpen?: boolean
onDocumentMenuOpenChange?: (open: boolean) => void
/** A document row was chosen. Opening it is up to the host. */
onDocumentSelect?: (document: SWHeaderDocument) => void
/** Current document id: its name is #FFC600 in the dropdown. The list keeps the `documents` order. */
activeDocumentId?: string
/**
* Close Document: an X ("문서 닫기") on every document row, shown on row hover or keyboard focus.
* The host closes that document and updates `documents` / `activeDocumentId`; the header does
* not choose the next document.
*/
onDocumentClose?: (document: SWHeaderDocument) => void
/** "+ New" in the dropdown footer. */
onDocumentNew?: () => void
/** "Open" in the dropdown footer. */
onDocumentOpen?: () => void
/** Default icon for documents without `icon`. Defaults to the Figma document icon. */
documentIcon?: ReactNode
/** Footer labels. Default `+ New` / `Open` (Figma). */
documentNewLabel?: string
documentOpenLabel?: string
/** Label of the drawing-mode area. Defaults to `그리기모드`. */
drawingModeLabel?: string
/** Current layer shown in the field (used when no `layers` entry matches `activeLayerId`). */
layer?: SWHeaderLayerValue
/**
* Layers of the dropdown under the layer field (Figma 26:10940). When omitted the field stays a
* plain button without a dropdown (the previous behavior); an empty array shows only the footer.
*/
layers?: SWHeaderLayer[]
/** Current layer id: shown in the field (name/state icons); its row retains the hover emphasis. */
activeLayerId?: string
/** Controlled open state of the layer dropdown; uncontrolled when undefined. */
layerMenuOpen?: boolean
onLayerMenuOpenChange?: (open: boolean) => void
/** A layer row (swatch / name) was chosen. The dropdown closes. */
onLayerSelect?: (layer: SWHeaderLayer) => void
/** Bulb toggle. Without it the bulb is a status icon only. The dropdown stays open. */
onLayerVisibilityChange?: (layer: SWHeaderLayer, nextVisible: boolean) => void
/** Snowflake toggle. Without it the snowflake is a status icon only. The dropdown stays open. */
onLayerFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
/** Lock toggle. Without it the lock is a status icon only. The dropdown stays open. */
onLayerLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
/** "레이어 상세" in the dropdown footer. Optional host action; hover remains enabled without it. */
onLayerDetail?: () => void
color?: SWHeaderColorValue
lineStyle?: SWHeaderLineStyleValue
textStyle?: SWHeaderTextStyleValue
dimensionStyle?: SWHeaderDimensionStyleValue
/**
* Basic palette of the color dropdown (Figma header/menu/색상 26:10986). When omitted the color
* field stays a plain button (the previous behavior). The header never changes the color itself:
* every control only reports through its callback and the host updates `color` / `colorMode`.
*/
colors?: SWHeaderColorOption[]
/** User colors, shown in extra 24px rows below the palette (Figma 26:11036). Any count. */
userColors?: SWHeaderColorOption[]
/** Color mode. null still means explicit color; the required indicator retains the last mode (default ByLayer). */
colorMode?: SWHeaderColorMode | null
/** Controlled open state of the color dropdown; uncontrolled when undefined. */
colorMenuOpen?: boolean
onColorMenuOpenChange?: (open: boolean) => void
/** A palette or user color chip was clicked. The dropdown stays open. */
onColorSelect?: (option: SWHeaderColorOption) => void
/** ByLayer / ByBlock was clicked. The dropdown stays open. */
onColorModeChange?: (mode: SWHeaderColorMode) => void
/** A valid `r,g,b` (0-255) or `#rrggbb` was committed in the color input (Enter or leaving the input). */
onColorInput?: (value: SWHeaderColorInputValue) => void
/** Color pick button (Figma btn_colorize). Picking from the drawing is the host's job; the dropdown closes. */
onColorPick?: () => void
/** "색상 상세". Without it the button keeps its Figma look, disabled. The detail popup is not part of the header. */
onColorDetail?: () => void
/** Line type column of the line style dropdown (Figma header/menu/선스타일 26:11104). When omitted (and no `lineWeights`) the field stays a plain button. */
lineTypes?: SWHeaderLineType[]
/** Line weight column (ByLayer / ByBlock / ByDefault / User ...). */
lineWeights?: SWHeaderLineWeight[]
/** Current line type; defaults to the row named `lineStyle.name`. */
activeLineTypeId?: string
/** Current line weight; defaults to the row named `lineStyle.weight`. */
activeLineWeightId?: string
lineStyleMenuOpen?: boolean
onLineStyleMenuOpenChange?: (open: boolean) => void
/** A line type row was clicked. Weight and scale are not touched. The dropdown stays open. */
onLineTypeSelect?: (lineType: SWHeaderLineType) => void
/** A line weight row was clicked. Line type and scale are not touched. The dropdown stays open. */
onLineWeightSelect?: (weight: SWHeaderLineWeight) => void
/** A valid non-negative number was committed in an editable weight row (User). */
onLineWeightValueChange?: (weight: SWHeaderLineWeight, value: string) => void
/** A valid positive number was committed in the scale input. */
onLineScaleChange?: (value: string) => void
/** "선스타일 상세". Without it the button keeps its Figma look, disabled. */
onLineStyleDetail?: () => void
/** Rows of the text style dropdown (Figma header/menu/문자스타일 26:11181). When omitted the field stays a plain button. */
textStyles?: SWHeaderTextStyleOption[]
/** Current text style; defaults to the row named `textStyle.name`. */
activeTextStyleId?: string
textStyleMenuOpen?: boolean
onTextStyleMenuOpenChange?: (open: boolean) => void
/** A text style row was chosen. The dropdown closes. */
onTextStyleSelect?: (style: SWHeaderTextStyleOption) => void
/** "문자스타일 상세". Without it the button keeps its Figma look, disabled. */
onTextStyleDetail?: () => void
/** Rows of the dimension style dropdown (Figma header/menu/치수스타일 26:11215). When omitted the field stays a plain button. */
dimensionStyles?: SWHeaderDimensionStyleOption[]
/** Current dimension style; defaults to the row named `dimensionStyle.name`. */
activeDimensionStyleId?: string
dimensionStyleMenuOpen?: boolean
onDimensionStyleMenuOpenChange?: (open: boolean) => void
/** A dimension style row was chosen. The dropdown closes. */
onDimensionStyleSelect?: (style: SWHeaderDimensionStyleOption) => void
/** "치수스타일 상세". Optional host action; hover remains enabled without it. */
onDimensionStyleDetail?: () => void
/** Which menu is selected (open) in the host program. */
activeMenu?: SWHeaderMenuId | null
propertyBarOpen?: boolean
defaultPropertyBarOpen?: boolean
propertyBar?: SWHeaderPropertyBarProps
onPropertyBarOpenChange?: (open: boolean) => void
propertyPanelOpen?: boolean
/** Additive property tool window configuration. Values and object changes belong to the host. */
propertyPanel?: Omit<SWHeaderPropertyPanelProps, "onClose" | "anchorRef" | "id" | "style">
/** Open/close notification for the tool window. Existing onPropertyPanelToggle is preserved. */
onPropertyPanelOpenChange?: (open: boolean) => void
/** Disables the menu / property / action groups (window controls stay enabled). */
disabled?: boolean
/** Extra tools rendered inside the object area, after the menus. */
tools?: ReactNode
/**
* Logo image URL of the SW program, contained in the 32 x 17 logo slot.
* The logo is decided by each program: none is rendered when omitted.
*/
logoSrc?: string
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc`. */
logo?: ReactNode
/** Accessible product name; the home button is announced as "<logoAlt> 홈" (default "홈"). */
logoAlt?: string
onHomeClick?: () => void
onDocumentClick?: () => void
onLayerOpen?: () => void
onColorOpen?: () => void
onLineStyleOpen?: () => void
onTextStyleOpen?: () => void
onDimensionStyleOpen?: () => void
onPropertyBarToggle?: () => void
onPropertyPanelToggle?: () => void
onPrint?: () => void
onPopupGuide?: () => void
/** Pointer down on the window-move area (the host decides how to move the window). */
onWindowMoveStart?: PointerEventHandler<HTMLDivElement>
onMinimize?: () => void
onClose?: () => void
}
@@ -0,0 +1,11 @@
import { forwardRef, type HTMLAttributes } from "react"
export type SWThemeProps = HTMLAttributes<HTMLDivElement>
/**
* Scope for SW sub-components used outside <SWHeader>: provides the SW tokens,
* typography and control reset. Override any `--hm-sw-header-*` token through `style`.
*/
export const SWTheme = forwardRef<HTMLDivElement, SWThemeProps>(function SWTheme({ className, ...rest }, ref) {
return <div ref={ref} className={`hm-sw-theme${className ? ` ${className}` : ""}`} {...rest} />
})