feat(sw): implement property field interaction variants

- Property panel rows follow Figma 26:12388 ~ 26:12649: 28px fields with
  Default / Hover border / numeric editing On (#0F0D0A) / dropdown On (#211D13)
- Layer, line style and color fields open the shared header dropdown contents
  (SWHeaderLayerRows, SWHeaderLineStyleColumns, SWHeaderColorPanelBody)
- Additive API: property.picker, property.decimals, panel menus
- Property Fields story and browser test (77 checks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 09:38:31 +09:00
1 parent 724c51732b
commit 0029cef60c
23 files changed
+1907 -189

No files matched your search

@@ -53,31 +53,7 @@ export function SWHeaderColorMenu({
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>
)
}
const [selectedMode, chooseMode] = useSWHeaderColorMode(mode)
return (
<div {...rootProps} className="hm-sw-header__menu hm-sw-header__menu--color" data-menu="color">
@@ -104,63 +80,132 @@ export function SWHeaderColorMenu({
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>
<SWHeaderColorPanelBody
value={value}
colors={colors}
userColors={userColors}
mode={mode}
selectedMode={selectedMode}
onColorSelect={onColorSelect}
onModeChange={next => {
chooseMode(next)
onModeChange?.(next)
}}
onColorInput={onColorInput}
onPick={onPick && (() => {
onPick()
close(true)
})}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
)}
</div>
)
}
/**
* ByLayer / ByBlock is a required single choice: with `mode` null (explicit color) the indicator keeps the
* last mode (default ByLayer). Returns the shown mode and the setter for a click.
*/
export function useSWHeaderColorMode(mode: SWHeaderColorMode | null | undefined) {
const [lastMode, setLastMode] = useState<SWHeaderColorMode>(mode ?? "byLayer")
useEffect(() => { if (mode) setLastMode(mode) }, [mode])
return [mode ?? lastMode, setLastMode] as const
}
export interface SWHeaderColorPanelBodyProps extends Pick<SWHeaderColorMenuProps, "value" | "colors" | "userColors" | "mode" | "onColorSelect" | "onColorInput"> {
/** Shown mode (see useSWHeaderColorMode). */
selectedMode: SWHeaderColorMode
/** A mode other than the shown one was clicked. */
onModeChange: (mode: SWHeaderColorMode) => void
/** Color pick; the caller closes its dropdown. Without it the button is disabled. */
onPick?: () => void
onDetail?: () => void
}
/**
* Contents of the color dropdown (Figma 26:10986, property field 26:12184): ByLayer / ByBlock, palette, user
* colors, btn_colorize + RGB input (0-255, validated) and "색상 상세". Shared by the header color field and
* the property window color field.
*/
export function SWHeaderColorPanelBody({ value, colors, userColors, mode, selectedMode, onColorSelect, onModeChange, onColorInput, onPick, onDetail }: SWHeaderColorPanelBodyProps) {
const swatch = value.swatch ?? "#ffe500"
const current = parseSWHeaderColor(value.value) ?? parseSWHeaderColor(swatch)
const inputValue = current?.value ?? value.value
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 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
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?.()}
>
<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} />
</div>
</>
)
}
@@ -187,46 +187,78 @@ export function SWHeaderLayerMenu({
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>
<SWHeaderLayerRows
layers={layers}
current={current}
detailDisabled={disabled}
onSelect={layer => {
onLayerSelect?.(layer)
close(true)
}}
onVisibilityChange={onVisibilityChange}
onFreezeChange={onFreezeChange}
onLockChange={onLockChange}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
</div>
)}
</div>
)
}
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange"> {
layers: SWHeaderLayer[]
/** Row shown as current (#FFC600 name, hover emphasis). None when the selection mixes layers. */
current?: SWHeaderLayer
/** A row (swatch / name) was chosen; the caller closes its dropdown. */
onSelect: (layer: SWHeaderLayer) => void
onDetail?: () => void
detailDisabled?: boolean
}
/**
* Rows + "레이어 상세" footer of the layer dropdown (Figma 26:10940, property field 26:12265 / 26:12291).
* Shared by the header layer field and the property window layer field: same rows, toggles and callbacks.
* The bulb, snowflake and lock are siblings of the row selection, so they never select the row.
*/
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false }: SWHeaderLayerRowsProps) {
return (
<>
{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={() => onSelect(layer)}
>
<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={detailDisabled} onClick={onDetail} />
</div>
</>
)
}
@@ -95,57 +95,95 @@ export function SWHeaderLineStyleMenu({
{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>
<SWHeaderLineStyleColumns
lineTypes={lineTypes}
lineWeights={lineWeights}
currentType={currentType}
currentWeight={currentWeight}
scale={value.scale}
onLineTypeSelect={onLineTypeSelect}
onLineWeightSelect={onLineWeightSelect}
onLineWeightValueChange={onLineWeightValueChange}
onScaleChange={onScaleChange}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
)}
</div>
)
}
export interface SWHeaderLineStyleColumnsProps extends Pick<SWHeaderLineStyleMenuProps, "onLineTypeSelect" | "onLineWeightSelect" | "onLineWeightValueChange" | "onScaleChange" | "onDetail"> {
lineTypes: readonly SWHeaderLineType[]
/** Line weight column; omitted => no weight column. */
lineWeights?: readonly SWHeaderLineWeight[]
currentType?: SWHeaderLineType
currentWeight?: SWHeaderLineWeight
/** Scale input; omitted => no scale column. */
scale?: string
}
/**
* The 466px drop of the line style dropdown (Figma 26:11143, property field 26:12404): line type 182 (pattern
* preview + name, "선스타일 상세") · weight 142 · scale 142. Shared by the header line style field and the
* property window line style field; each caller decides whether a selection closes its dropdown.
*/
export function SWHeaderLineStyleColumns({
lineTypes, lineWeights, currentType, currentWeight, scale, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onScaleChange, onDetail,
}: SWHeaderLineStyleColumnsProps) {
return (
<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} />
</div>
</div>
{lineWeights && (
<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>
)}
{scale !== undefined && (
<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={scale}
parse={draft => parseSWHeaderNumber(draft, { min: 0, minExclusive: true })}
onCommit={onScaleChange}
/>
</div>
</div>
)}
@@ -0,0 +1,221 @@
import { useLayoutEffect, useState, type CSSProperties, type ReactNode, type Ref } from "react"
import type { SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType } from "../types"
import { SWHeaderColorPanelBody, useSWHeaderColorMode } from "./SWHeaderColorMenu"
import { useSWHeaderDropdown } from "./SWHeaderDropdown"
import { SWHeaderInput, parseSWHeaderColor } from "./SWHeaderInput"
import { SWHeaderLayerRows } from "./SWHeaderLayerMenu"
import { SWHeaderLinePreview, SWHeaderLineStyleColumns } from "./SWHeaderLineStyleMenu"
import type { SWHeaderProperty, SWHeaderPropertyMenus, SWHeaderPropertyPicker } from "./SWHeaderPropertyPanel"
/** Dropdown of a property row: `picker` when set, else color rows and line style (select + linePreview). */
export function propertyPicker(property: SWHeaderProperty): SWHeaderPropertyPicker | undefined {
if (property.picker === false) return undefined
if (property.picker) return property.picker
if (property.type === "color") return "color"
if (property.type === "select" && property.linePreview) return "lineStyle"
return undefined
}
/** Line type rows: host `lineTypes`, else the property's own options (pattern only known for the current one). */
export function propertyLineTypes(property: SWHeaderProperty, menus?: SWHeaderPropertyMenus): readonly SWHeaderLineType[] {
return menus?.lineTypes ?? (property.options ?? []).map(option => ({
id: option.id, name: option.name, disabled: option.disabled, pattern: option.id === property.value ? property.linePattern : undefined,
}))
}
/** Layer rows: host `layers`, else the property's own options (no toggle state). */
function propertyLayers(property: SWHeaderProperty, menus?: SWHeaderPropertyMenus): readonly SWHeaderLayer[] {
return menus?.layers ?? (property.options ?? []).map(option => ({ id: option.id, name: option.name, disabled: option.disabled }))
}
const byValue = <T extends { id: string; name: string }>(items: readonly T[], value: string) =>
items.find(item => item.id === value) ?? items.find(item => item.name === value)
/**
* Field dropdown inside the property window: the shared header dropdown behavior (click / Enter / Space toggle,
* ArrowDown opens, Escape / outside click / Tab out close) with a fixed overlay, so the list escapes the
* scrolling panel body. Figma 26:12265 / 26:12404 attach at the field's right edge, 26:12184 at its left edge;
* the list flips above the field and shifts sideways only when the screen has no room.
*/
function useFieldDropdown(align: "start" | "end", disabled: boolean) {
const dropdown = useSWHeaderDropdown({ disabled })
const { open, rootRef, panelRef } = dropdown
const [position, setPosition] = useState<CSSProperties>({ visibility: "hidden" })
useLayoutEffect(() => {
if (!open) return
const update = () => {
const field = rootRef.current?.getBoundingClientRect()
const panel = panelRef.current
if (!field || !panel) return
const width = panel.offsetWidth, height = panel.offsetHeight
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
const left = Math.max(4, Math.min(align === "end" ? field.right - width : field.left, viewportWidth - width - 4))
const below = field.bottom
const top = below + height > viewportHeight - 4 && field.top - height >= 4 ? field.top - height : below
setPosition(current => (current.left === left && current.top === top ? current : { left, top }))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => {
window.removeEventListener("resize", update)
window.removeEventListener("scroll", update, true)
}
}, [open, align, rootRef, panelRef])
return { ...dropdown, position }
}
export interface SWHeaderPropertyPickerFieldProps {
picker: SWHeaderPropertyPicker
property: SWHeaderProperty
/** Id of the trigger (the row label points at it). */
id: string
/** Editable: opens the dropdown and commits. Otherwise the field only shows the value (Default / Hover). */
editable: boolean
menus?: SWHeaderPropertyMenus
/** Validated commit of the row (host callbacks). */
commit: (value: string) => void
/** Validation of the row, used by the color input. */
parse: (draft: string) => string | null
}
/**
* Layer (26:12250 / 26:12260 → 26:12265 / 26:12291), line style (26:12388 → 26:12404) and color
* (26:12170 → 26:12184) fields of the property window. Default: value only; Hover: 1px rgba(33,29,19,.4)
* border; On: #211D13 field + the shared header dropdown contents. Values and lists are host data; a
* selection is reported through the row commit (the host updates `properties`).
*/
export function SWHeaderPropertyPickerField({ picker, property, id, editable, menus, commit, parse }: SWHeaderPropertyPickerFieldProps) {
const { open, setOpen, close, panelRef, triggerRef, menuId, position, rootProps } = useFieldDropdown(picker === "color" ? "start" : "end", !editable)
const { mixed, value, unit } = property
const shared = {
...rootProps,
className: "hm-sw-property__value",
"data-field": "picker",
"data-picker": picker,
"data-mixed": mixed ? "true" : undefined,
"data-readonly": editable ? undefined : "true",
}
const trigger = {
id,
"aria-haspopup": "true" as const,
"aria-expanded": open,
"aria-controls": open ? menuId : undefined,
"aria-disabled": !editable && !property.disabled ? true : undefined,
}
const drop = (className: string, label: string, children: ReactNode) => (
<div ref={panelRef} id={menuId} className={`hm-sw-property__drop ${className}`} data-picker={picker} role="group" aria-label={label} style={position}>
{children}
</div>
)
if (picker === "layer") {
const layers = propertyLayers(property, menus)
const current = mixed ? undefined : byValue(layers, value)
return (
<div {...shared}>
<button ref={triggerRef} type="button" className="hm-sw-property__trigger" {...trigger} disabled={property.disabled} onClick={() => editable && setOpen(!open)}>
<span className="hm-sw-property__text" title={mixed ? "다양함" : current?.name ?? value}>{mixed ? "다양함" : current?.name ?? value}</span>
</button>
{open && drop("hm-sw-header__layer-panel", "레이어 목록", (
<SWHeaderLayerRows
layers={[...layers]}
current={current}
onSelect={layer => { commit(layer.id); close(true) }}
onVisibilityChange={menus?.onLayerVisibilityChange}
onFreezeChange={menus?.onLayerFreezeChange}
onLockChange={menus?.onLayerLockChange}
onDetail={menus?.onLayerDetail && (() => { menus.onLayerDetail?.(); close(true) })}
/>
))}
</div>
)
}
if (picker === "lineStyle") {
const lineTypes = propertyLineTypes(property, menus)
const current = mixed ? undefined : byValue(lineTypes, value)
const weights = menus?.lineWeights
const currentWeight = weights?.find(item => item.id === menus?.activeLineWeightId)
const text = mixed ? "다양함" : current?.name ?? property.options?.find(option => option.id === value)?.name ?? value
return (
<div {...shared}>
<button ref={triggerRef} type="button" className="hm-sw-property__trigger" {...trigger} disabled={property.disabled} onClick={() => editable && setOpen(!open)}>
<span className="hm-sw-property__trigger-text">
<span className="hm-sw-property__text" title={text}>{text}</span>
{unit && <span className="hm-sw-property__unit">{unit}</span>}
</span>
{property.linePreview !== false && <SWHeaderLinePreview pattern={current?.pattern ?? property.linePattern} />}
</button>
{open && drop("", "선스타일", (
<SWHeaderLineStyleColumns
lineTypes={lineTypes}
lineWeights={weights}
currentType={current}
currentWeight={currentWeight}
scale={menus?.lineScale}
onLineTypeSelect={item => { commit(item.id); close(true) }}
onLineWeightSelect={menus?.onLineWeightSelect}
onLineWeightValueChange={menus?.onLineWeightValueChange}
onScaleChange={menus?.onLineScaleChange}
onDetail={menus?.onLineStyleDetail && (() => { menus.onLineStyleDetail?.(); close(true) })}
/>
))}
</div>
)
}
return <ColorField {...{ property, editable, menus, commit, parse, shared, trigger, open, setOpen, close, triggerRef, drop }} />
}
const optionValue = (option: SWHeaderColorOption) => option.value ?? parseSWHeaderColor(option.color)?.value ?? option.color
/** Color (26:12170 / 26:12184): RGB input (validated 0-255, Figma sample 255,259,0 is not valid) + swatch. */
function ColorField({ property, editable, menus, commit, parse, shared, trigger, open, setOpen, close, triggerRef, drop }: Pick<SWHeaderPropertyPickerFieldProps, "property" | "editable" | "menus" | "commit" | "parse"> & {
shared: Record<string, unknown>
trigger: Record<string, unknown>
open: boolean
setOpen: (open: boolean) => void
close: (restoreFocus: boolean) => void
triggerRef: Ref<HTMLButtonElement>
drop: (className: string, label: string, children: ReactNode) => ReactNode
}) {
const [selectedMode, chooseMode] = useSWHeaderColorMode(menus?.colorMode)
const { mixed, value } = property
const parsed = parseSWHeaderColor(value)
const swatch = property.swatch ?? parsed?.hex ?? "transparent"
return (
<div {...shared}>
<span className="hm-sw-property__color-value">
<SWHeaderInput
ref={triggerRef as unknown as Ref<HTMLInputElement>}
{...trigger}
value={mixed ? "다양함" : value}
disabled={property.disabled}
readOnly={!editable}
parse={parse}
commitOnUnmount={false}
onCommit={editable ? commit : undefined}
onClick={() => { if (editable && !open) setOpen(true) }}
/>
</span>
<span className="hm-sw-property__swatch-area" aria-hidden="true" onClick={() => editable && setOpen(!open)}>
<span className="hm-sw-property__swatch"><i style={{ backgroundColor: swatch }} /></span>
</span>
{open && drop("hm-sw-header__menu-panel hm-sw-header__color-menu", "색상", (
<SWHeaderColorPanelBody
value={{ value: parsed?.value ?? value, swatch: property.swatch ?? parsed?.hex }}
colors={menus?.colors ?? []}
userColors={menus?.userColors}
mode={menus?.colorMode}
selectedMode={selectedMode}
onColorSelect={option => commit(optionValue(option))}
onModeChange={mode => { chooseMode(mode); menus?.onColorModeChange?.(mode, property) }}
onColorInput={next => commit(next.value)}
onPick={menus?.onColorPick && (() => { menus.onColorPick?.(property); close(true) })}
onDetail={menus?.onColorDetail && (() => { menus.onColorDetail?.(); close(true) })}
/>
))}
</div>
)
}
@@ -9,8 +9,10 @@ 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 type { SWHeaderColorMode, SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType, SWHeaderLineWeight } from "../types"
import { SWHeaderInput, parseSWHeaderColor, parseSWHeaderNumber } from "./SWHeaderInput"
import { SWHeaderLinePreview } from "./SWHeaderLineStyleMenu"
import { SWHeaderPropertyPickerField, propertyLineTypes, propertyPicker } from "./SWHeaderPropertyFields"
export type SWHeaderPropertyType = "text" | "number" | "select" | "color" | "slider" | "computed"
export interface SWHeaderProperty {
@@ -28,15 +30,53 @@ export interface SWHeaderProperty {
min?: number
max?: number
step?: number
/** Fixed decimals of a committed number (e.g. 4: `2` → `2.0000`). The host value is shown as given. */
decimals?: 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"
/**
* Field dropdown (Figma 26:12265 layer, 26:12404 line style, 26:12184 color). Default: `color` rows use the
* color dropdown and `select` rows with `linePreview` the line style dropdown; `layer` must be set. `false`
* keeps the plain field. Lists come from the panel `menus` (else from `options`).
*/
picker?: SWHeaderPropertyPicker | false
validate?: (draft: string) => boolean
onCommit?: (value: string, property: SWHeaderProperty) => void
}
export type SWHeaderPropertyPicker = "layer" | "lineStyle" | "color"
/**
* Host data of the property field dropdowns (same records as the header dropdowns). A row selection is
* committed to its property (`onPropertyChange` / `onCommit`, value = the row id); every other control only
* reports through its callback. Nothing here changes CAD data by itself.
*/
export interface SWHeaderPropertyMenus {
layers?: SWHeaderLayer[]
onLayerVisibilityChange?: (layer: SWHeaderLayer, nextVisible: boolean) => void
onLayerFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
onLayerLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
onLayerDetail?: () => void
lineTypes?: SWHeaderLineType[]
/** Weight column of the line style dropdown; omitted => no column. */
lineWeights?: SWHeaderLineWeight[]
activeLineWeightId?: string
/** Scale column of the line style dropdown; omitted => no column. */
lineScale?: string
onLineWeightSelect?: (weight: SWHeaderLineWeight) => void
onLineWeightValueChange?: (weight: SWHeaderLineWeight, value: string) => void
onLineScaleChange?: (value: string) => void
onLineStyleDetail?: () => void
colors?: SWHeaderColorOption[]
userColors?: SWHeaderColorOption[]
colorMode?: SWHeaderColorMode | null
onColorModeChange?: (mode: SWHeaderColorMode, property: SWHeaderProperty) => void
/** Color pick (btn_colorize); picking from the drawing is the host's job. The dropdown closes. */
onColorPick?: (property: SWHeaderProperty) => void
onColorDetail?: () => void
}
export interface SWHeaderPropertyGroup {
id: string
name?: string
@@ -72,6 +112,8 @@ export interface SWHeaderPropertyPanelProps {
properties: readonly SWHeaderProperty[]
groups: readonly SWHeaderPropertyGroup[]
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
/** Lists and callbacks of the layer / line style / color field dropdowns. */
menus?: SWHeaderPropertyMenus
expandedGroupIds?: readonly string[]
defaultExpandedGroupIds?: readonly string[]
onExpandedGroupsChange?: (ids: string[]) => void
@@ -120,27 +162,37 @@ export function useSWHeaderOverlay(anchorRef: RefObject<HTMLElement | null> | un
return { panelRef, style }
}
function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; onChange?: SWHeaderPropertyPanelProps["onPropertyChange"] }) {
function PropertyEditor({ property, onChange, menus }: { property: SWHeaderProperty; onChange?: SWHeaderPropertyPanelProps["onPropertyChange"]; menus?: SWHeaderPropertyMenus }) {
const inputId = useId()
const inputRef = useRef<HTMLInputElement>(null)
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 picker = propertyPicker(property)
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 === "number" || type === "slider") {
const number = parseSWHeaderNumber(draft, { min: property.min, max: property.max })
return number !== null && property.decimals !== undefined ? Number(number).toFixed(property.decimals) : number
}
if (type === "color") return parseSWHeaderColor(draft)?.value ?? null
if (type === "select") return property.options?.some(option => option.id === draft && !option.disabled) ? draft : null
if (type === "select") {
const rows = picker === "lineStyle" ? propertyLineTypes(property, menus) : property.options ?? []
return rows.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 parsed = parse(next)
if (parsed === null) { setDraft(value); return }
if (parsed === value) return
pendingRef.current = parsed
property.onCommit?.(parsed, property)
onChange?.(property, parsed)
}
const cancelDraft = () => setDraft(value)
const rangeMin = property.min ?? 0, rangeMax = property.max ?? 100, rangeValue = mixed ? rangeMin : Number(draft)
@@ -150,10 +202,23 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
if (event.key === "Enter") { event.preventDefault(); commit(draft) }
if (event.key === "Escape" && draft !== value) { event.stopPropagation(); cancelDraft() }
}
// Figma 26:12649: a number without unit / annotation (선가중치) and the slider value are the 41px hug field;
// every other value is the 200px box (26:12314 / 26:12590 / 26:12583 / 26:12574). Figma has an editing On
// (#0F0D0A) only for numbers; text and computed values keep Default / Hover (no invented On).
const compact = type === "slider" || (type === "number" && !unit && !property.annotation)
const field = compact ? undefined : "box"
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">
{picker ? <SWHeaderPropertyPickerField picker={picker} property={property} id={inputId} editable={editable} menus={menus} commit={commit} parse={parse} /> : (
<div className="hm-sw-property__value" data-field={field} data-on={field && editable && type === "number" ? "edit" : undefined}
data-readonly={editable ? undefined : "true"}
onMouseDown={event => {
// A click on the unit / empty part of the box edits the value (the box is the Figma field).
if (!field || !editable || event.target === inputRef.current || type === "select") return
event.preventDefault()
inputRef.current?.focus()
}}>
{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>}
@@ -162,7 +227,9 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
</select>
) : (
<SWHeaderInput
ref={inputRef}
id={inputId} aria-label={property.name} value={mixed ? "다양함" : value}
className={compact ? "hm-sw-property__compact" : undefined}
valueType={type === "number" || type === "slider" ? "number" : "text"}
disabled={disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable ? commit : undefined}
@@ -170,18 +237,19 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
)}
{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>}
{type === "color" && <span className="hm-sw-property__swatch-area" aria-hidden="true"><span className="hm-sw-property__swatch"><i style={{ backgroundColor: property.swatch ?? parseSWHeaderColor(value)?.hex ?? "transparent" }} /></span></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 ? rangeMin : draft} style={{ "--hm-sw-range-fill": rangeFill } as CSSProperties} 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 = [],
selection, properties, groups, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
onExpandedGroupsChange, toolActive: controlledTool, onToolActiveChange, toolLabel = "객체 선택 도구",
onOverviewClick, shapeContent, onClose, id, anchorRef, style: customStyle,
}: SWHeaderPropertyPanelProps) {
@@ -244,7 +312,7 @@ export function SWHeaderPropertyPanel({
{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>)}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></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>
@@ -19,7 +19,7 @@ export {
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 { SWHeaderPropertyPanel, type SWHeaderPropertyPanelProps, type SWHeaderProperty, type SWHeaderPropertyType, type SWHeaderPropertyPicker, type SWHeaderPropertyMenus, 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 {