feat(sw): implement SW header design system
This commit is contained in:
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} />
|
||||
})
|
||||
Reference in new issue
Block a user