feat(sw): implement property field interaction variants

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

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

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+813
View File
@@ -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": []
}
+3
View File
@@ -502,6 +502,9 @@ function DrawingHeader() {
- `toolActive/onToolActiveChange/toolLabel`로 내부 아이콘을 연결합니다. 현재 의미는 호스트가 정하는 선택 도구 상태 통지이며 CAD 기능을 내장하지 않습니다.
- 단독 사용: `<SWTheme><SWHeaderPropertyPanel ... onClose={close} /></SWTheme>`. 단독 배치는 호스트의 `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를 유지합니다.
@@ -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 근거를 재사용합니다.
@@ -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 {
+111 -2
View File
@@ -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)
+122 -7
View File
@@ -5,7 +5,7 @@ import {
SWHeaderProperties, SWHeaderWindowControls, SWTheme, SWHeaderLayerMenu, SWHeaderInput, swHeaderLinePatterns, SWHeaderPropertyPanel, SWHeaderPropertyBar,
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderControlConfig, type SWHeaderHideableControlId, type SWHeaderPropertyShapeContent,
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderControlConfig, type SWHeaderHideableControlId, type SWHeaderPropertyShapeContent,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
@@ -1372,11 +1372,11 @@ const PROPERTY_GROUPS: SWHeaderPropertyGroup[] = [
{ id: "position", name: "위치", layout: "position", positionLabel: "중간점" },
]
const PROPERTY_FIXTURE: SWHeaderProperty[] = [
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", validate: draft => draft.length <= 40 },
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 },
{ id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" },
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] },
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 },
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, unit: "(LTscale:1)" },
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
@@ -1412,6 +1412,45 @@ function propertyFixture(example: PropertyExample) {
}))
}
/* Property field dropdowns (2026-10-19): host data, same records as the header dropdowns. Ids are the names
because the property value is the layer / line type name. */
const PROPERTY_LAYERS: SWHeaderLayer[] = [
{ id: "0", name: "0" },
{ id: "0_Layer_Cludmark", name: "0_Layer_Cludmark", color: "#ff0000" },
{ id: "CC-D GNL", name: "CC-D GNL" },
{ id: "Sample-Wall", name: "Sample-Wall", color: "#ffe500", visible: false },
{ id: "Sample-Door", name: "Sample-Door", color: "#00ff00", frozen: true },
{ id: "Sample-Text", name: "Sample-Text", color: "#00ffff", locked: true },
{ id: "Sample-Defpoints", name: "Sample-Defpoints", color: "#808080", disabled: true },
]
const PROPERTY_LINE_TYPES: SWHeaderLineType[] = FIGMA_LINE_TYPES.map(item => ({ ...item, id: item.name }))
const linePatternOf = (value: string) => PROPERTY_LINE_TYPES.find(item => item.id === value)?.pattern
function usePropertyMenus(record: (line: string) => void, setProperties: (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void): SWHeaderPropertyMenus {
const [layers, setLayers] = useState(PROPERTY_LAYERS)
const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS)
const [weightId, setWeightId] = useState("w-default")
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
const setValue = (id: string, value: string) => setProperties(items => items.map(item => item.id === id ? { ...item, value, mixed: false } : item))
const scale = "1.0000"
const toggle = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => {
record(`layer.${key}(${layer.id}, ${next})`)
setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item))
}
return {
layers, onLayerVisibilityChange: toggle("visible"), onLayerFreezeChange: toggle("frozen"), onLayerLockChange: toggle("locked"),
onLayerDetail: () => record("layerDetail"),
lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale,
onLineWeightSelect: weight => { record(`lineWeight(${weight.id})`); setWeightId(weight.id); if (weight.value) setValue("weight", weight.value) },
onLineWeightValueChange: (weight, value) => { record(`lineWeightValue(${weight.id}, ${value})`); setWeights(items => items.map(item => item.id === weight.id ? { ...item, value } : item)) },
onLineScaleChange: value => { record(`lineScale(${value})`); setValue("scale", Number(value).toFixed(4)) },
onLineStyleDetail: () => record("lineStyleDetail"),
colors: FIGMA_COLORS, colorMode,
onColorModeChange: mode => { record(`colorMode(${mode})`); setColorMode(mode) },
onColorPick: property => record(`colorPick(${property.id})`),
onColorDetail: () => record("colorDetail"),
}
}
function PropertyPanelDemo({ standalone = false }: { standalone?: boolean }) {
const [example, setExample] = useState<PropertyExample>("none")
const [selection, setSelection] = useState(propertySelection("none"))
@@ -1422,16 +1461,17 @@ function PropertyPanelDemo({ standalone = false }: { standalone?: boolean }) {
const [width, setWidth] = useState(1920)
const [log, setLog] = useState<string[]>([])
const record = (text: string) => setLog(lines => [...lines.slice(-19), text])
const menus = usePropertyMenus(record, setProperties)
const changeExample = (next: PropertyExample) => {
setExample(next); setSelection(propertySelection(next)); setProperties(propertyFixture(next))
setExpanded(next === "expanded" ? ["common"] : [])
}
const panel = {
selection, properties, groups: PROPERTY_GROUPS, expandedGroupIds: expanded,
selection, properties, groups: PROPERTY_GROUPS, expandedGroupIds: expanded, menus,
onExpandedGroupsChange: setExpanded,
onPropertyChange: (property: SWHeaderProperty, value: string) => {
record("property(" + property.id + ", " + value + ")")
setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: value === "CENTER" ? swHeaderLinePatterns.center : value === "HIDDEN" ? swHeaderLinePatterns.hidden : undefined } : {}) } : item))
setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: linePatternOf(value) } : {}) } : item))
},
onToolActiveChange: (next: boolean) => record("tool(" + next + ")"),
onOverviewClick: () => changeExample("all"),
@@ -1662,9 +1702,10 @@ const PROPERTY_STATES: { example: PropertyExample; title: string; node: string;
function PropertyState({ example, shapeContent, onLog }: { example: PropertyExample; shapeContent?: SWHeaderPropertyShapeContent; onLog?: (line: string) => void }) {
const [properties, setProperties] = useState(() => propertyFixture(example))
const [expanded, setExpanded] = useState<string[]>(example === "expanded" ? ["common"] : [])
const menus = usePropertyMenus(line => onLog?.(line), setProperties)
return <SWTheme><SWHeaderPropertyPanel selection={propertySelection(example)} properties={properties} groups={PROPERTY_GROUPS}
expandedGroupIds={expanded} onExpandedGroupsChange={setExpanded} shapeContent={shapeContent}
onPropertyChange={(property, value) => { onLog?.(`property(${property.id}, ${value})`); setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false } : item)) }}
expandedGroupIds={expanded} onExpandedGroupsChange={setExpanded} shapeContent={shapeContent} menus={menus}
onPropertyChange={(property, value) => { onLog?.(`property(${property.id}, ${value})`); setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: linePatternOf(value) } : {}) } : item)) }}
onClose={() => onLog?.("close")} /></SWTheme>
}
function PropertyStateStory({ example }: { example: PropertyExample }) {
@@ -1745,3 +1786,77 @@ export const PropertyShapeSlot: Story = {
parameters: { controls: { disable: true } },
render: () => <StoryPage><StoryPageHeader kicker={KICKER} title="형상 영역 Slot" description="shapeContent: 미지정은 Figma 원형 그림, null은 208 × 208 빈 영역(치수 라벨·패널 높이 유지), ReactNode·Render Prop은 외부 형상 컴포넌트입니다. 예시 SVG는 Storybook 전용 샘플이며 패키지에 포함되지 않습니다. 원주·지름·반지름·면적·위치 값은 properties로만 표시됩니다." /><ShapeSlotDemo /></StoryPage>,
}
/* Property fields (2026-10-19): the 13 Figma field nodes in the real SWHeaderPropertyPanel rows.
Default / pinned Hover columns are specimens; the live panel is the one to click (On / dropdown). */
const FIELD_NODES = [
{ node: "26:12388 / 26:12404", name: "선스타일", states: "Default · Hover / On(Dropdown)" },
{ node: "26:12250 / 26:12265", name: "레이어 — 다양함", states: "Default · Hover / On(Dropdown)" },
{ node: "26:12260 / 26:12291", name: "레이어 — 단일 값", states: "Default · Hover / On(Dropdown)" },
{ node: "26:12314", name: "선축적", states: "Default · Hover · On(편집)" },
{ node: "26:12649", name: "선가중치 · 투명도", states: "Default · Hover · On(편집)" },
{ node: "26:12170 / 26:12184", name: "색상", states: "Default · Hover / On(Dropdown)" },
{ node: "26:12583", name: "하이퍼링크", states: "Default · Hover (Figma On 없음)" },
{ node: "26:12590", name: "두께", states: "Default · Hover · On(편집)" },
{ node: "26:12574", name: "재료", states: "Default · Hover (Figma On 없음)" },
]
const FIELD_FIXTURE: SWHeaderProperty[] = [
{ id: "layer-mixed", name: "레이어", value: "0_Layer_Cludmark", mixed: true, type: "text", groupId: "common", picker: "layer" },
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer" },
// Figma sample text 255,259,0 is out of range: shown as host text only, never accepted as input.
{ id: "color", name: "색상", value: "255,259,0", swatch: "#ffe500", type: "color", groupId: "common" },
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: PROPERTY_LINE_TYPES.map(({ id, name }) => ({ id, name })) },
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 },
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
{ id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" },
]
const FIELD_HOVER = [".hm-sw-property__value[data-field]", ".hm-sw-property__compact"]
function FieldPanel({ title, properties, onChange, menus }: { title: string; properties: SWHeaderProperty[]; onChange?: (property: SWHeaderProperty, value: string) => void; menus?: SWHeaderPropertyMenus }) {
return <SWTheme><SWHeaderPropertyPanel selection={{ id: title, title, kind: "none" }} properties={properties} groups={[{ id: "common" }]} onPropertyChange={onChange} menus={menus} /></SWTheme>
}
function PropertyFieldsDemo() {
const [properties, setProperties] = useState(FIELD_FIXTURE)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-11), line])
const menus = usePropertyMenus(record, setProperties as (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void)
const [noMenus, setNoMenus] = useState(false)
const patch = (id: string, change: Partial<SWHeaderProperty>) => setProperties(items => items.map(item => item.id === id ? { ...item, ...change } : item))
const change = (property: SWHeaderProperty, value: string) => {
record(`property(${property.id}, ${value})`)
patch(property.id, { value, mixed: false, ...(property.id === "line" ? { linePattern: linePatternOf(value) } : {}), ...(property.id === "color" ? { swatch: undefined } : {}) })
}
return <div data-fields-demo="">
<div className="sw-story-controls"><strong>외부 데이터</strong>
<button data-action="layer-mixed" onClick={() => patch("layer-mixed", { mixed: !properties.find(item => item.id === "layer-mixed")?.mixed })}>레이어 다양함 ↔ 단일</button>
<button data-action="layer-external" onClick={() => patch("layer", { value: "CC-D GNL" })}>레이어 외부 값 변경</button>
<button data-action="scale-external" onClick={() => patch("scale", { value: "2.5000" })}>선축적 외부 값 변경</button>
<button data-action="thickness-unit" onClick={() => patch("thickness", { unit: properties.find(item => item.id === "thickness")?.unit === "mm" ? "cm" : "mm" })}>두께 단위 변경</button>
<button data-action="line-center" onClick={() => patch("line", { value: "CENTER", linePattern: linePatternOf("CENTER") })}>선스타일 외부 CENTER</button>
<button data-action="readonly" onClick={() => setProperties(items => items.map(item => item.id === "thickness" || item.id === "layer" ? { ...item, readOnly: !item.readOnly } : item))}>두께·레이어 ReadOnly</button>
<button data-action="disabled" onClick={() => setProperties(items => items.map(item => item.id === "scale" || item.id === "color" ? { ...item, disabled: !item.disabled } : item))}>선축적·색상 Disabled</button>
<button data-action="add-row" onClick={() => setProperties(items => [...items, { id: `extra-${items.length}`, name: "추가 속성", value: "호스트 값", type: "text", groupId: "common" }])}>속성 추가</button>
<button data-action="remove-row" onClick={() => setProperties(items => items.filter(item => item.id !== "link"))}>하이퍼링크 삭제</button>
<button data-action="no-menus" onClick={() => setNoMenus(value => !value)}>menus 없음(options 사용): {String(noMenus)}</button>
</div>
<p className="sw-story-log" data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
<pre className="sw-story-layer-state" data-property-state="">{JSON.stringify(properties.map(item => [item.id, item.mixed ? "다양함" : item.value]))}</pre>
<div className="sw-story-compare">
<figure className="sw-story-compare__cell" data-fields="live"><figcaption><strong>실제 조작</strong><span>클릭 → On · Enter 확정 · Escape 취소/닫기</span></figcaption>
<FieldPanel title="실제 조작" properties={properties} onChange={change} menus={noMenus ? undefined : menus} /></figure>
<figure className="sw-story-compare__cell" data-fields="default"><figcaption><strong>Default</strong><span>Figma Default</span></figcaption>
<FieldPanel title="Default" properties={FIELD_FIXTURE} /></figure>
<figure className="sw-story-compare__cell" data-fields="hover"><figcaption><strong>Hover (고정)</strong><span>Figma Hover · 값 변경 없음</span></figcaption>
<PinHover selectors={FIELD_HOVER}><FieldPanel title="Hover" properties={FIELD_FIXTURE} /></PinHover></figure>
</div>
<table className="sw-story-node-table"><thead><tr><th>Figma</th><th>항목</th><th>상태</th></tr></thead>
<tbody>{FIELD_NODES.map(item => <tr key={item.node}><td>{item.node}</td><td>{item.name}</td><td>{item.states}</td></tr>)}</tbody></table>
</div>
}
export const PropertyFields: Story = {
name: "Property Panel · 필드 13개",
parameters: { controls: { disable: true } },
render: () => <StoryPage className="sw-story-followup"><StoryPageHeader kicker={KICKER} title="속성창 입력·선택 필드" description="Figma 13개 필드(26:12388 ~ 26:12649)를 기존 SWHeaderPropertyPanel 행으로 표시합니다. Default · Hover 고정 표본과 실제 조작 패널입니다. 숫자는 편집 On(#0F0D0A), 레이어·선스타일·색상은 Dropdown On(#211D13)이며 하이퍼링크·재료는 Figma에 On이 없어 Default · Hover만 있습니다. 색상 255,259,0은 Figma 표기 예시로, 입력 검증은 0~255를 유지합니다." /><PropertyFieldsDemo /></StoryPage>,
}
+3
View File
@@ -48,3 +48,6 @@
.sw-story-detail-comparison { overflow-x: auto; padding: 16px; background: var(--hm-sw-header-layer-menu-background); }
.sw-story-detail-row { display: grid; grid-template-columns: 140px repeat(4, 180px); align-items: center; gap: 16px; min-width: 924px; padding: 12px 0; }
.sw-story-detail-row > div { display: flex; align-items: center; gap: 8px; }
/* Property fields (2026-10-19): Figma node table under the specimens. */
.sw-story-node-table { margin-top: 16px; border-collapse: collapse; font-size: 12px; color: #3d4a45; }
.sw-story-node-table :is(th, td) { padding: 4px 10px; border-bottom: 1px solid #d5ddd9; text-align: left; }