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