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>
No files matched your search
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -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": []
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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; }
|
||||