diff --git a/docs/sw/property-fields-qa/run4-compare.png b/docs/sw/property-fields-qa/run4-compare.png new file mode 100644 index 0000000..72d05d1 Binary files /dev/null and b/docs/sw/property-fields-qa/run4-compare.png differ diff --git a/docs/sw/property-fields-qa/run4-default.png b/docs/sw/property-fields-qa/run4-default.png new file mode 100644 index 0000000..a0fe15c Binary files /dev/null and b/docs/sw/property-fields-qa/run4-default.png differ diff --git a/docs/sw/property-fields-qa/run4-hover-scale.png b/docs/sw/property-fields-qa/run4-hover-scale.png new file mode 100644 index 0000000..6293475 Binary files /dev/null and b/docs/sw/property-fields-qa/run4-hover-scale.png differ diff --git a/docs/sw/property-fields-qa/run4-on-color.png b/docs/sw/property-fields-qa/run4-on-color.png new file mode 100644 index 0000000..afd082e Binary files /dev/null and b/docs/sw/property-fields-qa/run4-on-color.png differ diff --git a/docs/sw/property-fields-qa/run4-on-layer-mixed.png b/docs/sw/property-fields-qa/run4-on-layer-mixed.png new file mode 100644 index 0000000..128ce34 Binary files /dev/null and b/docs/sw/property-fields-qa/run4-on-layer-mixed.png differ diff --git a/docs/sw/property-fields-qa/run4-on-layer.png b/docs/sw/property-fields-qa/run4-on-layer.png new file mode 100644 index 0000000..a54a187 Binary files /dev/null and b/docs/sw/property-fields-qa/run4-on-layer.png differ diff --git a/docs/sw/property-fields-qa/run4-on-line.png b/docs/sw/property-fields-qa/run4-on-line.png new file mode 100644 index 0000000..ea3b391 Binary files /dev/null and b/docs/sw/property-fields-qa/run4-on-line.png differ diff --git a/docs/sw/property-fields-qa/run4-on-scale.png b/docs/sw/property-fields-qa/run4-on-scale.png new file mode 100644 index 0000000..e49e4cc Binary files /dev/null and b/docs/sw/property-fields-qa/run4-on-scale.png differ diff --git a/docs/sw/property-fields-qa/run4-on-thickness.png b/docs/sw/property-fields-qa/run4-on-thickness.png new file mode 100644 index 0000000..b0d60e9 Binary files /dev/null and b/docs/sw/property-fields-qa/run4-on-thickness.png differ diff --git a/docs/sw/property-fields-qa/run4-on-weight.png b/docs/sw/property-fields-qa/run4-on-weight.png new file mode 100644 index 0000000..861b316 Binary files /dev/null and b/docs/sw/property-fields-qa/run4-on-weight.png differ diff --git a/docs/sw/property-fields-qa/run4-result.json b/docs/sw/property-fields-qa/run4-result.json new file mode 100644 index 0000000..4999f20 --- /dev/null +++ b/docs/sw/property-fields-qa/run4-result.json @@ -0,0 +1,813 @@ +{ + "label": "run4", + "checks": [ + { + "name": "Default layer-mixed: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 371.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default layer: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 403.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default color: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 435.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default line: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 467.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default scale: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 531.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default link: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 595.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default thickness: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 627.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default material: 200 x 28, radius 2, no border / surface", + "status": "PASS", + "data": { + "x": 97, + "y": 659.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopLeftRadius": "2px" + } + }, + { + "name": "Default weight: compact 28px hug field (pl 6 / pr 4 incl. border), no border", + "status": "PASS", + "data": { + "x": 97, + "y": 499.5, + "w": 39.23, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "paddingLeft": "5px", + "paddingRight": "3px" + } + }, + { + "name": "Default opacity: compact 28px hug field (pl 6 / pr 4 incl. border), no border", + "status": "PASS", + "data": { + "x": 97, + "y": 563.5, + "w": 39.23, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "paddingLeft": "5px", + "paddingRight": "3px" + } + }, + { + "name": "Typography: value #E60403 14px, unit #000 12px", + "status": "PASS", + "data": { + "value": [ + "rgb(230, 4, 3)", + "14px" + ], + "unit": [ + "rgb(0, 0, 0)", + "12px" + ] + } + }, + { + "name": "Hover layer-mixed: rgba(33,29,19,.4) border, 28px kept, value not moved", + "status": "PASS", + "data": { + "x": 97, + "y": 371.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 377.5 + ], + "after": [ + 105, + 377.5 + ] + } + }, + { + "name": "Mouse Leave layer-mixed: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 371.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover layer: rgba(33,29,19,.4) border, 28px kept, value not moved", + "status": "PASS", + "data": { + "x": 97, + "y": 403.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 409.5 + ], + "after": [ + 105, + 409.5 + ] + } + }, + { + "name": "Mouse Leave layer: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 403.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover color: rgba(33,29,19,.4) border, 28px kept, value not moved, divider shown", + "status": "PASS", + "data": { + "x": 98, + "y": 436.5, + "w": 114, + "h": 26, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 436.5 + ], + "after": [ + 105, + 436.5 + ], + "borderRightColor": "rgba(33, 29, 19, 0.4)" + } + }, + { + "name": "Mouse Leave color: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 435.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover line: rgba(33,29,19,.4) border, 28px kept, value not moved", + "status": "PASS", + "data": { + "x": 97, + "y": 467.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 473.5 + ], + "after": [ + 105, + 473.5 + ] + } + }, + { + "name": "Mouse Leave line: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 467.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover scale: rgba(33,29,19,.4) border, 28px kept, value not moved", + "status": "PASS", + "data": { + "x": 97, + "y": 531.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 532.5 + ], + "after": [ + 105, + 532.5 + ] + } + }, + { + "name": "Mouse Leave scale: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 531.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover link: rgba(33,29,19,.4) border, 28px kept, value not moved", + "status": "PASS", + "data": { + "x": 97, + "y": 595.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 596.5 + ], + "after": [ + 105, + 596.5 + ] + } + }, + { + "name": "Mouse Leave link: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 595.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover thickness: rgba(33,29,19,.4) border, 28px kept, value not moved", + "status": "PASS", + "data": { + "x": 97, + "y": 627.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 628.5 + ], + "after": [ + 105, + 628.5 + ] + } + }, + { + "name": "Mouse Leave thickness: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 627.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover material: rgba(33,29,19,.4) border, 28px kept, value not moved", + "status": "PASS", + "data": { + "x": 97, + "y": 659.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "backgroundColor": "rgba(0, 0, 0, 0)", + "before": [ + 105, + 660.5 + ], + "after": [ + 105, + 660.5 + ] + } + }, + { + "name": "Mouse Leave material: back to Default", + "status": "PASS", + "data": { + "x": 97, + "y": 659.5, + "w": 200, + "h": 28, + "borderTopColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover / Leave weight compact", + "status": "PASS", + "data": { + "hover": "rgba(33, 29, 19, 0.4)", + "leave": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Hover / Leave opacity compact", + "status": "PASS", + "data": { + "hover": "rgba(33, 29, 19, 0.4)", + "leave": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "On(편집) scale: #0F0D0A, border, unit #FFF, focused input with red caret + outline", + "status": "PASS", + "data": { + "x": 97, + "y": 531.5, + "w": 200, + "h": 28, + "backgroundColor": "rgb(15, 13, 10)", + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "unit": "rgb(255, 255, 255)", + "focused": true, + "caret": "rgb(230, 4, 3)", + "shadow": true + } + }, + { + "name": "On(편집) thickness: #0F0D0A, border, unit #FFF, focused input with red caret + outline", + "status": "PASS", + "data": { + "x": 97, + "y": 627.5, + "w": 200, + "h": 28, + "backgroundColor": "rgb(15, 13, 10)", + "borderTopColor": "rgba(33, 29, 19, 0.4)", + "unit": "rgb(255, 255, 255)", + "focused": true, + "caret": "rgb(230, 4, 3)", + "shadow": true + } + }, + { + "name": "Enter: 선축적 2 → validated, 4 decimals committed", + "status": "PASS", + "data": "property(scale, 2.0000)" + }, + { + "name": "Escape: draft cancelled (2.0000 restored, no callback)", + "status": "PASS", + "data": "2.0000" + }, + { + "name": "On(편집) 선가중치 compact: #0F0D0A", + "status": "PASS", + "data": { + "x": 97, + "y": 499.5, + "w": 43.03, + "h": 28, + "backgroundColor": "rgb(15, 13, 10)" + } + }, + { + "name": "Invalid 선가중치 -1 (min 0): restored, no callback", + "status": "PASS", + "data": "0.25" + }, + { + "name": "선가중치 0.5 committed", + "status": "PASS", + "data": "property(scale, 2.0000) → property(weight, 0.5)" + }, + { + "name": "하이퍼링크 focus: no On surface (Figma has no On), editable", + "status": "PASS", + "data": { + "x": 97, + "y": 595.5, + "w": 200, + "h": 28, + "backgroundColor": "rgba(0, 0, 0, 0)", + "borderTopColor": "rgba(33, 29, 19, 0.4)" + } + }, + { + "name": "하이퍼링크 commit via host callback", + "status": "PASS", + "data": "property(scale, 2.0000) → property(weight, 0.5) → property(link, http://a)" + }, + { + "name": "재료: read-only, no On", + "status": "PASS", + "data": { + "x": 97, + "y": 659.5, + "w": 200, + "h": 28, + "backgroundColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "Layer On: #211D13 field, 234 wide list, right edges meet, starts at field bottom", + "status": "PASS", + "data": { + "f": { + "x": 97, + "y": 403.5, + "w": 200, + "h": 28, + "backgroundColor": "rgb(33, 29, 19)", + "borderTopColor": "rgba(33, 29, 19, 0.4)" + }, + "d": { + "x": 63, + "y": 431.5, + "w": 234, + "h": 250 + } + } + }, + { + "name": "Layer list rows 30px, footer 40px, height = rows*30+40", + "status": "PASS", + "data": { + "rows": 7, + "row": 30, + "footer": 40, + "h": 250 + } + }, + { + "name": "Layer current row marked (0_Layer_Cludmark)", + "status": "PASS", + "data": null + }, + { + "name": "Bulb toggle: callback only, list stays open, row not selected", + "status": "PASS", + "data": "property(scale, 2.0000) → property(weight, 0.5) → property(link, http://a) → layer.visible(Sample-Wall, true)" + }, + { + "name": "Row select: property(layer, CC-D GNL), closes, field shows value", + "status": "PASS", + "data": "property(scale, 2.0000) → property(weight, 0.5) → property(link, http://a) → layer.visible(Sample-Wall, true) → property(layer, CC-D GNL)" + }, + { + "name": "Close keeps the value and focus returns to the field", + "status": "PASS", + "data": null + }, + { + "name": "다양함: text 다양함, no current row in list", + "status": "PASS", + "data": null + }, + { + "name": "Escape closes the layer list; panel stays", + "status": "PASS", + "data": null + }, + { + "name": "Host data: 다양함 → 단일 값 (0_Layer_Cludmark)", + "status": "PASS", + "data": "0_Layer_Cludmark" + }, + { + "name": "External value change shown (CC-D GNL)", + "status": "PASS", + "data": null + }, + { + "name": "Keyboard: ArrowDown opens + focuses list, Escape closes to trigger", + "status": "PASS", + "data": null + }, + { + "name": "Outside click closes", + "status": "PASS", + "data": null + }, + { + "name": "Line style On: #211D13, (ByL) #D4CBBD, 3 columns, right edges meet (or shifted to the 4px screen edge when no room)", + "status": "PASS", + "data": { + "f": { + "x": 97, + "y": 485.5, + "w": 200, + "h": 28, + "backgroundColor": "rgb(33, 29, 19)" + }, + "d": { + "x": 4, + "y": 513.5, + "w": 471.19, + "h": 190 + }, + "cols": [ + 187.19, + 142, + 142 + ] + } + }, + { + "name": "Line style drop 466 x 190 (type column grows only for wider font names)", + "status": "PASS", + "data": { + "w": 471.19, + "h": 190, + "cols": [ + 187.19, + 142, + 142 + ] + } + }, + { + "name": "Weight column: callback only, stays open", + "status": "PASS", + "data": "property(scale, 2.0000) → property(weight, 0.5) → property(link, http://a) → layer.visible(Sample-Wall, true) → property(layer, CC-D GNL) → lineWeight(w-byblock)" + }, + { + "name": "Line type select: property(line, CENTER), closes, preview = CENTER pattern (4 segments)", + "status": "PASS", + "data": { + "segs": 4 + } + }, + { + "name": "Figma sample 255,259,0 shown as host text, swatch from host", + "status": "PASS", + "data": null + }, + { + "name": "Color On: dropdown 192 wide, left edges meet, RGB area #211D13 + divider", + "status": "PASS", + "data": { + "f": { + "x": 97, + "y": 435.5, + "w": 200, + "h": 28 + }, + "d": { + "x": 97, + "y": 463.5, + "w": 192, + "h": 158 + }, + "left": { + "x": 98, + "y": 436.5, + "w": 114, + "h": 26, + "backgroundColor": "rgb(33, 29, 19)", + "borderRightColor": "rgba(33, 29, 19, 0.4)" + } + } + }, + { + "name": "Color dropdown height 158 (Figma)", + "status": "PASS", + "data": 158 + }, + { + "name": "ByBlock: callback, required single choice (ByBlock pressed, ByLayer not)", + "status": "PASS", + "data": null + }, + { + "name": "Clicking the pressed mode is a no-op", + "status": "PASS", + "data": null + }, + { + "name": "Chip: property(color, 241,4,4)", + "status": "PASS", + "data": "property(scale, 2.0000) → property(weight, 0.5) → property(link, http://a) → layer.visible(Sample-Wall, true) → property(layer, CC-D GNL) → lineWeight(w-byblock) → property(line, CENTER) → colorMode(byBlock) → property(color, 241,4,4)" + }, + { + "name": "Out-of-range 255,259,0 rejected (no callback, restored)", + "status": "PASS", + "data": "241,4,4" + }, + { + "name": "Valid RGB committed normalized (1,2,3)", + "status": "PASS", + "data": "property(scale, 2.0000) → property(weight, 0.5) → property(link, http://a) → layer.visible(Sample-Wall, true) → property(layer, CC-D GNL) → lineWeight(w-byblock) → property(line, CENTER) → colorMode(byBlock) → property(color, 241,4,4) → property(color, 1,2,3)" + }, + { + "name": "btn_colorize: host callback, dropdown closes", + "status": "PASS", + "data": null + }, + { + "name": "Field RGB 300,0,0 rejected", + "status": "PASS", + "data": "1,2,3" + }, + { + "name": "Escape #1 cancels draft, dropdown still open", + "status": "PASS", + "data": null + }, + { + "name": "Escape #2 closes the dropdown, panel stays", + "status": "PASS", + "data": null + }, + { + "name": "ReadOnly 두께: no On; ReadOnly 레이어: no dropdown", + "status": "PASS", + "data": { + "x": 97, + "y": 647, + "w": 200, + "h": 28, + "backgroundColor": "rgba(0, 0, 0, 0)" + } + }, + { + "name": "ReadOnly keeps Hover border", + "status": "PASS", + "data": null + }, + { + "name": "Disabled 색상: no dropdown; Disabled 선축적: no hover border", + "status": "PASS", + "data": null + }, + { + "name": "External value / unit props: 선축적 2.5000, 두께 cm", + "status": "PASS", + "data": null + }, + { + "name": "Dynamic rows: added at the end, removed row gone, order kept", + "status": "PASS", + "data": [ + "layer-mixed", + "layer", + "color", + "line", + "weight", + "scale", + "opacity", + "thickness", + "material", + "extra-10" + ] + }, + { + "name": "Without menus: line types from options only (1 column), no crash", + "status": "PASS", + "data": 1 + }, + { + "name": "Pinned Hover specimen uses the same border", + "status": "PASS", + "data": "rgba(33, 29, 19, 0.4)" + }, + { + "name": "4 panel states: 314 x 256 / 361 / 658 / 754", + "status": "PASS", + "data": [ + [ + "none", + 314, + 256 + ], + [ + "all", + 314, + 361 + ], + [ + "circle", + 314, + 658 + ], + [ + "expanded", + 314, + 754 + ] + ] + }, + { + "name": "Header-hosted panel: layer list attached; Escape closes list only", + "status": "PASS", + "data": { + "hosted": { + "right": 0, + "top": 0, + "panelOpen": true + }, + "stillOpen": 1 + } + }, + { + "name": "no console / page errors", + "status": "PASS", + "data": [] + } + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/sw/sw-header-usage.md b/docs/sw/sw-header-usage.md index 39219b8..38bac66 100644 --- a/docs/sw/sw-header-usage.md +++ b/docs/sw/sw-header-usage.md @@ -502,6 +502,9 @@ function DrawingHeader() { - `toolActive/onToolActiveChange/toolLabel`로 내부 아이콘을 연결합니다. 현재 의미는 호스트가 정하는 선택 도구 상태 통지이며 CAD 기능을 내장하지 않습니다. - 단독 사용: ``. 단독 배치는 호스트의 `style`로 지정할 수 있습니다. - 동적 패널 입력은 `SWHeaderInput commitOnUnmount={false}`를 사용합니다. 기존 Input의 기본 `true` 동작은 유지합니다. +- 속성 필드 상태(2026-10-19, Figma 26:12388 ~ 26:12649): 공통 행은 200 × 28 필드(선가중치처럼 단위 없는 숫자와 투명도 값은 41px 내외 Hug)입니다. Default는 값만, Hover는 `rgba(33,29,19,.4)` 테두리, 숫자 편집 On은 `#0F0D0A` + 흰 단위, Dropdown On은 `#211D13`입니다. 하이퍼링크(text)·재료(computed)는 Figma에 On이 없어 Default/Hover만 적용합니다. [검증 기록](./sw-property-fields-verification-2026-10-19.md). +- 필드 Dropdown: `property.picker`(`"layer" | "lineStyle" | "color" | false`)와 패널 `menus`(`SWHeaderPropertyMenus`: layers·toggle Callback, lineTypes·lineWeights·lineScale, colors·colorMode·onColorPick 등)를 전달합니다. `type: "color"`는 색상, `type: "select"` + `linePreview`는 선스타일 Dropdown이 기본이며 레이어는 `picker: "layer"`를 지정합니다. 목록 선택은 해당 속성의 `onPropertyChange`(값 = 행 id)로 확정되고, 레이어 Toggle·가중치·스케일·ByLayer/ByBlock·색상 추출·상세는 `menus` Callback만 호출합니다. `menus`가 없으면 `options`로 목록을 만듭니다. `mixed`는 `다양함`으로 표시하며 현재 행을 표시하지 않습니다. +- `decimals`를 지정한 숫자는 확정값을 고정 소수 자릿수로 정규화합니다(예: 선축적 `2` → `2.0000`). 색상 입력은 기존 0~255 검증을 유지합니다. - K 가이드는 별도 데이터를 요구하지 않습니다. 기존 K 버튼 Hover/Focus로 원본 이미지가 Pan/Zoom 반복되고, 작은 화면의 More에서도 같은 미리보기가 보입니다. Reduced Motion·Escape·재진입 초기화가 적용됩니다. 클릭은 기존 `onPopupGuide`만 호출합니다. - 색상 모드의 null은 직접 색상 의미를 보존하고, 필수 모드 표시는 마지막 유효 모드(처음 ByLayer)를 유지합니다. 선택된 모드를 재클릭해도 onColorModeChange가 중복 호출되지 않습니다. - 신규 Story: Property Panel / Property Panel Variants / K Guide / Additional Hover. 기존 17개 Story를 유지합니다. diff --git a/docs/sw/sw-property-fields-verification-2026-10-19.md b/docs/sw/sw-property-fields-verification-2026-10-19.md new file mode 100644 index 0000000..44cf99c --- /dev/null +++ b/docs/sw/sw-property-fields-verification-2026-10-19.md @@ -0,0 +1,44 @@ +# SW 속성창 입력·선택 필드 검증 (2026-10-19) + +Figma `rj6wpsfk24lOWvWiVRAANP`의 속성 필드 13개 노드를 기존 `SWHeaderPropertyPanel` 행에 반영했습니다. 팝업은 새로 만들지 않았고, Header의 레이어·색상·선스타일 Dropdown 본문을 공유 컴포넌트로 분리해 재사용했습니다. + +## 구현 범위 + +| Figma | 항목 | Default / Hover | On | +|---|---|---|---| +| 26:12388 / 26:12404 | 선스타일 | 값 + `(ByL)` + 선택한 선 패턴 | `#211D13`, `(ByL)` `#D4CBBD`, 3열 Dropdown(우측 정렬) | +| 26:12250 / 26:12265 | 레이어 — 다양함 | `다양함` | `#211D13`, 234px 레이어 목록(현재 행 없음) | +| 26:12260 / 26:12291 | 레이어 — 단일 값 | 레이어 이름 | `#211D13`, 234px 레이어 목록(현재 행 강조) | +| 26:12314 | 선축적 | 값 + `(LTscale:1)` | 편집 `#0F0D0A`, 단위 흰색, 빨간 caret | +| 26:12649 | 선가중치 · 투명도 | 41px 내외 Hug(pl 6 / pr 4) | 편집 `#0F0D0A` | +| 26:12170 / 26:12184 | 색상 | RGB + 70 × 16 색상칩, Hover 시 구분선 | RGB 영역 `#211D13`, 192px 색상 Dropdown(좌측 정렬) | +| 26:12583 | 하이퍼링크 | `--` | Figma On 없음, Hover 모양만 유지 | +| 26:12590 | 두께 | 값 + `mm` | 편집 `#0F0D0A`, 단위 흰색 | +| 26:12574 | 재료 | 값 + `(ByL)` | Figma On 없음(computed, 읽기 전용) | + +공통: 높이 28px, radius 2px, Hover `1px rgba(33,29,19,.4)`. 테두리는 Default에서도 투명 1px로 존재해 Hover/On에서 값이 움직이지 않습니다. + +## 실행한 검증 + +| 항목 | 결과 | 근거 | +|---|---|---| +| 필드 인터랙션 (Chrome, Playwright) | 77/77 PASS | `packages/sw/tests/property-fields.browser.mjs`, `property-fields-qa/run4-result.json` | +| 기존 Header·Dropdown 회귀(detail-buttons verify) | 257 checks PASS, 33 stories | 기존 baseline 대비, 산출물은 임시 폴더 | +| followup-four | 39/39 PASS | 기존 스크립트 재실행 | +| property-close | 6/6 PASS | 기존 스크립트 재실행 | +| 속성창 4상태 크기 | 314 × 256 / 361 / 658 / 754 | 위 인터랙션 테스트 마지막 단계 | +| SW Typecheck / Build, 루트 앱 Typecheck, Storybook Build | PASS | 임시 폴더로 빌드, `storybook-static`은 변경하지 않음 | + +## Figma와 남은 차이 + +- 글꼴: Figma Dotum과 달리 Noto Sans KR은 폭이 넓습니다. 선스타일 필드는 이름이 잘리지 않도록 선 미리보기를 68px에서 최소 40px까지 줄입니다. Dropdown의 선 종류 열은 기존 Header 정책대로 182px보다 넓어질 수 있습니다(측정 187px). +- 값 굵기: 기존 속성창 기준 500을 유지했습니다(Figma 레이어명은 Dotum Regular이고 컴포넌트명에 bold가 붙어 있습니다). +- Figma에서는 Hover 시 테두리 때문에 값이 1px 이동하지만, 요청에 따라 구현은 이동하지 않습니다. 색상칩의 오른쪽 여백은 Default 기준(6px)입니다. +- 열린 Dropdown은 화면 여유가 없으면 화면 안쪽(4px)으로 이동하거나 필드 위로 열립니다. +- 하이퍼링크는 편집 가능하지만 Figma에 On이 없으므로 포커스 중에도 Hover 테두리만 표시합니다. + +## NOT VERIFIED + +- Firefox: 이번 필드 변경은 Chrome에서만 실행했습니다. Firefox의 `field-sizing` 미지원 경로(Hug 폭 계산)는 실행하지 않았습니다. +- 실제 CAD 호스트 연동(색상 추출, 레이어 상세 등)은 Callback 호출까지만 확인했습니다. +- Header 77개 구성 시나리오와 10개 폭 비교는 이번 변경 범위가 아니어서 다시 실행하지 않고 기존 PASS 근거를 재사용합니다. diff --git a/packages/sw/src/components/sw-header/components/SWHeaderColorMenu.tsx b/packages/sw/src/components/sw-header/components/SWHeaderColorMenu.tsx index 0f25fbf..378ddbc 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderColorMenu.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderColorMenu.tsx @@ -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(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 ( - - ) - } + const [selectedMode, chooseMode] = useSWHeaderColorMode(mode) return (
@@ -104,63 +80,132 @@ export function SWHeaderColorMenu({ data-shifted={shifted ? "true" : undefined} style={panelStyle} > -
- {modes.map(item => ( - - ))} -
-
- {colors.length > 0 &&
{colors.map(chip)}
} - {userColors && userColors.length > 0 && ( -
{userColors.map(chip)}
- )} -
-
- - - - parseSWHeaderColor(draft)?.value ?? null} - onCommit={onColorInput ? next => { - const parsed = parseSWHeaderColor(next) - if (parsed) onColorInput(parsed) - } : undefined} - /> - -
-
- { onDetail(); close(true) })} /> -
+ { + chooseMode(next) + onModeChange?.(next) + }} + onColorInput={onColorInput} + onPick={onPick && (() => { + onPick() + close(true) + })} + onDetail={onDetail && (() => { onDetail(); close(true) })} + />
)} ) } + +/** + * 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(mode ?? "byLayer") + useEffect(() => { if (mode) setLastMode(mode) }, [mode]) + return [mode ?? lastMode, setLastMode] as const +} + +export interface SWHeaderColorPanelBodyProps extends Pick { + /** 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 ( + + ) + } + + return ( + <> +
+ {modes.map(item => ( + + ))} +
+
+ {colors.length > 0 &&
{colors.map(chip)}
} + {userColors && userColors.length > 0 && ( +
{userColors.map(chip)}
+ )} +
+
+ + + + parseSWHeaderColor(draft)?.value ?? null} + onCommit={onColorInput ? next => { + const parsed = parseSWHeaderColor(next) + if (parsed) onColorInput(parsed) + } : undefined} + /> + +
+
+ +
+ + ) +} diff --git a/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx b/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx index 8a7c6b1..d9ddd37 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx @@ -187,46 +187,78 @@ export function SWHeaderLayerMenu({ style={offset !== 0 ? { left: offset } : undefined} >
- {layers.length > 0 && ( -
    - {layers.map(layer => ( -
  • - - - - - - -
  • - ))} -
- )} -
- { onDetail(); close(true) })} /> -
+ { + onLayerSelect?.(layer) + close(true) + }} + onVisibilityChange={onVisibilityChange} + onFreezeChange={onFreezeChange} + onLockChange={onLockChange} + onDetail={onDetail && (() => { onDetail(); close(true) })} + />
)} ) } + +export interface SWHeaderLayerRowsProps extends Pick { + 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 && ( +
    + {layers.map(layer => ( +
  • + + + + + + +
  • + ))} +
+ )} +
+ +
+ + ) +} diff --git a/packages/sw/src/components/sw-header/components/SWHeaderLineStyleMenu.tsx b/packages/sw/src/components/sw-header/components/SWHeaderLineStyleMenu.tsx index 0e4c9fa..0dc37af 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderLineStyleMenu.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderLineStyleMenu.tsx @@ -95,57 +95,95 @@ export function SWHeaderLineStyleMenu({ {open && (
-
-
-
    - {lineTypes.map(item => ( -
  • - -
  • - ))} -
-
- { onDetail(); close(true) })} /> -
-
-
-
    - {lineWeights.map(item => ( -
  • - - {item.editable && ( - // Sibling of the row button (no nested controls): editing never selects the row. - parseSWHeaderNumber(draft, { min: 0 })} - onCommit={onLineWeightValueChange && (next => onLineWeightValueChange(item, next))} - /> - )} -
  • - ))} -
-
-
-
- 스케일 - parseSWHeaderNumber(draft, { min: 0, minExclusive: true })} - onCommit={onScaleChange} - /> -
-
+ { onDetail(); close(true) })} + /> +
+ )} +
+ ) +} + +export interface SWHeaderLineStyleColumnsProps extends Pick { + 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 ( +
+
+
    + {lineTypes.map(item => ( +
  • + +
  • + ))} +
+
+ +
+
+ {lineWeights && ( +
+
    + {lineWeights.map(item => ( +
  • + + {item.editable && ( + // Sibling of the row button (no nested controls): editing never selects the row. + parseSWHeaderNumber(draft, { min: 0 })} + onCommit={onLineWeightValueChange && (next => onLineWeightValueChange(item, next))} + /> + )} +
  • + ))} +
+
+ )} + {scale !== undefined && ( +
+
+ 스케일 + parseSWHeaderNumber(draft, { min: 0, minExclusive: true })} + onCommit={onScaleChange} + />
)} diff --git a/packages/sw/src/components/sw-header/components/SWHeaderPropertyFields.tsx b/packages/sw/src/components/sw-header/components/SWHeaderPropertyFields.tsx new file mode 100644 index 0000000..d20e4fe --- /dev/null +++ b/packages/sw/src/components/sw-header/components/SWHeaderPropertyFields.tsx @@ -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 = (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({ 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) => ( +
+ {children} +
+ ) + + if (picker === "layer") { + const layers = propertyLayers(property, menus) + const current = mixed ? undefined : byValue(layers, value) + return ( +
+ + {open && drop("hm-sw-header__layer-panel", "레이어 목록", ( + { commit(layer.id); close(true) }} + onVisibilityChange={menus?.onLayerVisibilityChange} + onFreezeChange={menus?.onLayerFreezeChange} + onLockChange={menus?.onLayerLockChange} + onDetail={menus?.onLayerDetail && (() => { menus.onLayerDetail?.(); close(true) })} + /> + ))} +
+ ) + } + + 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 ( +
+ + {open && drop("", "선스타일", ( + { commit(item.id); close(true) }} + onLineWeightSelect={menus?.onLineWeightSelect} + onLineWeightValueChange={menus?.onLineWeightValueChange} + onScaleChange={menus?.onLineScaleChange} + onDetail={menus?.onLineStyleDetail && (() => { menus.onLineStyleDetail?.(); close(true) })} + /> + ))} +
+ ) + } + + return +} + +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 & { + shared: Record + trigger: Record + open: boolean + setOpen: (open: boolean) => void + close: (restoreFocus: boolean) => void + triggerRef: Ref + 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 ( +
+ + } + {...trigger} + value={mixed ? "다양함" : value} + disabled={property.disabled} + readOnly={!editable} + parse={parse} + commitOnUnmount={false} + onCommit={editable ? commit : undefined} + onClick={() => { if (editable && !open) setOpen(true) }} + /> + + + {open && drop("hm-sw-header__menu-panel hm-sw-header__color-menu", "색상", ( + 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) })} + /> + ))} +
+ ) +} diff --git a/packages/sw/src/components/sw-header/components/SWHeaderPropertyPanel.tsx b/packages/sw/src/components/sw-header/components/SWHeaderPropertyPanel.tsx index 86fa24e..9fa480e 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderPropertyPanel.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderPropertyPanel.tsx @@ -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 | 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(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(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 (
-
+ {picker ? : ( +
{ + // 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" ? ( ) : ( {property.annotation}} {unit && type !== "slider" && {unit}} - {type === "color" && } + {type === "color" && } {property.linePreview && } {type === "slider" && setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys} />} {unit && type === "slider" && {unit}}
+ )}
) } /** 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" &&
구분{visible.map(item => {item.name})}
}
{group.layout === "position" && {group.positionLabel}} - {visible.map(property =>
)} + {visible.map(property =>
)}
{expandable && }
diff --git a/packages/sw/src/components/sw-header/index.ts b/packages/sw/src/components/sw-header/index.ts index 90cf302..28d48db 100644 --- a/packages/sw/src/components/sw-header/index.ts +++ b/packages/sw/src/components/sw-header/index.ts @@ -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 { diff --git a/packages/sw/src/theme/sw.css b/packages/sw/src/theme/sw.css index 3b577b4..c7f117c 100644 --- a/packages/sw/src/theme/sw.css +++ b/packages/sw/src/theme/sw.css @@ -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; } diff --git a/packages/sw/tests/property-fields.browser.mjs b/packages/sw/tests/property-fields.browser.mjs new file mode 100644 index 0000000..87d544d --- /dev/null +++ b/packages/sw/tests/property-fields.browser.mjs @@ -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