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 {
|
||||
|
||||
@@ -75,6 +75,12 @@
|
||||
--hm-sw-property-label: #000;
|
||||
--hm-sw-property-table: #d2c8b4;
|
||||
--hm-sw-property-inner-border: #948871;
|
||||
/* Property fields (Figma 26:12170 ~ 26:12649): Hover border, editing On, dropdown On, unit colors. */
|
||||
--hm-sw-property-field-hover-border-color: rgba(33, 29, 19, 0.4);
|
||||
--hm-sw-property-field-editing-background: #0f0d0a;
|
||||
--hm-sw-property-field-open-background: #211d13;
|
||||
--hm-sw-property-field-editing-unit-color: #fff;
|
||||
--hm-sw-property-field-open-unit-color: #d4cbbd;
|
||||
/* Gauge (user-supplied Figma SVGs, 2026-10-19). Fill = Rectangle 6926: 6px pill, 1px black stroke,
|
||||
vertical #8E0000 / #FF4242 37% / #E40000 76% / #1E0000 inside the stroke; its right end sits under
|
||||
the thumb. Unfilled track = beige measured row by row from the user's reference image.
|
||||
@@ -1520,8 +1526,10 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-property__value > select:disabled { opacity: 1; appearance: none; color: var(--hm-sw-header-input-color); }
|
||||
.hm-sw-property__value > select:focus { background: var(--hm-sw-property-content); border-color: var(--hm-sw-property-inner-border); }
|
||||
.hm-sw-property__unit { flex: 0 0 auto; font-size: 12px; white-space: nowrap; }
|
||||
.hm-sw-property__swatch { display: block; flex: 0 0 70px; height: 16px; padding: 1px; border: 1px solid #000; border-radius: 2px; }
|
||||
.hm-sw-property__swatch > i { display: block; height: 12px; border: 1px solid #fff; }
|
||||
/* Color preview (26:12175): 70 x 16 black tile (1px padding, radius 2), 68 x 14 swatch with a white 1px border. */
|
||||
.hm-sw-property__swatch-area { display: flex; align-items: center; flex: 0 0 auto; }
|
||||
.hm-sw-property__swatch { display: block; flex: 0 0 70px; width: 70px; height: 16px; padding: 1px; border-radius: 2px; background: #000; }
|
||||
.hm-sw-property__swatch > i { display: block; height: 14px; border: 1px solid #fff; border-radius: 1px; }
|
||||
.hm-sw-property__value > input[type="range"] { appearance: none; flex: 0 0 86px; width: 86px; height: 16px; margin: 0; background: transparent; cursor: pointer; }
|
||||
/* --hm-sw-range-fill (0-1) is set from the value; the red ends under the thumb centre (14px thumb).
|
||||
The 3px radius rounds the fill's left cap; the first layer is its left stroke. Track and thumb are
|
||||
@@ -1582,6 +1590,107 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
||||
.hm-sw-property__row[data-disabled="true"] { opacity: .4; }
|
||||
.hm-sw-property button:focus-visible, .hm-sw-property input:focus-visible, .hm-sw-property select:focus-visible { outline: 2px solid var(--hm-sw-property-border); outline-offset: -2px; }
|
||||
|
||||
/* Property fields (2026-10-19, Figma 26:12170 ~ 26:12649). Common rows only; the circle / position groups keep
|
||||
their layout. Box: 28px, radius 2, a transparent 1px border so Hover / On never move the value (8px inset as
|
||||
in Default). Default: value only. Hover: rgba(33,29,19,.4) border, values / units / previews unchanged.
|
||||
On (editing, numbers 26:12323 / 26:12599 / 26:12656): #0F0D0A, black text outline, unit #FFF, red caret.
|
||||
On (dropdown 26:12265 / 26:12291 / 26:12404 / 26:12184): #211D13, outlined value, unit #D4CBBD.
|
||||
Text and computed values (26:12583 / 26:12574) have no Figma On: only Default / Hover. */
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-field] {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-field="box"] { padding: 0 7px; }
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-field="picker"] { padding: 0; }
|
||||
.hm-sw-property__group--rows .hm-sw-property__row:not([data-disabled]) > .hm-sw-property__value[data-field]:is(:hover, [data-open="true"], :not([data-readonly]):focus-within) {
|
||||
border-color: var(--hm-sw-property-field-hover-border-color);
|
||||
}
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-field] .hm-sw-header__input,
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-field] .hm-sw-header__input:is(:hover, :focus) {
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: none;
|
||||
}
|
||||
/* The box shows the editing state; read-only values keep the keyboard focus ring. */
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-field] .hm-sw-header__input:not(:read-only):focus-visible { outline: none; }
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-on="edit"]:focus-within { background: var(--hm-sw-property-field-editing-background); }
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-on="edit"]:focus-within > .hm-sw-header__input { text-shadow: var(--hm-sw-header-text-outline); }
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-on="edit"]:focus-within > .hm-sw-property__unit { color: var(--hm-sw-property-field-editing-unit-color); }
|
||||
|
||||
/* 41px hug field (26:12649: 선가중치 / 투명도 value): pl 6, pr 4, the input itself is the box. */
|
||||
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact,
|
||||
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact:is(:hover, :focus) {
|
||||
flex: 0 0 auto;
|
||||
width: calc(var(--hm-sw-input-chars, 1) * (1ch + 0.02em) + 10px);
|
||||
height: 28px;
|
||||
padding: 0 3px 0 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
@supports (field-sizing: content) {
|
||||
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact { width: auto; min-width: calc(1ch + 10px); }
|
||||
}
|
||||
.hm-sw-property__group--rows .hm-sw-property__row:not([data-disabled]) .hm-sw-header__input.hm-sw-property__compact:is(:hover, :focus:not(:read-only)) { border-color: var(--hm-sw-property-field-hover-border-color); }
|
||||
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact:focus:not(:read-only) {
|
||||
outline: none;
|
||||
background: var(--hm-sw-property-field-editing-background);
|
||||
text-shadow: var(--hm-sw-header-text-outline);
|
||||
}
|
||||
|
||||
/* Dropdown fields: layer (pl/pr 8), line style (pl 8, pr 6: value + (ByL) + pattern), color (RGB | swatch). */
|
||||
.hm-sw-property__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
padding: 0 7px;
|
||||
color: var(--hm-sw-header-input-color);
|
||||
text-align: left;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hm-sw-property__value[data-picker="lineStyle"] > .hm-sw-property__trigger { padding-right: 5px; }
|
||||
.hm-sw-property__value[data-readonly] > .hm-sw-property__trigger,
|
||||
.hm-sw-property__trigger:disabled { cursor: default; opacity: 1; }
|
||||
.hm-sw-property__trigger-text { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||
.hm-sw-property__text { min-width: 0; overflow: clip; overflow-clip-margin: var(--hm-sw-header-text-outline-room); white-space: nowrap; text-overflow: ellipsis; font-weight: 500; }
|
||||
.hm-sw-property__trigger .hm-sw-property__unit { color: var(--hm-sw-property-label); }
|
||||
/* Figma 68px (Dotum). Noto Sans KR needs more room for "Continuous (ByL)": the preview gives way first (Figma On draws it at 50px), then the name ellipsizes. */
|
||||
.hm-sw-property__trigger > .hm-sw-header__line-preview { flex: 0 1 68px; width: 68px; min-width: 40px; }
|
||||
.hm-sw-property__value[data-picker="lineStyle"] .hm-sw-property__trigger-text { flex-shrink: 0; max-width: calc(100% - 44px); }
|
||||
.hm-sw-property__value[data-field="picker"][data-open="true"]:not([data-picker="color"]),
|
||||
.hm-sw-property__value[data-picker="color"][data-open="true"] > .hm-sw-property__color-value { background: var(--hm-sw-property-field-open-background); }
|
||||
.hm-sw-property__group--rows .hm-sw-property__value[data-field][data-open="true"] :is(.hm-sw-property__text, .hm-sw-header__input) { text-shadow: var(--hm-sw-header-text-outline); }
|
||||
.hm-sw-property__value[data-open="true"] .hm-sw-property__trigger .hm-sw-property__unit { color: var(--hm-sw-property-field-open-unit-color); }
|
||||
.hm-sw-property__color-value { display: flex; align-items: center; flex: 1 1 auto; align-self: stretch; min-width: 0; padding: 0 7px; border-right: 1px solid transparent; }
|
||||
.hm-sw-property__value[data-field] > .hm-sw-property__swatch-area { align-self: stretch; padding: 0 5px; }
|
||||
.hm-sw-property__value[data-field="box"] > .hm-sw-property__swatch-area { padding-right: 0; }
|
||||
.hm-sw-property__value[data-picker="color"]:not([data-readonly]) > .hm-sw-property__swatch-area { cursor: pointer; }
|
||||
.hm-sw-property__group--rows .hm-sw-property__row:not([data-disabled]) > .hm-sw-property__value[data-picker="color"]:is(:hover, [data-open="true"]) > .hm-sw-property__color-value {
|
||||
border-right-color: var(--hm-sw-property-field-hover-border-color);
|
||||
}
|
||||
/* The shared header dropdown contents in a fixed overlay (escapes the scrolling body). No yellow ring:
|
||||
the property Figma dropdowns have the black stroke and the brown surface only. */
|
||||
.hm-sw-property__drop { position: fixed; z-index: 50; top: 0; left: 0; color: var(--hm-sw-header-text-color); text-align: left; }
|
||||
.hm-sw-property__drop[data-picker="layer"] { width: var(--hm-sw-header-layer-menu-width); }
|
||||
.hm-sw-property__drop.hm-sw-header__menu-panel { position: fixed; max-width: none; }
|
||||
.hm-sw-property__drop.hm-sw-header__menu-panel::before { display: none; }
|
||||
|
||||
/* K guide 26:11698 / 26:11669: original image, fixed mask viewport and continuous pan/zoom. */
|
||||
.hm-sw-k-guide { position: relative; flex: 0 0 40px; height: 38px; }
|
||||
.hm-sw-k-guide__panel { z-index: 45; height: 149px; min-height: 149px; overflow: hidden; border: 1px solid var(--hm-sw-property-border); border-radius: 2px; background: #0e3c2e; animation: hm-sw-k-guide-enter 511ms ease-out; }
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Property fields (Figma 26:12388 ~ 26:12649): Default / real pointer Hover / Mouse Leave / On (editing, dropdown),
|
||||
// selection callbacks, keyboard, Escape, external data, Disabled / ReadOnly, and the four panel states.
|
||||
// Usage: node packages/sw/tests/property-fields.browser.mjs <evidence-dir> <label> [storybook-url]
|
||||
// Storybook serves packages/sw/dist: build @hm-design/sw first.
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/property-fields-qa')
|
||||
const label = process.argv[3] ?? 'run'
|
||||
const base = process.argv[4] ?? 'http://127.0.0.1:6024'
|
||||
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||||
await mkdir(out, { recursive: true })
|
||||
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 2 })
|
||||
const report = { label, checks: [], errors: [] }
|
||||
page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
|
||||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
|
||||
const shot = async (name, locator) => writeFile(path.join(out, `${label}-${name}.png`), await (locator ?? page).screenshot())
|
||||
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--property-fields&viewMode=story`)
|
||||
await page.locator('[data-fields="live"] .hm-sw-property').waitFor(); await page.evaluate(() => document.fonts.ready)
|
||||
const live = page.locator('[data-fields="live"]')
|
||||
const row = id => live.locator(`.hm-sw-property__row[data-property-id="${id}"]`)
|
||||
const field = id => row(id).locator('.hm-sw-property__value').first()
|
||||
const log = () => page.locator('[data-property-log]').textContent()
|
||||
const HOVER = 'rgba(33, 29, 19, 0.4)', CLEAR = 'rgba(0, 0, 0, 0)'
|
||||
const style = (locator, ...props) => locator.evaluate((e, props) => { const s = getComputedStyle(e), r = e.getBoundingClientRect(); return Object.assign({ x: r.x, y: r.y, w: +r.width.toFixed(2), h: +r.height.toFixed(2) }, ...props.map(p => ({ [p]: s[p] }))) }, props)
|
||||
const textBox = async id => row(id).locator('.hm-sw-header__input, .hm-sw-property__text').first().evaluate(e => { const r = e.getBoundingClientRect(); return [r.x, r.y] })
|
||||
await page.mouse.move(1, 1)
|
||||
|
||||
// 1. Geometry (Default): 200 x 28 boxes, 28px compact (선가중치 / 투명도 value), radius 2, no border, no surface.
|
||||
for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thickness', 'material']) {
|
||||
const s = await style(field(id), 'borderTopColor', 'backgroundColor', 'borderTopLeftRadius')
|
||||
check(`Default ${id}: 200 x 28, radius 2, no border / surface`, s.w === 200 && s.h === 28 && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.borderTopLeftRadius === '2px', s)
|
||||
}
|
||||
for (const id of ['weight', 'opacity']) {
|
||||
const s = await style(row(id).locator('.hm-sw-property__compact'), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight')
|
||||
check(`Default ${id}: compact 28px hug field (pl 6 / pr 4 incl. border), no border`, s.h === 28 && s.w < 60 && s.borderTopColor === CLEAR && s.paddingLeft === '5px' && s.paddingRight === '3px', s)
|
||||
}
|
||||
const typo = await row('scale').evaluate(r => { const v = getComputedStyle(r.querySelector('.hm-sw-header__input')), u = getComputedStyle(r.querySelector('.hm-sw-property__unit')); return { value: [v.color, v.fontSize], unit: [u.color, u.fontSize] } })
|
||||
check('Typography: value #E60403 14px, unit #000 12px', typo.value.join() === 'rgb(230, 4, 3),14px' && typo.unit.join() === 'rgb(0, 0, 0),12px', typo)
|
||||
await shot('default', live.locator('.hm-sw-property'))
|
||||
|
||||
// 2. Real pointer Hover → border only, value does not move; Mouse Leave → Default.
|
||||
for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thickness', 'material']) {
|
||||
const before = await textBox(id)
|
||||
await field(id).hover()
|
||||
const s = await style(field(id), 'borderTopColor', 'backgroundColor')
|
||||
const after = await textBox(id)
|
||||
const extra = id === 'color' ? await style(row(id).locator('.hm-sw-property__color-value'), 'borderRightColor') : {}
|
||||
check(`Hover ${id}: rgba(33,29,19,.4) border, 28px kept, value not moved${id === 'color' ? ', divider shown' : ''}`, s.borderTopColor === HOVER && s.h === 28 && s.backgroundColor === CLEAR && before.join() === after.join() && (id !== 'color' || extra.borderRightColor === HOVER), { ...s, before, after, ...extra })
|
||||
await page.mouse.move(1, 1)
|
||||
const left = await style(field(id), 'borderTopColor')
|
||||
check(`Mouse Leave ${id}: back to Default`, left.borderTopColor === CLEAR, left)
|
||||
}
|
||||
for (const id of ['weight', 'opacity']) {
|
||||
const c = row(id).locator('.hm-sw-property__compact')
|
||||
await c.hover(); const s = await style(c, 'borderTopColor'); await page.mouse.move(1, 1); const l = await style(c, 'borderTopColor')
|
||||
check(`Hover / Leave ${id} compact`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
|
||||
}
|
||||
await field('scale').hover(); await shot('hover-scale', field('scale')); await page.mouse.move(1, 1)
|
||||
|
||||
// 3. On (editing): numbers only. #0F0D0A, unit white, outlined value, real input focus; Enter commits (4 decimals).
|
||||
for (const id of ['scale', 'thickness']) {
|
||||
await row(id).locator('.hm-sw-property__unit').click()
|
||||
const s = await style(field(id), 'backgroundColor', 'borderTopColor')
|
||||
const u = await style(row(id).locator('.hm-sw-property__unit'), 'color')
|
||||
const input = await row(id).locator('input').evaluate(e => ({ focused: document.activeElement === e, caret: getComputedStyle(e).caretColor, shadow: getComputedStyle(e).textShadow !== 'none' }))
|
||||
check(`On(편집) ${id}: #0F0D0A, border, unit #FFF, focused input with red caret + outline`, s.backgroundColor === 'rgb(15, 13, 10)' && s.borderTopColor === HOVER && u.color === 'rgb(255, 255, 255)' && input.focused && input.caret === 'rgb(230, 4, 3)' && input.shadow && s.h === 28, { ...s, unit: u.color, ...input })
|
||||
await shot(`on-${id}`, field(id))
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
const scale = row('scale').locator('input')
|
||||
await scale.click(); await scale.fill('2'); await page.keyboard.press('Enter')
|
||||
check('Enter: 선축적 2 → validated, 4 decimals committed', (await log()).includes('property(scale, 2.0000)') && await scale.inputValue() === '2.0000', await log())
|
||||
await scale.fill('7'); await page.keyboard.press('Escape')
|
||||
check('Escape: draft cancelled (2.0000 restored, no callback)', await scale.inputValue() === '2.0000' && !(await log()).includes('7'), await scale.inputValue())
|
||||
const weight = row('weight').locator('input')
|
||||
await weight.click(); const w = await style(weight, 'backgroundColor')
|
||||
check('On(편집) 선가중치 compact: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
|
||||
await shot('on-weight', row('weight'))
|
||||
await weight.fill('-1'); await page.keyboard.press('Enter')
|
||||
check('Invalid 선가중치 -1 (min 0): restored, no callback', await weight.inputValue() === '0.25' && !(await log()).includes('weight, -1'), await weight.inputValue())
|
||||
await weight.fill('0.5'); await page.keyboard.press('Enter')
|
||||
check('선가중치 0.5 committed', (await log()).includes('property(weight, 0.5)'), await log())
|
||||
await page.mouse.click(1, 1)
|
||||
|
||||
// 4. No invented On: 하이퍼링크 (text) focus keeps the Hover look only; 재료 (computed) is read-only.
|
||||
await row('link').locator('input').click()
|
||||
const link = await style(field('link'), 'backgroundColor', 'borderTopColor')
|
||||
check('하이퍼링크 focus: no On surface (Figma has no On), editable', link.backgroundColor === CLEAR && link.borderTopColor === HOVER, link)
|
||||
await row('link').locator('input').fill('http://a'); await page.keyboard.press('Enter')
|
||||
check('하이퍼링크 commit via host callback', (await log()).includes('property(link, http://a)'), await log())
|
||||
await row('material').locator('input').click()
|
||||
const mat = await style(field('material'), 'backgroundColor')
|
||||
check('재료: read-only, no On', mat.backgroundColor === CLEAR && await row('material').locator('input').evaluate(e => e.readOnly), mat)
|
||||
await page.mouse.click(1, 1)
|
||||
|
||||
// 5. Layer dropdown (26:12265 / 26:12291): #211D13, 234px list right-aligned under the field, rows 30, footer 40.
|
||||
const layerTrigger = field('layer').locator('.hm-sw-property__trigger')
|
||||
await layerTrigger.click()
|
||||
const drop = page.locator('.hm-sw-property__drop')
|
||||
let f = await style(field('layer'), 'backgroundColor', 'borderTopColor'), d = await style(drop.first())
|
||||
const rows = await drop.locator('.hm-sw-header__layer-row').count()
|
||||
const rowH = await style(drop.locator('.hm-sw-header__layer-row').first()), foot = await style(drop.locator('.hm-sw-header__layer-footer'))
|
||||
check('Layer On: #211D13 field, 234 wide list, right edges meet, starts at field bottom', f.backgroundColor === 'rgb(33, 29, 19)' && d.w === 234 && Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d })
|
||||
check('Layer list rows 30px, footer 40px, height = rows*30+40', rowH.h === 30 && foot.h === 40 && Math.abs(d.h - (rows * 30 + 40)) < 0.5, { rows, row: rowH.h, footer: foot.h, h: d.h })
|
||||
check('Layer current row marked (0_Layer_Cludmark)', await drop.locator('[aria-current="true"]').textContent() === '0_Layer_Cludmark', null)
|
||||
await shot('on-layer', page)
|
||||
await drop.locator('.hm-sw-header__layer-row').nth(3).locator('.hm-sw-header__layer-toggle').first().click()
|
||||
check('Bulb toggle: callback only, list stays open, row not selected', (await log()).includes('layer.visible(Sample-Wall, true)') && await drop.count() === 1 && !(await log()).includes('property(layer, Sample-Wall)'), await log())
|
||||
await drop.getByRole('button', { name: 'CC-D GNL', exact: true }).click()
|
||||
check('Row select: property(layer, CC-D GNL), closes, field shows value', (await log()).includes('property(layer, CC-D GNL)') && await drop.count() === 0 && (await field('layer').textContent()).includes('CC-D GNL'), await log())
|
||||
check('Close keeps the value and focus returns to the field', await layerTrigger.evaluate(e => document.activeElement === e), null)
|
||||
// Mixed (다양함): no current row; host switch to single value.
|
||||
await field('layer-mixed').locator('.hm-sw-property__trigger').click()
|
||||
check('다양함: text 다양함, no current row in list', (await field('layer-mixed').locator('.hm-sw-property__trigger').textContent()) === '다양함' && await drop.locator('[aria-current="true"]').count() === 0, null)
|
||||
await shot('on-layer-mixed', page)
|
||||
await page.keyboard.press('Escape')
|
||||
check('Escape closes the layer list; panel stays', await drop.count() === 0 && await live.locator('.hm-sw-property').count() === 1, null)
|
||||
await page.getByRole('button', { name: '레이어 다양함 ↔ 단일' }).click()
|
||||
check('Host data: 다양함 → 단일 값 (0_Layer_Cludmark)', (await field('layer-mixed').textContent()) === '0_Layer_Cludmark', await field('layer-mixed').textContent())
|
||||
await page.getByRole('button', { name: '레이어 외부 값 변경' }).click()
|
||||
check('External value change shown (CC-D GNL)', (await field('layer').textContent()) === 'CC-D GNL', null)
|
||||
// Keyboard: Enter opens, ArrowDown moves into the list, Escape closes back to the trigger.
|
||||
await layerTrigger.focus(); await page.keyboard.press('ArrowDown')
|
||||
const focusedInList = await page.evaluate(() => !!document.activeElement?.closest('.hm-sw-property__drop'))
|
||||
await page.keyboard.press('Escape')
|
||||
check('Keyboard: ArrowDown opens + focuses list, Escape closes to trigger', focusedInList && await drop.count() === 0 && await layerTrigger.evaluate(e => document.activeElement === e), null)
|
||||
await layerTrigger.click(); await page.mouse.click(1300, 900)
|
||||
check('Outside click closes', await drop.count() === 0, null)
|
||||
|
||||
// 6. Line style (26:12388 / 26:12404): 466 = 182 + 142 + 142, right-aligned, preview follows the value.
|
||||
await field('line').locator('.hm-sw-property__trigger').click()
|
||||
f = await style(field('line'), 'backgroundColor'); d = await style(drop.locator('.hm-sw-header__line-drop'))
|
||||
const cols = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.map(e => +e.getBoundingClientRect().width.toFixed(2)))
|
||||
const unitOn = await style(field('line').locator('.hm-sw-property__unit'), 'color')
|
||||
check('Line style On: #211D13, (ByL) #D4CBBD, 3 columns, right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 3 && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols })
|
||||
check('Line style drop 466 x 190 (type column grows only for wider font names)', d.w >= 466 && d.h === 190, { w: d.w, h: d.h, cols })
|
||||
await shot('on-line', page)
|
||||
await drop.getByRole('button', { name: 'ByBlock', exact: true }).nth(1).click()
|
||||
check('Weight column: callback only, stays open', (await log()).includes('lineWeight(w-byblock)') && await drop.count() === 1, await log())
|
||||
await drop.getByRole('button', { name: 'CENTER' }).click()
|
||||
const segs = await field('line').locator('.hm-sw-header__line-preview > i').count()
|
||||
check('Line type select: property(line, CENTER), closes, preview = CENTER pattern (4 segments)', (await log()).includes('property(line, CENTER)') && await drop.count() === 0 && segs === 4, { segs })
|
||||
await page.getByRole('button', { name: '선스타일 외부 CENTER' }).click()
|
||||
|
||||
// 7. Color (26:12170 / 26:12184): RGB value | swatch, 192px dropdown left-aligned, ByLayer / ByBlock, chips, pick, validation.
|
||||
const colorInput = field('color').locator('.hm-sw-property__color-value input')
|
||||
check('Figma sample 255,259,0 shown as host text, swatch from host', await colorInput.inputValue() === '255,259,0', null)
|
||||
await colorInput.click()
|
||||
f = await style(field('color')); d = await style(drop.first())
|
||||
const left = await style(field('color').locator('.hm-sw-property__color-value'), 'backgroundColor', 'borderRightColor')
|
||||
check('Color On: dropdown 192 wide, left edges meet, RGB area #211D13 + divider', d.w === 192 && Math.abs(d.x - f.x) < 0.5 && Math.abs(d.y - (f.y + f.h)) < 0.5 && left.backgroundColor === 'rgb(33, 29, 19)' && left.borderRightColor === HOVER, { f, d, left })
|
||||
check('Color dropdown height 158 (Figma)', d.h === 158, d.h)
|
||||
await shot('on-color', page)
|
||||
await drop.getByRole('button', { name: 'ByBlock' }).click()
|
||||
check('ByBlock: callback, required single choice (ByBlock pressed, ByLayer not)', (await log()).includes('colorMode(byBlock)') && await drop.getByRole('button', { name: 'ByBlock' }).getAttribute('aria-pressed') === 'true' && await drop.getByRole('button', { name: 'ByLayer' }).getAttribute('aria-pressed') === 'false', null)
|
||||
await drop.getByRole('button', { name: 'ByBlock' }).click()
|
||||
check('Clicking the pressed mode is a no-op', (await log()).split('colorMode').length === 2, null)
|
||||
await drop.getByRole('button', { name: '빨강' }).click()
|
||||
check('Chip: property(color, 241,4,4)', (await log()).includes('property(color, 241,4,4)'), await log())
|
||||
const inner = drop.getByRole('textbox', { name: '색상값 (R,G,B)' })
|
||||
await inner.fill('255,259,0'); await page.keyboard.press('Enter')
|
||||
check('Out-of-range 255,259,0 rejected (no callback, restored)', !(await log()).includes('255,259,0') && await inner.inputValue() === '241,4,4', await inner.inputValue())
|
||||
await inner.fill('1, 2, 3'); await page.keyboard.press('Enter')
|
||||
check('Valid RGB committed normalized (1,2,3)', (await log()).includes('property(color, 1,2,3)'), await log())
|
||||
await drop.getByRole('button', { name: '색상 추출' }).click()
|
||||
check('btn_colorize: host callback, dropdown closes', (await log()).includes('colorPick(color)') && await drop.count() === 0, null)
|
||||
await colorInput.click(); await colorInput.fill('300,0,0'); await page.keyboard.press('Enter')
|
||||
check('Field RGB 300,0,0 rejected', !(await log()).includes('300,0,0') && await colorInput.inputValue() === '1,2,3', await colorInput.inputValue())
|
||||
await colorInput.fill('9,9,9'); await page.keyboard.press('Escape')
|
||||
check('Escape #1 cancels draft, dropdown still open', await colorInput.inputValue() === '1,2,3' && await drop.count() === 1, null)
|
||||
await page.keyboard.press('Escape')
|
||||
check('Escape #2 closes the dropdown, panel stays', await drop.count() === 0 && await live.locator('.hm-sw-property').count() === 1, null)
|
||||
|
||||
// 8. Disabled / ReadOnly and external updates.
|
||||
await page.getByRole('button', { name: '두께·레이어 ReadOnly' }).click()
|
||||
await page.getByRole('button', { name: '선축적·색상 Disabled' }).click()
|
||||
await row('thickness').locator('input').click()
|
||||
const ro = await style(field('thickness'), 'backgroundColor')
|
||||
await field('layer').locator('.hm-sw-property__trigger').click({ force: true })
|
||||
check('ReadOnly 두께: no On; ReadOnly 레이어: no dropdown', ro.backgroundColor === CLEAR && await drop.count() === 0, ro)
|
||||
await field('thickness').hover()
|
||||
check('ReadOnly keeps Hover border', (await style(field('thickness'), 'borderTopColor')).borderTopColor === HOVER, null)
|
||||
await colorInput.click({ force: true })
|
||||
await field('scale').hover()
|
||||
check('Disabled 색상: no dropdown; Disabled 선축적: no hover border', await drop.count() === 0 && (await style(field('scale'), 'borderTopColor')).borderTopColor === CLEAR, null)
|
||||
await page.getByRole('button', { name: '두께·레이어 ReadOnly' }).click(); await page.getByRole('button', { name: '선축적·색상 Disabled' }).click()
|
||||
await page.getByRole('button', { name: '선축적 외부 값 변경' }).click(); await page.getByRole('button', { name: '두께 단위 변경' }).click()
|
||||
check('External value / unit props: 선축적 2.5000, 두께 cm', await scale.inputValue() === '2.5000' && (await row('thickness').locator('.hm-sw-property__unit').textContent()) === 'cm', null)
|
||||
await page.getByRole('button', { name: '속성 추가' }).click(); await page.getByRole('button', { name: '하이퍼링크 삭제' }).click()
|
||||
const ids = await live.locator('.hm-sw-property__row').evaluateAll(es => es.map(e => e.dataset.propertyId))
|
||||
check('Dynamic rows: added at the end, removed row gone, order kept', ids.at(-1).startsWith('extra-') && !ids.includes('link') && ids[0] === 'layer-mixed', ids)
|
||||
await page.getByRole('button', { name: /menus 없음/ }).click()
|
||||
await field('line').locator('.hm-sw-property__trigger').click()
|
||||
const fallback = await drop.locator('.hm-sw-header__line-column').count()
|
||||
await page.keyboard.press('Escape')
|
||||
check('Without menus: line types from options only (1 column), no crash', fallback === 1, fallback)
|
||||
|
||||
// 9. Pinned Hover specimen matches the real hover.
|
||||
const pinned = await page.locator('[data-fields="hover"] .hm-sw-property__value[data-field]').first().evaluate(e => getComputedStyle(e).borderTopColor)
|
||||
check('Pinned Hover specimen uses the same border', pinned === HOVER, pinned)
|
||||
|
||||
// 10. Four property panel states keep their Figma sizes.
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--property-panel-compare&viewMode=story`)
|
||||
await page.locator('.hm-sw-property').first().waitFor(); await page.evaluate(() => document.fonts.ready)
|
||||
const sizes = await page.locator('[data-compare]').evaluateAll(cells => cells.map(c => { const r = c.querySelector('.hm-sw-property').getBoundingClientRect(); return [c.dataset.compare, Math.round(r.width), Math.round(r.height)] }))
|
||||
const want = { none: [314, 256], all: [314, 361], circle: [314, 658], expanded: [314, 754] }
|
||||
check('4 panel states: 314 x 256 / 361 / 658 / 754', sizes.every(([k, w, h]) => w === want[k][0] && h === want[k][1]), sizes)
|
||||
await shot('compare', page.locator('.sw-story-compare'))
|
||||
// The header-hosted panel: layer field opens its list over the header page as well.
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--property-panel&viewMode=story`)
|
||||
await page.locator('.hm-sw-header').waitFor()
|
||||
await page.getByRole('button', { name: '원 1개', exact: true }).click()
|
||||
await page.locator('.hm-sw-header [data-property-panel-trigger]').click()
|
||||
await page.locator('.hm-sw-property .hm-sw-property__row[data-property-id="layer"] .hm-sw-property__trigger').click()
|
||||
const hosted = await page.evaluate(() => { const d = document.querySelector('.hm-sw-property__drop'), f = document.querySelector('.hm-sw-property__row[data-property-id="layer"] .hm-sw-property__value'); if (!d) return null; const a = d.getBoundingClientRect(), b = f.getBoundingClientRect(); return { right: a.right - b.right, top: a.top - b.bottom, panelOpen: !!document.querySelector('.hm-sw-property') } })
|
||||
await page.keyboard.press('Escape')
|
||||
const stillOpen = await page.locator('.hm-sw-property').count()
|
||||
check('Header-hosted panel: layer list attached; Escape closes list only', hosted && Math.abs(hosted.right) < 0.5 && Math.abs(hosted.top) < 0.5 && stillOpen === 1, { hosted, stillOpen })
|
||||
check('no console / page errors', report.errors.length === 0, report.errors)
|
||||
await writeFile(path.join(out, `${label}-result.json`), JSON.stringify(report, null, 2))
|
||||
await browser.close()
|
||||
const failed = report.checks.filter(c => c.status === 'FAIL').length
|
||||
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
Reference in new issue
Block a user