feat(sw): integrate responsive home menu into header
- Merge SW / Home Menu Stories into SW / Header (sidebar: Home Menu; others verification-tagged) - Home On: logo hover ring and header top/bottom #5F4B0F lines while the menu is open - Measured More in SWHeader; home menu narrow widths use a More (...) section with a grouped text menu instead of horizontal scroll, placed right after the last visible section - Action tiles no longer stay on after a click; 최근파일 stays Active only while its list is open - More dropdown and recent files list never open together; list top joins the accent line - Recent files preview falls back left / over the list at narrow widths (390px) - Update Storybook, browser tests, docs, AI guides and QA evidence
No files matched your search
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,219 @@
|
|||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "1920 tiles: 14 action tiles — no Active / aria-pressed / ring after the click",
|
||||||
|
"ok": true,
|
||||||
|
"data": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hover kept: Variant2 (ring + #FF5C00) while the pointer is on the tile",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": null,
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": "1",
|
||||||
|
"color": "rgb(255, 92, 0)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pressed: Variant3 only while held (Space down), Default after release",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"held": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": null,
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": "1",
|
||||||
|
"color": "rgb(255, 92, 0)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
"released": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": null,
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": "0",
|
||||||
|
"color": "rgb(246, 244, 242)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "최근파일 tile: Variant3 (Active, aria-expanded) while the list is open; re-click closes list and Active",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"rOpen": {
|
||||||
|
"tile": {
|
||||||
|
"active": "true",
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": "true",
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": "1",
|
||||||
|
"color": "rgb(255, 92, 0)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
"list": true
|
||||||
|
},
|
||||||
|
"rClosed": {
|
||||||
|
"tile": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": "false",
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": "0",
|
||||||
|
"color": "rgb(246, 244, 242)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
"list": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "최근파일: Escape closes the list → Active off",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"tile": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": "false",
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": "0",
|
||||||
|
"color": "rgb(246, 244, 242)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
"list": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "최근파일: opening a file closes the menu; reopening starts with 최근파일 off",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"afterFile": false,
|
||||||
|
"reopened": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": "false",
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": "0",
|
||||||
|
"color": "rgb(246, 244, 242)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "home button keeps Home On while the menu is open",
|
||||||
|
"ok": true,
|
||||||
|
"data": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "More tile keeps its Variant3 while its dropdown is open",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"active": "true",
|
||||||
|
"ring": "1",
|
||||||
|
"expanded": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "1080 More text menu: 10 action rows — no Active, no ✓, no brown row after the click (dropdown stays open)",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"rowLeft": [],
|
||||||
|
"stillOpen": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "text row Hover kept (#62533D + #FF5C00)",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": null,
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": null,
|
||||||
|
"color": "rgb(255, 92, 0)",
|
||||||
|
"bg": "rgb(98, 83, 61)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "text row Pressed only while held, nothing after release",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"rowHeld": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": null,
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": null,
|
||||||
|
"color": "rgb(255, 92, 0)",
|
||||||
|
"bg": "rgb(98, 83, 61)"
|
||||||
|
},
|
||||||
|
"rowReleased": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": null,
|
||||||
|
"mark": "\"\"",
|
||||||
|
"ring": null,
|
||||||
|
"color": "rgb(246, 244, 242)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "최근파일 row: list opens; More stays Variant3; reopened dropdown shows 최근파일 Active with › (no ✓)",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"listOpen": true,
|
||||||
|
"moreWhileList": "true",
|
||||||
|
"rRow": {
|
||||||
|
"active": "true",
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": "true",
|
||||||
|
"mark": "\"›\"",
|
||||||
|
"ring": null,
|
||||||
|
"color": "rgb(255, 92, 0)",
|
||||||
|
"bg": "rgb(98, 83, 61)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Escape (dropdown, then list): list closed → 최근파일 row and More off",
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"rAfter": {
|
||||||
|
"list": false,
|
||||||
|
"more": null
|
||||||
|
},
|
||||||
|
"rRow2": {
|
||||||
|
"active": null,
|
||||||
|
"pressed": null,
|
||||||
|
"expanded": "false",
|
||||||
|
"mark": "\"›\"",
|
||||||
|
"ring": null,
|
||||||
|
"color": "rgb(246, 244, 242)",
|
||||||
|
"bg": "rgba(0, 0, 0, 0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "More tile off once its dropdown and the list are closed",
|
||||||
|
"ok": true,
|
||||||
|
"data": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "callbacks: action tile / text row → onItemSelect(active=false) without onActiveChange; 최근파일 → onActiveChange([recent]) + onItemSelect(active=true)",
|
||||||
|
"ok": true,
|
||||||
|
"data": "onItemSelect(open / recent, active=true)\nonActiveChange([recent])\nonItemSelect(setting / display, active=false)\nonItemSelect(working / save, active=false)\nonHomeMenuOpenChange(true, home)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no page errors",
|
||||||
|
"ok": true,
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,271 @@
|
|||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"vw": 1920,
|
||||||
|
"menu": {
|
||||||
|
"x": 375,
|
||||||
|
"y": 0,
|
||||||
|
"width": 1305,
|
||||||
|
"height": 77,
|
||||||
|
"top": 0,
|
||||||
|
"right": 1680,
|
||||||
|
"bottom": 77,
|
||||||
|
"left": 375
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"x": 808,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 1162,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 808
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"x": 808,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 1162,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 808
|
||||||
|
},
|
||||||
|
"accent": "outline",
|
||||||
|
"listBorder": [
|
||||||
|
"0px",
|
||||||
|
"1px",
|
||||||
|
"rgb(218, 104, 19)"
|
||||||
|
],
|
||||||
|
"accentBox": {
|
||||||
|
"top": "47px",
|
||||||
|
"height": "30px",
|
||||||
|
"padding": "2px",
|
||||||
|
"radius": "3px"
|
||||||
|
},
|
||||||
|
"joint": [],
|
||||||
|
"pageScroll": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vw": 1440,
|
||||||
|
"menu": {
|
||||||
|
"x": 375,
|
||||||
|
"y": 0,
|
||||||
|
"width": 825,
|
||||||
|
"height": 77,
|
||||||
|
"top": 0,
|
||||||
|
"right": 1200,
|
||||||
|
"bottom": 77,
|
||||||
|
"left": 375
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"x": 808,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 1162,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 808
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"x": 808,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 1162,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 808
|
||||||
|
},
|
||||||
|
"accent": "outline",
|
||||||
|
"listBorder": [
|
||||||
|
"0px",
|
||||||
|
"1px",
|
||||||
|
"rgb(218, 104, 19)"
|
||||||
|
],
|
||||||
|
"accentBox": {
|
||||||
|
"top": "47px",
|
||||||
|
"height": "30px",
|
||||||
|
"padding": "2px",
|
||||||
|
"radius": "3px"
|
||||||
|
},
|
||||||
|
"joint": [],
|
||||||
|
"pageScroll": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vw": 1024,
|
||||||
|
"menu": {
|
||||||
|
"x": 364,
|
||||||
|
"y": 0,
|
||||||
|
"width": 484,
|
||||||
|
"height": 77,
|
||||||
|
"top": 0,
|
||||||
|
"right": 848,
|
||||||
|
"bottom": 77,
|
||||||
|
"left": 364
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"x": 670,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 1024,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 670
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"x": 670,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 1024,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 670
|
||||||
|
},
|
||||||
|
"accent": "outline",
|
||||||
|
"listBorder": [
|
||||||
|
"0px",
|
||||||
|
"1px",
|
||||||
|
"rgb(218, 104, 19)"
|
||||||
|
],
|
||||||
|
"accentBox": {
|
||||||
|
"top": "47px",
|
||||||
|
"height": "30px",
|
||||||
|
"padding": "2px",
|
||||||
|
"radius": "3px"
|
||||||
|
},
|
||||||
|
"joint": [
|
||||||
|
[
|
||||||
|
845,
|
||||||
|
1024,
|
||||||
|
75,
|
||||||
|
77,
|
||||||
|
"linear-gradient(0deg, rgb(255, 81, 0) 0%"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"pageScroll": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vw": 768,
|
||||||
|
"menu": {
|
||||||
|
"x": 304,
|
||||||
|
"y": 0,
|
||||||
|
"width": 288,
|
||||||
|
"height": 77,
|
||||||
|
"top": 0,
|
||||||
|
"right": 592,
|
||||||
|
"bottom": 77,
|
||||||
|
"left": 304
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"x": 414,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 768,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 414
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"x": 414,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 768,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 414
|
||||||
|
},
|
||||||
|
"accent": "outline",
|
||||||
|
"listBorder": [
|
||||||
|
"0px",
|
||||||
|
"1px",
|
||||||
|
"rgb(218, 104, 19)"
|
||||||
|
],
|
||||||
|
"accentBox": {
|
||||||
|
"top": "47px",
|
||||||
|
"height": "30px",
|
||||||
|
"padding": "2px",
|
||||||
|
"radius": "3px"
|
||||||
|
},
|
||||||
|
"joint": [
|
||||||
|
[
|
||||||
|
589,
|
||||||
|
768,
|
||||||
|
75,
|
||||||
|
77,
|
||||||
|
"linear-gradient(0deg, rgb(255, 81, 0) 0%"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"pageScroll": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vw": 390,
|
||||||
|
"menu": {
|
||||||
|
"x": 161,
|
||||||
|
"y": 0,
|
||||||
|
"width": 165,
|
||||||
|
"height": 77,
|
||||||
|
"top": 0,
|
||||||
|
"right": 326,
|
||||||
|
"bottom": 77,
|
||||||
|
"left": 161
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"x": 36,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 390,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 36
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"x": 36,
|
||||||
|
"y": 77,
|
||||||
|
"width": 354,
|
||||||
|
"height": 752,
|
||||||
|
"top": 77,
|
||||||
|
"right": 390,
|
||||||
|
"bottom": 829,
|
||||||
|
"left": 36
|
||||||
|
},
|
||||||
|
"accent": "outline",
|
||||||
|
"listBorder": [
|
||||||
|
"0px",
|
||||||
|
"1px",
|
||||||
|
"rgb(218, 104, 19)"
|
||||||
|
],
|
||||||
|
"accentBox": {
|
||||||
|
"top": "47px",
|
||||||
|
"height": "30px",
|
||||||
|
"padding": "2px",
|
||||||
|
"radius": "3px"
|
||||||
|
},
|
||||||
|
"joint": [
|
||||||
|
[
|
||||||
|
36,
|
||||||
|
164,
|
||||||
|
75,
|
||||||
|
77,
|
||||||
|
"linear-gradient(0deg, rgb(255, 81, 0) 0%"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
323,
|
||||||
|
390,
|
||||||
|
75,
|
||||||
|
77,
|
||||||
|
"linear-gradient(0deg, rgb(255, 81, 0) 0%"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"pageScroll": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -517,6 +517,18 @@ Composed Controls의 기존 레이어·색상·상세보기 예제는 보존하
|
|||||||
|
|
||||||
검증(2026-10-16, Storybook `SW / Header / Responsive`, 18개 폭): 1920 / 1800 / 1799 / 1770 / 1600 / 1440 / 1280 / 1180 / 1179 / 1080 / 1079 / 900 / 899 / 768 / 640 / 639 / 560 / 390에서 헤더 가로 스크롤 0, 요소 겹침 0, 객체 수정 면 밖으로 넘침 0. 필드 값·라벨 잘림은 390px의 레이어명 1건뿐입니다. 각 폭에서 더보기 항목 선택 시 Callback이 호출되고, 선택 상태가 반영되며, 메뉴가 닫히는 것을 확인했습니다(속성Bar·속성창 체크 전환, 인쇄·팝업 가이드 Callback 포함). 실제 Viewport 1799 / 1079 / 390에서도 같은 결과입니다.
|
검증(2026-10-16, Storybook `SW / Header / Responsive`, 18개 폭): 1920 / 1800 / 1799 / 1770 / 1600 / 1440 / 1280 / 1180 / 1179 / 1080 / 1079 / 900 / 899 / 768 / 640 / 639 / 560 / 390에서 헤더 가로 스크롤 0, 요소 겹침 0, 객체 수정 면 밖으로 넘침 0. 필드 값·라벨 잘림은 390px의 레이어명 1건뿐입니다. 각 폭에서 더보기 항목 선택 시 Callback이 호출되고, 선택 상태가 반영되며, 메뉴가 닫히는 것을 확인했습니다(속성Bar·속성창 체크 전환, 인쇄·팝업 가이드 Callback 포함). 실제 Viewport 1799 / 1079 / 390에서도 같은 결과입니다.
|
||||||
|
|
||||||
|
### 12.1 실측 More — 실제 사용 가능한 공간 기준 (2026-10-19)
|
||||||
|
|
||||||
|
2026-10-19 사용자 요청("고정된 화면 너비만 기준으로 처리하지 말고 실제 사용 가능한 공간을 기준으로")에 따라 위 Breakpoint 위에 실측 단계를 더했습니다. Breakpoint 표는 그대로이며, 기본 구성(추가 요소 없음)의 배치는 바뀌지 않습니다.
|
||||||
|
|
||||||
|
- 측정 대상: 객체 수정 영역의 in-flow 내용. 측정하는 동안(`data-measuring`, 같은 Layout Effect 안에서 설정·해제, 그려지지 않음) 영역 안 요소는 줄거나 늘지 않고 auto margin도 0이므로 실제로 필요한 폭이 드러납니다. 구성형(`controls`) 파일명은 기본 디자인의 최소 파일명 폭(96px)으로 계산합니다.
|
||||||
|
- 이동 순서: Breakpoint 단계 순서 그대로 선·문자·치수스타일 → 그리기모드(라벨만 숨김) → 속성 블록(상세보기·속성Bar·속성창, 기본 구성에서는 한 덩어리) → 색상 → 인쇄·K(구성형만, 기본 구성에서는 창 영역이라 Breakpoint 유지). 같은 단계는 오른쪽부터. 대표 칸(레이어 또는 More에서 고른 필드)은 이동하지 않습니다. 로고 오른쪽 Open(`documentOpenPlacement="header"`)은 가장 먼저 옮겨지고 가장 늦게 돌아옵니다.
|
||||||
|
- 복귀: 옮긴 직후 그 이동으로 생긴 공간(More 버튼 등장·여백 변화 포함)을 한 번 측정해 두고, 남은 공간이 그만큼 생기면 역순으로 원래 자리에 돌아옵니다(경계에서 깜박임 없음).
|
||||||
|
- 메뉴를 열고 닫아도 위치·폭은 바뀌지 않습니다. Dropdown·로고 메뉴·More 목록·Tooltip은 positioned 층이라 측정에서 제외됩니다.
|
||||||
|
- 한계: `leading`·`tools`처럼 호스트가 넣은 요소는 More로 옮기지 않습니다(More 항목이 없음). 그 요소만으로도 공간이 부족하면 기존처럼 대표 필드가 줄어듭니다.
|
||||||
|
|
||||||
|
상세 검증: [홈 메뉴 통합·실측 More 보고](./sw-header-home-menu-responsive-2026-10-19.md).
|
||||||
|
|
||||||
## 13. 컴포넌트 분리 기준
|
## 13. 컴포넌트 분리 기준
|
||||||
|
|
||||||
- 공개: `SWHeader`(전체), `SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerControl`, `SWHeaderLayerMenu`(레이어 필드 + Dropdown, §5.2), `SWHeaderColorMenu` · `SWHeaderLineStyleMenu` · `SWHeaderTextStyleMenu` · `SWHeaderDimensionStyleMenu`(§5.3), `SWHeaderLinePreview` · `swHeaderLinePatterns`, `SWHeaderInput` · `parseSWHeaderColor` · `parseSWHeaderNumber`(§5.5), `SWHeaderColorControl`, `SWHeaderLineStyle`, `SWHeaderTextStyle`, `SWHeaderDimensionStyle`, `SWHeaderProperties`, `SWHeaderActions`, `SWHeaderWindowControls`, `SWTheme`(단독 사용 시 토큰 범위).
|
- 공개: `SWHeader`(전체), `SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerControl`, `SWHeaderLayerMenu`(레이어 필드 + Dropdown, §5.2), `SWHeaderColorMenu` · `SWHeaderLineStyleMenu` · `SWHeaderTextStyleMenu` · `SWHeaderDimensionStyleMenu`(§5.3), `SWHeaderLinePreview` · `swHeaderLinePatterns`, `SWHeaderInput` · `parseSWHeaderColor` · `parseSWHeaderNumber`(§5.5), `SWHeaderColorControl`, `SWHeaderLineStyle`, `SWHeaderTextStyle`, `SWHeaderDimensionStyle`, `SWHeaderProperties`, `SWHeaderActions`, `SWHeaderWindowControls`, `SWTheme`(단독 사용 시 토큰 범위).
|
||||||
@@ -540,6 +552,7 @@ Composed Controls의 기존 레이어·색상·상세보기 예제는 보존하
|
|||||||
| `--hm-sw-header-arrow-color` | `#47AF91` | 파일명 화살표 |
|
| `--hm-sw-header-arrow-color` | `#47AF91` | 파일명 화살표 |
|
||||||
| `--hm-sw-header-hover` | `rgba(255,255,255,.08)` | 더보기 메뉴 항목 Hover 배경 |
|
| `--hm-sw-header-hover` | `rgba(255,255,255,.08)` | 더보기 메뉴 항목 Hover 배경 |
|
||||||
| `--hm-sw-header-hover-border-gradient` / `-hover-border-width` | Figma 홈 hover gradient / 1px | Hover 테두리(§11.1) |
|
| `--hm-sw-header-hover-border-gradient` / `-hover-border-width` | Figma 홈 hover gradient / 1px | Hover 테두리(§11.1) |
|
||||||
|
| `--hm-sw-header-home-on-line-color` / `-home-on-line-width` | `#5F4B0F` / 2px | Home On(로고 메뉴 열림, Figma 662:67267): Header 전폭 상·하단 선. 로고는 Hover 테두리 유지 |
|
||||||
| `--hm-sw-header-document-menu-width` / `-document-menu-background` / `-document-menu-border-color` / `-document-menu-border-width` | 310px / `#103127` / `#041611` / 1px | 문서 Dropdown(§5.1) |
|
| `--hm-sw-header-document-menu-width` / `-document-menu-background` / `-document-menu-border-color` / `-document-menu-border-width` | 310px / `#103127` / `#041611` / 1px | 문서 Dropdown(§5.1) |
|
||||||
| `--hm-sw-header-document-menu-shadow` / `-document-menu-inset-shadow` / `-document-menu-divider-color` | `0 2px 4px rgba(0,0,0,.25)` / `inset 0 4px 8px rgba(0,0,0,.4)` / `rgba(194,217,210,.2)` | 문서 Dropdown(§5.1) |
|
| `--hm-sw-header-document-menu-shadow` / `-document-menu-inset-shadow` / `-document-menu-divider-color` | `0 2px 4px rgba(0,0,0,.25)` / `inset 0 4px 8px rgba(0,0,0,.4)` / `rgba(194,217,210,.2)` | 문서 Dropdown(§5.1) |
|
||||||
| `--hm-sw-header-document-list-max-height` | 320px | 목록 내부 스크롤 시작 높이(구현값) |
|
| `--hm-sw-header-document-list-max-height` | 320px | 목록 내부 스크롤 시작 높이(구현값) |
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# SW Header 홈 메뉴 통합 · 실측 More (2026-10-19)
|
||||||
|
|
||||||
|
> 브랜치 `feature/sw-home-menu`(기준 `c542bdc`, 작업 전 미커밋 `packages/sw/src/theme/sw-viewer.css` 3줄 보존). **Commit·Push 하지 않았습니다(승인 대기).**
|
||||||
|
> 검증: Storybook 개발 서버(포트 6025, 6024는 다른 세션이 사용 중)와 Claude 내장 브라우저(Chromium). 이 PC에는 기존 `*.browser.mjs`가 쓰는 Playwright 런타임이 없어 그 스크립트는 실행하지 않았습니다.
|
||||||
|
|
||||||
|
## 1. 요청과 결과
|
||||||
|
|
||||||
|
| 요청 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `SW / Home Menu` 기능·디자인을 `SW / Header`로 통합 | `SW / Header` → `Home Menu`(`sw-header--home-menu`) 한 페이지에 로고 클릭 열기/닫기·이벤트 기록, 헤더 폭 조절, Figma 26:10591 비교, Default/Hover/Active, 최근파일 하위 패널, 속성창·SWPopup·Escape 통합. `Overview` 로고도 같은 홈 메뉴를 엶 |
|
||||||
|
| 홈 버튼 클릭 → 열림, 다시 클릭 → 닫힘 | 기존 `SWHeader homeMenu` 동작 그대로(코드 변경 없음). 5개 섹션·15개 타일·Hover·하위 패널 유지 |
|
||||||
|
| SW 일반 모드 전용 | `SWViewer*` 코드·Story 변경 없음. 뷰어 Story에 홈 메뉴 0개 |
|
||||||
|
| 반응형 더보기: 실제 사용 가능한 공간 기준, 넓어지면 원위치, 순서·기능 유지 | `SWHeader` 실측 More 추가(아래 §2). Breakpoint는 그대로 |
|
||||||
|
| 메뉴 열고 닫을 때 헤더 요소 위치·폭 유지 | 측정에서 positioned 층(Dropdown·홈 메뉴·More 목록·Tooltip) 제외. 열기/닫기 전후 좌표 동일 확인 |
|
||||||
|
| `SW / Home Menu` 사이드바 제거, 컴포넌트·Export·스타일·AI 지침 유지 | Story 파일을 `sw-header-home-menu.stories.tsx`로 옮기고 title `SW/Header`. `SWHomeMenu`·`SWRecentFiles`·Export·`sw-home-menu.css`(패키지)·AI 지침 삭제 없음 |
|
||||||
|
|
||||||
|
## 2. 실측 More 동작
|
||||||
|
|
||||||
|
- 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)를 먼저 적용하고, 그 위에서 객체 수정 영역의 실제 남은 공간을 측정합니다.
|
||||||
|
- 측정: Layout Effect 안에서 `data-measuring`을 잠깐 붙여(그려지지 않음) 영역 안 요소가 줄거나 늘지 않고 auto margin이 0인 상태의 in-flow 폭을 읽습니다. 구성형 파일명은 최소 96px로 계산합니다.
|
||||||
|
- 이동 순서: Open(로고 오른쪽) → 선·문자·치수스타일 → 그리기모드 → 속성 블록 → 색상 → (구성형) 인쇄·K. 같은 단계는 오른쪽부터. 대표 칸은 이동하지 않습니다.
|
||||||
|
- 복귀: 옮긴 직후 생긴 공간을 한 번 측정해 두고 그만큼 공간이 생기면 역순으로 복귀. 기본 구성은 아무것도 옮기지 않으면 기존 CSS 전용 배치를 그대로 씁니다.
|
||||||
|
- 공개 API 변경 없음(추가 Prop 없음). 내부 상태·`data-squeezed` 속성·CSS만 추가.
|
||||||
|
|
||||||
|
## 3. 변경 파일
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `packages/sw/src/components/sw-header/SWHeader.tsx` | 실측 More(측정 함수·이동 순서·상태·Layout Effect), Open 이동 판정을 같은 측정으로 통일 |
|
||||||
|
| `packages/sw/src/theme/sw.css` | `data-measuring` 측정 규칙, 옮겨진 그리기모드·속성 블록 자리 보정 |
|
||||||
|
| `src/sw/sw-home-menu.stories.tsx` → `src/sw/sw-header-home-menu.stories.tsx` | title `SW/Header`, 대표 Story `Home Menu`, 나머지 `verification` 태그(`sw-header--home-menu-*`) |
|
||||||
|
| `src/sw/sw-header.stories.tsx` | `Overview`에 홈 메뉴 연결, `Responsive More`에 "실제 사용 가능한 공간 기준" 섹션 |
|
||||||
|
| `src/sw/sw-header-overview.tsx` | 선택 `homeMenuSections` Prop |
|
||||||
|
| `src/sw/sw-home-menu.css`, `src/sw/sw-stories.css` | Story 전용 폭 조절 컨트롤 배치 |
|
||||||
|
| `packages/sw/tests/home-menu-integration.browser.mjs`, `packages/sw/tests/storybook-inventory.mjs` | 새 Story ID·제목 기대값, 이동 ID 표 |
|
||||||
|
| `docs/sw/sw-header-design-spec.md` §12.1, `docs/sw/sw-header-usage.md`, `packages/sw/README.md`, `packages/sw/ai/DESIGN_RULES.md` §7, `packages/sw/ai/COMPONENT_CATALOG.md` | 실측 More·Storybook 위치 |
|
||||||
|
|
||||||
|
보존: `packages/sw/src/theme/sw-viewer.css`의 작업 전 미커밋 변경(`.hm-sw-viewer-menu-root { display: contents; }`)은 손대지 않았습니다. 과거 검증 보고서는 수정하지 않았습니다.
|
||||||
|
|
||||||
|
## 4. 검증
|
||||||
|
|
||||||
|
| 항목 | 결과 | 방법 |
|
||||||
|
|---|---|---|
|
||||||
|
| SW typecheck / 앱 `tsc -b` / SW build | PASS | `npm run typecheck -w @hm-design/sw`, `npx tsc -b`, `npm run build -w @hm-design/sw` |
|
||||||
|
| AI 문서 | PASS / 32/32 | `npm run check:ai-docs`, `node packages/sw/tests/ai-docs.check.mjs` |
|
||||||
|
| Storybook 메뉴 | PASS | `index.json`: SW title = Header·Popup·Viewer(`SW/Home Menu` 없음), `sw-header--home-menu` 표시, 홈 메뉴 하위 14개 `verification` |
|
||||||
|
| 기존 Header Story 배치 회귀 | PASS(기본 구성 변화 없음) | 작업 전 dist로 기록한 요소 좌표·폭과 비교: Overview·Interactive States·Logo Slot·Document Dropdown·Responsive More(19폭)·K Guide·Hidden Sub Controls 동일. 차이는 `Document Open`(§5)뿐 |
|
||||||
|
| 이동한 홈 메뉴 Story 배치 | PASS 18/18 | 옛 `sw-home-menu--*`와 새 `sw-header--home-menu-*` 6개 × 1920/1280/768 좌표 동일(메뉴 x·폭 포함) |
|
||||||
|
| 홈 메뉴 열기/닫기 | PASS | 로고 클릭 → 메뉴 1·섹션 5·타일 15·`aria-expanded=true`·Focus 메뉴 안, 재클릭 → 닫힘, `onHomeClick` 미호출, 열기/닫기 전후 Header 좌표 동일. `Overview`에서도 열기/닫기 확인 |
|
||||||
|
| Hover·하위 패널·Escape | PASS | 실제 포인터 Hover 시 라벨 `#FF5C00`, 최근파일 → 하위 패널·outline 강조, Escape 1회 패널만 닫힘, 2회 메뉴 닫힘·로고로 Focus |
|
||||||
|
| 좁은 화면 더보기 · 폭 변경 재배치 | PASS | `Responsive More` 실측 섹션(로고 오른쪽 버튼 2개 + 도구 1개): 1920→390 축소 시 치수 → 문자 → 그리기모드 → 속성 블록 → 색상 순 이동, 겹침·가로 스크롤 0(520까지), 390→1920 확대 시 역순 복귀, 추가 요소 끄면 기본 CSS 배치로 복귀 |
|
||||||
|
| 더보기 안 메뉴 선택 · 팝업 | PASS | 1180: More 목록 = 선/문자/치수스타일·속성Bar·속성창(순서 유지). 속성창 → 팝업(`role=dialog`) 열림·Focus 안, Escape 닫힘·More로 Focus 복귀. 선스타일 → 대표 칸·Dropdown 열림·Focus, 레이어는 More로 |
|
||||||
|
| 헤더 요소 위치·폭 유지 | PASS | More 열기, 속성창 열기/닫기, 홈 메뉴, 레이어 Dropdown, 문서 Dropdown 각각 전후 좌표 동일 |
|
||||||
|
| SW 뷰어 모드 영향 | PASS | 뷰어 코드·Story diff 없음, `SW/Viewer` 3개 Story 렌더 오류 0·홈 메뉴 0 |
|
||||||
|
| Storybook production build | PASS | `npm run build-storybook`(인덱스 358개, SW title = Header·Popup·Viewer, `SW/Header` 27개) |
|
||||||
|
|
||||||
|
참고: 내장 브라우저 창이 숨겨진 상태라 ResizeObserver가 프레임이 그려질 때만 실행됩니다. 폭 변경 검증은 각 단계마다 화면 프레임을 갱신한 뒤 측정했습니다.
|
||||||
|
|
||||||
|
## 5. 의도된 배치 변화 (`Document Open` Story)
|
||||||
|
|
||||||
|
로고 오른쪽 Open을 쓰는 기본 구성은 작업 전 아래 폭에서 실제로 겹쳤습니다(기존 판정이 `scrollWidth`만 봐서 감지 못함). 이제 Open(필요하면 메뉴 1개)이 More로 이동합니다.
|
||||||
|
|
||||||
|
| 프레임 | 작업 전 | 작업 후 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1800 | 치수스타일이 속성 블록과 겹침 | Open·치수스타일 → More |
|
||||||
|
| 1180 / 1080 | 색상이 More와 겹침 | Open → More |
|
||||||
|
| 900 | 색상이 More와 3px 겹침 | Open → More |
|
||||||
|
| 640 | 레이어가 More와 겹침 | Open → More |
|
||||||
|
| 구성형 768 | Open이 More에 있음 | Open 표시(파일명 200px ≥ 최소 96px) |
|
||||||
|
|
||||||
|
## 6. NOT VERIFIED · 한계
|
||||||
|
|
||||||
|
- 기존 Playwright 스크립트(`home-menu-integration.browser.mjs` 등): 이 PC에 런타임 없음. ID만 갱신했고 실행하지 않았습니다.
|
||||||
|
- Firefox·Safari, 실제 소비 앱(`HMDesignSwTest`) 재검증, 픽셀 단위 스크린샷 비교(좌표·폭 비교로 대신함).
|
||||||
|
- 호스트 요소(`leading`·`tools`)는 More로 옮기지 않습니다. 390px에 로고 오른쪽 버튼 2개 + 도구를 함께 넣으면 대표 필드가 0까지 줄고 도구와 More가 겹칩니다(옮길 메뉴가 더 없음).
|
||||||
|
- 기존 동작 관찰(이번 변경과 무관, 수정하지 않음): 홈 메뉴에서 Escape로 닫을 때 Story 기록에 `onHomeMenuOpenChange(false, escape)`·`onActiveChange([])`가 2회씩 남습니다. 이전 Story(`sw-header--home-menu-header-composition`, 구 `sw-home-menu--header-composition`)에서도 같습니다.
|
||||||
@@ -371,6 +371,7 @@ import { SWHeaderInput, parseSWHeaderNumber, SWTheme } from "@hm-design/sw"
|
|||||||
- 레이어는 그동안 더보기로 이동하며, 더보기 → 레이어로 원래 배치로 돌아갑니다. 이미 화면에 있는 필드는 더보기에 나오지 않습니다.
|
- 레이어는 그동안 더보기로 이동하며, 더보기 → 레이어로 원래 배치로 돌아갑니다. 이미 화면에 있는 필드는 더보기에 나오지 않습니다.
|
||||||
- 전환은 UI 상태입니다. 데이터 Callback은 호출되지 않고, 필드 클릭과 같은 `on*Open` 1회와 `on*MenuOpenChange(true)`만 호출됩니다. 값은 그대로 유지됩니다.
|
- 전환은 UI 상태입니다. 데이터 Callback은 호출되지 않고, 필드 클릭과 같은 `on*Open` 1회와 `on*MenuOpenChange(true)`만 호출됩니다. 값은 그대로 유지됩니다.
|
||||||
- 폭이 넓어져 그 필드가 다시 보이는 단계에서는 1920 기준 배치로 돌아갑니다(Breakpoint 1800 / 1180 / 1080 / 900 / 640 / 520은 그대로).
|
- 폭이 넓어져 그 필드가 다시 보이는 단계에서는 1920 기준 배치로 돌아갑니다(Breakpoint 1800 / 1180 / 1080 / 900 / 640 / 520은 그대로).
|
||||||
|
- 실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간도 측정합니다. `leading`·`tools`·긴 라벨·사이드 패널 등으로 공간이 모자라면 표시할 수 없는 메뉴만 더보기로 옮기고, 공간이 다시 생기면 원래 자리·순서로 돌려놓습니다. 메뉴를 열고 닫는 것만으로는 Header 요소의 위치·폭이 바뀌지 않습니다([명세 §12.1](./sw-header-design-spec.md#121-실측-more--실제-사용-가능한-공간-기준-2026-10-19)).
|
||||||
- Dropdown 데이터(`colors` 등)가 없는 필드도 같은 방식으로 대표 칸에 표시됩니다(목록 없이 `on*Open`만).
|
- Dropdown 데이터(`colors` 등)가 없는 필드도 같은 방식으로 대표 칸에 표시됩니다(목록 없이 `on*Open`만).
|
||||||
|
|
||||||
## 8. 하위 컴포넌트 재사용
|
## 8. 하위 컴포넌트 재사용
|
||||||
@@ -406,7 +407,7 @@ import type { CSSProperties } from "react"
|
|||||||
- 기존 뷰어 `Toolbar.tsx`의 파일명(`fileName`)은 `documentTitle`로, 패널 토글(`propsOpen` 등)은 `propertyPanelOpen` + `onPropertyPanelToggle`로 연결할 수 있습니다.
|
- 기존 뷰어 `Toolbar.tsx`의 파일명(`fileName`)은 `documentTitle`로, 패널 토글(`propsOpen` 등)은 `propertyPanelOpen` + `onPropertyPanelToggle`로 연결할 수 있습니다.
|
||||||
- 뷰어의 파일 열기·샘플·Model/Layout 탭·2.5D·어두운 배경·로드 상태는 Figma SW Header에 없는 뷰어 전용 도구입니다. 필요하면 `tools` 슬롯에 호스트가 렌더링합니다(스타일은 호스트 책임).
|
- 뷰어의 파일 열기·샘플·Model/Layout 탭·2.5D·어두운 배경·로드 상태는 Figma SW Header에 없는 뷰어 전용 도구입니다. 필요하면 `tools` 슬롯에 호스트가 렌더링합니다(스타일은 호스트 책임).
|
||||||
- 헤더 높이는 49px입니다. 고정 배치(`position: fixed`)는 `className`·`style`로 호스트에서 지정합니다.
|
- 헤더 높이는 49px입니다. 고정 배치(`position: fixed`)는 `className`·`style`로 호스트에서 지정합니다.
|
||||||
- 반응형은 모두 헤더 자신의 폭(container query)을 기준으로 하므로 사이드 패널 때문에 헤더가 좁아져도 동작합니다. 단계(1800 / 1180 / 1080 / 900 / 640 / 520)는 [디자인 명세 §12](./sw-header-design-spec.md#12-반응형)를 따릅니다.
|
- 반응형은 모두 헤더 자신의 폭(container query)을 기준으로 하므로 사이드 패널 때문에 헤더가 좁아져도 동작합니다. 단계(1800 / 1180 / 1080 / 900 / 640 / 520)는 [디자인 명세 §12](./sw-header-design-spec.md#12-반응형)를 따르고, 그 위에서 실제 남은 공간을 측정해 부족한 메뉴만 더보기로 옮깁니다([§12.1](./sw-header-design-spec.md#121-실측-more--실제-사용-가능한-공간-기준-2026-10-19)).
|
||||||
- 뷰어는 현재 `@hm-design/ui`의 SWHeader 패턴을 사용합니다. 이 패키지로 바꾸는 작업은 이번 범위가 아닙니다.
|
- 뷰어는 현재 `@hm-design/ui`의 SWHeader 패턴을 사용합니다. 이 패키지로 바꾸는 작업은 이번 범위가 아닙니다.
|
||||||
|
|
||||||
## 10.1 상호작용 상태
|
## 10.1 상호작용 상태
|
||||||
@@ -454,7 +455,9 @@ Tooltip은 45 × 17px, 검정 80% 배경과 흰색 12px/400 글자입니다. Too
|
|||||||
npm run storybook
|
npm run storybook
|
||||||
```
|
```
|
||||||
|
|
||||||
현재(2026-10-19) `SW / Header`에는 33개 Story가 있습니다. Story ID·분류·용도 전체 표는 [1차 정리 보고 §2](./sw-first-pass-cleanup-review-2026-10-19.md#2-storybook-전체-목록-및-분류)를 봅니다. 아래 문단은 Story를 추가한 시점별 설명입니다.
|
현재(2026-10-19, 홈 메뉴 통합 후) `SW / Header`에는 27개 Story가 있습니다(사이드바 기본 표시 12개 + `verification` 태그 15개). 이전 시점의 Story 표는 [1차 정리 보고 §2](./sw-first-pass-cleanup-review-2026-10-19.md#2-storybook-전체-목록-및-분류), 홈 메뉴 통합 후 목록은 [홈 메뉴 통합·실측 More 보고](./sw-header-home-menu-responsive-2026-10-19.md)를 봅니다. 아래 문단은 Story를 추가한 시점별 설명입니다.
|
||||||
|
|
||||||
|
2026-10-19 홈 메뉴 통합: 기존 `SW / Home Menu` 메뉴는 사이드바에서 제거하고 `SW / Header`로 옮겼습니다. 사이드바에는 `Home Menu`(`sw-header--home-menu`: 로고 클릭 열기/닫기·이벤트 기록, 헤더 폭 조절, Figma 26:10591 비교, Default/Hover/Active, 최근파일 하위 패널, 속성창·SWPopup·Escape 통합) 하나만 표시합니다. Figma 비교 캔버스·Variant·데이터 변경·단독 사용·속성Bar 비교 등은 `sw-header--home-menu-*` ID의 `verification` 태그 Story로 남아 직접 URL과 사이드바 태그 필터로 엽니다. `Overview`의 로고도 같은 홈 메뉴를 엽니다. `Responsive More` 첫 섹션 "실제 사용 가능한 공간 기준"은 헤더 폭 Range와 로고 오른쪽 버튼·추가 도구·홈 메뉴 On/Off로 실측 More의 이동·복귀를 확인합니다. 홈 메뉴는 SW 일반 모드 전용이며 `SW / Viewer`에는 없습니다.
|
||||||
|
|
||||||
`SW / Header` 아래 Story: Overview, Default, Controls, Interactive States(인쇄·팝업 가이드·최소화·종료 Default/Hover 비교와 Callback 기록 포함), Responsive, Composed Controls, Logo Slot(기본 임시 로고 / 사용자 지정 로고 / 로고 미전달), Document Dropdown(Default / Hover / Open / 선택 / 여러 문서 / 문서 없음 / 긴 파일명 / 좁은 화면), Layer Dropdown(Layer Default / Hover / Open(Figma 8행) / Row Hover / 전구·눈꽃·자물쇠 4상태 3배 확대 / 선택·Toggle Callback 기록과 상태표 / 여러 상태·현재 레이어 / 긴 레이어명 / 20개 스크롤 / 레이어 없음 / 레이어 상세 / 상태 표시 전용 / 좁은 화면 1440–390). Responsive Story는 헤더 폭 18개(1920–390, 각 단계 경계 전후 포함) 프레임을 나열합니다. 모든 단계가 헤더 폭 기준이므로 Viewport를 바꾸지 않아도 확인할 수 있습니다.
|
`SW / Header` 아래 Story: Overview, Default, Controls, Interactive States(인쇄·팝업 가이드·최소화·종료 Default/Hover 비교와 Callback 기록 포함), Responsive, Composed Controls, Logo Slot(기본 임시 로고 / 사용자 지정 로고 / 로고 미전달), Document Dropdown(Default / Hover / Open / 선택 / 여러 문서 / 문서 없음 / 긴 파일명 / 좁은 화면), Layer Dropdown(Layer Default / Hover / Open(Figma 8행) / Row Hover / 전구·눈꽃·자물쇠 4상태 3배 확대 / 선택·Toggle Callback 기록과 상태표 / 여러 상태·현재 레이어 / 긴 레이어명 / 20개 스크롤 / 레이어 없음 / 레이어 상세 / 상태 표시 전용 / 좁은 화면 1440–390). Responsive Story는 헤더 폭 18개(1920–390, 각 단계 경계 전후 포함) 프레임을 나열합니다. 모든 단계가 헤더 폭 기준이므로 Viewport를 바꾸지 않아도 확인할 수 있습니다.
|
||||||
|
|
||||||
@@ -644,9 +647,9 @@ Figma 26:10591의 2단 Header 메뉴입니다. 기본 숨김, 로고 클릭으
|
|||||||
onHomeMenuOpenChange={(open, reason) => {}} /> // reason: home | escape | background | outside | select | file | document
|
onHomeMenuOpenChange={(open, reason) => {}} /> // reason: home | escape | background | outside | select | file | document
|
||||||
```
|
```
|
||||||
|
|
||||||
- `sections`: `{ id, title, items: [{ id, label, icon, hoverIcon?, toggle?, panel?, disabled?, hidden?, onSelect? }] }[]`. 최근파일은 타일의 `panel: <SWRecentFiles files={…} onFileOpen={…} />`(선택).
|
- `sections`: `{ id, title, items: [{ id, label, icon, hoverIcon?, toggle?, panel?, disabled?, hidden?, onSelect? }] }[]`. 최근파일은 타일의 `panel: <SWRecentFiles files={…} onFileOpen={…} />`(선택). `panel`이 없는 항목은 실행 버튼입니다(클릭 시 `onSelect`/`onItemSelect`, 클릭 후 ON 유지 없음). 최근파일만 목록이 열린 동안 Active이며, 유지형이 필요하면 `toggle: true`.
|
||||||
- 이벤트 우선순위: `homeMenu`가 있으면 로고 클릭은 메뉴 토글만 하고 `onHomeClick`은 호출되지 않습니다. `homeMenu`가 없으면 기존과 같이 `onHomeClick`만 호출됩니다.
|
- 이벤트 우선순위: `homeMenu`가 있으면 로고 클릭은 메뉴 토글만 하고 `onHomeClick`은 호출되지 않습니다. `homeMenu`가 없으면 기존과 같이 `onHomeClick`만 호출됩니다.
|
||||||
- New / Open은 문서 Dropdown 안 그대로입니다(`documentOpenPlacement` 기본 `"menu"`). 메뉴가 열리면 필드·문서 Dropdown이 닫히고, 문서 Dropdown을 열면 메뉴가 닫힙니다.
|
- New / Open은 문서 Dropdown 안 그대로입니다(`documentOpenPlacement` 기본 `"menu"`). 메뉴가 열리면 필드·문서 Dropdown이 닫히고, 문서 Dropdown을 열면 메뉴가 닫힙니다.
|
||||||
- 위치: 객체 수정 영역 −2px ~ 창 영역(DOM 측정, 1920에서 x 375 · 폭 1305). 좁은 폭에서는 메뉴 안 가로 스크롤.
|
- 위치: 객체 수정 영역 −2px ~ 창 영역(DOM 측정, 1920에서 x 375 · 폭 1305). 좁은 폭에서는 가로 스크롤 없이 들어가는 섹션만 표시하고 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다(Dropdown은 섹션 제목 + 기능 텍스트 목록, 타일과 같은 Hover·Active·클릭, 폭이 넓어지면 자동 복귀, 배지 문구 `homeMenu.moreLabel`).
|
||||||
- Escape: 하위 패널 → 메뉴 순서, 닫히면 로고로 Focus. 속성창·`SWPopup`과 같은 Escape 순서(Focus가 모두 밖이면 최근 사용 하나).
|
- Escape: 하위 패널 → 메뉴 순서, 닫히면 로고로 Focus. 속성창·`SWPopup`과 같은 Escape 순서(Focus가 모두 밖이면 최근 사용 하나).
|
||||||
- 상세: 패키지 [카탈로그 5절](../../packages/sw/ai/COMPONENT_CATALOG.md), [통합 검증 기록](./sw-home-menu-integration-2026-10-19.md).
|
- 상세: 패키지 [카탈로그 5절](../../packages/sw/ai/COMPONENT_CATALOG.md), [통합 검증 기록](./sw-home-menu-integration-2026-10-19.md).
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
## 남은 항목
|
## 남은 항목
|
||||||
|
|
||||||
- 미실행: 독립 React 19 Tarball 소비 설치, SW + HM UI CSS 동시 사용, Figma 스크린샷 픽셀 비교(배치·크기는 DOM 실측으로만 확인), Firefox·Safari.
|
- 미실행: 독립 React 19 Tarball 소비 설치, SW + HM UI CSS 동시 사용, Figma 스크린샷 픽셀 비교(배치·크기는 DOM 실측으로만 확인), Firefox·Safari.
|
||||||
- 좁은 폭: 메뉴는 접히지 않고 메뉴 안에서 가로 스크롤(1440 825px, 1080 639px, 768·520 288px, 390 165px). 좁은 폭 디자인은 없습니다.
|
- 좁은 폭: (2026-10-19 후속 수정) 가로 스크롤 대신 측정형 더보기(⋯). 들어가는 섹션만 앞에서부터 Figma 타일로 표시하고 나머지는 더보기 Dropdown의 텍스트 메뉴(섹션 제목 + 항목, 구분선)로 이동, 폭이 넓어지면 복귀. 실측(capture-responsive): 1920 5개, 1680 3+더보기, 1440·1280·1180 2+더보기, 1080·960 1+더보기, 768 이하 더보기만. 메뉴 행·페이지 가로 스크롤 0.
|
||||||
- 디자인 승인 대기(미적용): 속성Bar 차이 3건, 열림 상태 Header 상·하단 `#5F4B0F` 선, 폰트 차이.
|
- 디자인 승인 대기(미적용): 속성Bar 차이 3건, 폰트 차이. 열림 상태 Header 상·하단 `#5F4B0F` 선과 로고 Home On은 2026-10-19 사용자 요청으로 적용(Figma 662:67267).
|
||||||
- Task A의 기존 제한(넓은 `tools` + 390px에서 파일명·레이어 폭 0)은 이번 통합 범위 밖입니다.
|
- Task A의 기존 제한(넓은 `tools` + 390px에서 파일명·레이어 폭 0)은 이번 통합 범위 밖입니다.
|
||||||
@@ -44,6 +44,7 @@ import "@hm-design/sw/styles.css"
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
- 반응형 More: 좁은 헤더에서 더보기의 필드를 고르면 그 필드가 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 아래 열립니다. 레이어는 더보기에서 다시 고를 수 있으며, 전환은 UI 상태라 데이터 Callback을 호출하지 않습니다([명세 §5.4](../../docs/sw/sw-header-design-spec.md#54-반응형-more--대표-필드-전환-2026-10-16)).
|
- 반응형 More: 좁은 헤더에서 더보기의 필드를 고르면 그 필드가 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 아래 열립니다. 레이어는 더보기에서 다시 고를 수 있으며, 전환은 UI 상태라 데이터 Callback을 호출하지 않습니다([명세 §5.4](../../docs/sw/sw-header-design-spec.md#54-반응형-more--대표-필드-전환-2026-10-16)).
|
||||||
|
- 실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해 표시할 수 없는 메뉴만 More로 옮기고, 공간이 생기면 원래 자리·순서로 돌려놓습니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다([명세 §12.1](../../docs/sw/sw-header-design-spec.md#121-실측-more--실제-사용-가능한-공간-기준-2026-10-19)). Storybook의 홈 메뉴 예제는 `SW / Header` → `Home Menu`입니다.
|
||||||
- Text Stroke: Figma에 Stroke가 있는 텍스트만 검정 외곽선(14·16px 1.3px, 12px 1px)을 표시합니다. 자기 색 0.3px Stroke는 2026-10-16 추가 수정으로 제거했습니다. 더보기·Tooltip·`+ New`/`Open`은 제외([명세 §6.1](../../docs/sw/sw-header-design-spec.md#61-text-stroke-2026-10-16)).
|
- Text Stroke: Figma에 Stroke가 있는 텍스트만 검정 외곽선(14·16px 1.3px, 12px 1px)을 표시합니다. 자기 색 0.3px Stroke는 2026-10-16 추가 수정으로 제거했습니다. 더보기·Tooltip·`+ New`/`Open`은 제외([명세 §6.1](../../docs/sw/sw-header-design-spec.md#61-text-stroke-2026-10-16)).
|
||||||
- `SWHeaderInput`: 레이어 상세 등 상세 영역에서 쓰는 수정 가능 Input(Figma 26:11656, Default / Hover / 편집, caret `#E60403`). `value`(확정값) · `onValueChange`(임시값) · `onCommit`(Enter·Focus 이동, 유효 + 변경 시) · `onCancel`(Escape) · `valueType`(`text` / `number`) · `parse` · `readOnly` · `disabled`([명세 §5.5](../../docs/sw/sw-header-design-spec.md#55-레이어-상세-버튼--수정-가능한-input-2026-10-16)).
|
- `SWHeaderInput`: 레이어 상세 등 상세 영역에서 쓰는 수정 가능 Input(Figma 26:11656, Default / Hover / 편집, caret `#E60403`). `value`(확정값) · `onValueChange`(임시값) · `onCommit`(Enter·Focus 이동, 유효 + 변경 시) · `onCancel`(Escape) · `valueType`(`text` / `number`) · `parse` · `readOnly` · `disabled`([명세 §5.5](../../docs/sw/sw-header-design-spec.md#55-레이어-상세-버튼--수정-가능한-input-2026-10-16)).
|
||||||
- Peer: `react`, `react-dom` `^19.0.0`. 런타임 dependency는 없습니다.
|
- Peer: `react`, `react-dom` `^19.0.0`. 런타임 dependency는 없습니다.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
|
|||||||
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
|
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
|
||||||
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F).
|
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F).
|
||||||
- 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다.
|
- 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다.
|
||||||
|
- 메뉴도 고정 폭만이 아니라 실제 남은 공간을 기준으로 More에 옮기고 공간이 생기면 원래 자리·순서로 돌려놓습니다(Breakpoint는 그대로, 추가 Prop 없음). 메뉴를 열고 닫아도 Header 요소의 위치·폭은 바뀌지 않습니다.
|
||||||
```tsx
|
```tsx
|
||||||
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
|
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
|
||||||
```
|
```
|
||||||
@@ -96,9 +97,9 @@ Figma 26:10591(home on), 타일 15종 26:11355 … 26:11631, 섹션 제목 26:12
|
|||||||
|
|
||||||
| 컴포넌트 | 용도 | 주요 Props | 크기·동작 | 검증 |
|
| 컴포넌트 | 용도 | 주요 Props | 크기·동작 | 검증 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `SWHomeMenu` | 2단 메뉴 전체(섹션 여러 개). 보통 `SWHeader homeMenu`로 씀 | `sections`(`SWHomeMenuSectionData[]`) 또는 `children`, `open`/`defaultOpen`(false)/`onOpenChange(open, reason)`, `activeIds`/`defaultActiveIds`/`onActiveChange`, `selection`(`multiple` 기본 / `single`), `onItemSelect`, `closeOnBackgroundClick`(true), `closeOnSelect`(false), `closeOnEscape`(true), `closeOnOutsideClick`(false), `autoFocus`(true), `returnFocusRef`, `accent`(`auto`/`sections`/`outline`) | 높이 77(섹션 75 + 강조 2), 섹션 폭은 타일 수로 자동. 좁으면 메뉴 안 가로 스크롤 | ✅ |
|
| `SWHomeMenu` | 2단 메뉴 전체(섹션 여러 개). 보통 `SWHeader homeMenu`로 씀 | `sections`(`SWHomeMenuSectionData[]`) 또는 `children`, `open`/`defaultOpen`(false)/`onOpenChange(open, reason)`, `activeIds`/`defaultActiveIds`/`onActiveChange`, `selection`(`multiple` 기본 / `single`), `onItemSelect`, `closeOnBackgroundClick`(true), `closeOnSelect`(false), `closeOnEscape`(true), `closeOnOutsideClick`(false), `autoFocus`(true), `returnFocusRef`, `accent`(`auto`/`sections`/`outline`), `moreLabel`("더보기") | 높이 77(섹션 75 + 강조 2), 섹션 폭은 타일 수로 자동. 좁으면 들어가는 섹션만 표시하고 나머지는 더보기(⋯) Dropdown의 텍스트 메뉴(섹션 제목 + 항목, 같은 Active·Callback·하위 패널)로 이동(가로 스크롤 없음, 넓어지면 복귀) | ✅ |
|
||||||
| `SWHomeMenuSection` | 섹션(제목 badge + 타일 그룹) | `title`, `children` | badge 폭 = 글자 + 46px | ✅ |
|
| `SWHomeMenuSection` | 섹션(제목 badge + 타일 그룹) | `title`, `children` | badge 폭 = 글자 + 46px | ✅ |
|
||||||
| `SWHomeMenuItem` | 80 × 46 타일(Default / Hover / Active) | 데이터 `SWHomeMenuItemData`: `id`, `label`, `icon`, `hoverIcon`, `toggle`(true = 켜고 끄기, false = 실행만), `panel`, `panelLabel`, `disabled`, `hidden`, `title`, `onSelect`. 메뉴 밖 단독이면 `active`/`defaultActive`/`onActiveChange` | Hover·Active = 주황 gradient 1px 테두리 + `#FF5C00` 아이콘·글자 | ✅ |
|
| `SWHomeMenuItem` | 80 × 46 타일(Default / Hover / Active) | 데이터 `SWHomeMenuItemData`: `id`, `label`, `icon`, `hoverIcon`, `toggle`(기본: `panel` 있는 항목만 true = 패널이 열린 동안 Active, 나머지는 실행 버튼 = 클릭 후 ON 없음. true = 켜고 끄기 유지), `panel`, `panelLabel`, `disabled`, `hidden`, `title`, `onSelect`. 메뉴 밖 단독이면 `active`/`defaultActive`/`onActiveChange` | Hover·Active = 주황 gradient 1px 테두리 + `#FF5C00` 아이콘·글자 | ✅ |
|
||||||
| `SWRecentFiles` | 선택 하위 패널: 최근파일 목록 + 2뎁스 미리보기 | `files`(`SWFileEntry[]`: `id`, `name`, `path`, `badge`, `preview`, `previewTitle`, `disabled`), `onFileOpen`, `onFileHover`, `renderPreview`, `closeOnOpen`(true), `emptyText` | 폭 354, 행 50, 미리보기 310 × 270 | ✅ |
|
| `SWRecentFiles` | 선택 하위 패널: 최근파일 목록 + 2뎁스 미리보기 | `files`(`SWFileEntry[]`: `id`, `name`, `path`, `badge`, `preview`, `previewTitle`, `disabled`), `onFileOpen`, `onFileHover`, `renderPreview`, `closeOnOpen`(true), `emptyText` | 폭 354, 행 50, 미리보기 310 × 270 | ✅ |
|
||||||
|
|
||||||
- **Header 연결:** `SWHeader`의 `homeMenu`에 `SWHomeMenu` Props를 주면 됩니다. 기본 숨김, 로고 클릭 열기, 다시 클릭 닫기. 위치는 SWHeader가 실제 DOM으로 정합니다(객체 수정 영역 −2px ~ 창 영역, 고정 x 없음. `controls`에 `document`가 있으면 파일명 오른쪽 −2px부터). 로고·파일명·창 영역은 가리지 않습니다.
|
- **Header 연결:** `SWHeader`의 `homeMenu`에 `SWHomeMenu` Props를 주면 됩니다. 기본 숨김, 로고 클릭 열기, 다시 클릭 닫기. 위치는 SWHeader가 실제 DOM으로 정합니다(객체 수정 영역 −2px ~ 창 영역, 고정 x 없음. `controls`에 `document`가 있으면 파일명 오른쪽 −2px부터). 로고·파일명·창 영역은 가리지 않습니다.
|
||||||
|
|||||||
@@ -83,7 +83,9 @@
|
|||||||
|
|
||||||
Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음).
|
Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음).
|
||||||
|
|
||||||
로고 클릭 메뉴는 좁은 폭에서 접히지 않고 메뉴 안에서 가로 스크롤합니다(좁은 폭 전용 디자인 없음, §12).
|
실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해, `leading`·`tools`·긴 라벨 등으로 표시할 수 없는 메뉴만 같은 단계 순서(선/문자/치수 → 그리기모드 → 속성 블록 → 색상 → 구성형의 인쇄·K, 같은 단계는 오른쪽부터)로 More에 옮기고 공간이 생기면 역순으로 돌려놓습니다. 로고 옆 Open이 가장 먼저 옮겨집니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다. 호스트 요소(`leading`·`tools`)는 More로 옮기지 않으므로 폭이 좁은 화면에는 많이 넣지 않습니다.
|
||||||
|
|
||||||
|
로고 클릭 메뉴는 좁은 폭에서는 가로 스크롤 없이 들어가는 섹션만 앞에서부터 보여 주고, 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다(2026-10-19 사용자 확정, Figma에 좁은 폭 디자인 없음). 더보기를 누르면 옮긴 섹션이 텍스트 메뉴(섹션 제목 + 기능 텍스트 목록, 그룹 사이 구분선)로 아래 Dropdown에 열리고, 폭이 넓어지면 원래 자리로 돌아갑니다. 화면에 남은 섹션은 Figma 타일 그대로입니다(§12).
|
||||||
|
|
||||||
조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다.
|
조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다.
|
||||||
|
|
||||||
@@ -163,11 +165,11 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
|
|||||||
| 크기 | 메뉴 높이 77(섹션 75 + 하단 강조 2), 그룹 46, 타일 80 × 46, 아이콘 18 슬롯, 섹션 폭은 타일 수로 자동 | — |
|
| 크기 | 메뉴 높이 77(섹션 75 + 하단 강조 2), 그룹 46, 타일 80 × 46, 아이콘 18 슬롯, 섹션 폭은 타일 수로 자동 | — |
|
||||||
| 섹션 면 | 세로 gradient 면 + 1px `#081813` + 안쪽 1px `#20190E`, 아래 모서리 3px | `--hm-sw-home-menu-section-*` |
|
| 섹션 면 | 세로 gradient 면 + 1px `#081813` + 안쪽 1px `#20190E`, 아래 모서리 3px | `--hm-sw-home-menu-section-*` |
|
||||||
| 섹션 제목 | Figma tit_* badge(9-slice), 글자 400 검정 | `--hm-sw-home-menu-title-color` |
|
| 섹션 제목 | Figma tit_* badge(9-slice), 글자 400 검정 | `--hm-sw-home-menu-title-color` |
|
||||||
| 타일 상태 | Default 아이콘 `#B6B4AF` · Hover·Active 주황 gradient 1px 테두리 + 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동. 라벨 700 | `--hm-sw-home-menu-item-*`, `--hm-sw-home-menu-icon-color` |
|
| 타일 상태 | Default 아이콘 `#B6B4AF` · Hover·Pressed(누르는 동안)·Active 주황 gradient 1px 테두리 + 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동. 라벨 700. 최근파일 외 항목은 실행 버튼: 클릭 후 ON 유지 없음(2026-10-19 사용자 확정). Active 유지는 하위 패널 타일(최근파일, 목록이 열린 동안)과 더보기·홈 버튼(메뉴가 열린 동안)만. 유지형이 필요하면 `toggle: true` | `--hm-sw-home-menu-item-*`, `--hm-sw-home-menu-icon-color` |
|
||||||
| 하단 강조 | `sections` = 섹션마다 주황 띠, `outline` = 메뉴 전체 2px 외곽선(하위 패널이 열린 동안, `auto` 기본) | `--hm-sw-home-menu-accent-*` |
|
| 하단 강조 | `sections` = 섹션마다 주황 띠, `outline` = 메뉴 전체 2px 외곽선(하위 패널이 열린 동안, `auto` 기본) | `--hm-sw-home-menu-accent-*` |
|
||||||
| 최근파일 패널 | 폭 354, 행 50, 배경 `#27241D`, 주황 테두리, 행 Hover `#62533D` + `#FF5C00`, 미리보기 310 × 270 | `--hm-sw-recent-files-*` |
|
| 최근파일 패널 | 폭 354, 행 50, 배경 `#27241D`, 주황 테두리, 행 Hover `#62533D` + `#FF5C00`, 미리보기 310 × 270. 미리보기 위치: 목록 오른쪽(Figma) → 오른쪽에 공간이 없으면 왼쪽 → 좌우 모두 없으면(예: 390) 목록 위에 겹쳐 Hover 행 바로 아래(목록 왼쪽 1px 안쪽, 4면 테두리·radius 4, 크기·색 동일), 화면 아래 공간이 없으면 행 바로 위(2026-10-19 사용자 확정, Figma 없음). 가로 스크롤 없음 | `--hm-sw-recent-files-*` |
|
||||||
| 동작 | 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기. 메뉴 빈 곳 클릭 닫기(Figma CLOSE). Escape: 하위 패널 → 메뉴, 닫히면 로고로 Focus. 메뉴 밖 클릭은 닫지 않음(Figma overlay 기준) | — |
|
| 동작 | 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기. 메뉴 빈 곳 클릭 닫기(Figma CLOSE). Escape: 하위 패널 → 메뉴, 닫히면 로고로 Focus. 메뉴 밖 클릭은 닫지 않음(Figma overlay 기준) | — |
|
||||||
| 좁은 폭 | 접는 디자인이 없어 메뉴 안 가로 스크롤(의도된 동작, 겹침·잘림 없음) | — |
|
| 좁은 폭 | 측정해 들어가는 섹션만 표시, 나머지는 끝의 더보기(⋯) 섹션(기존 섹션·타일 부품, 배지 `moreLabel` 기본 "더보기", 타일 "N개 그룹")으로 이동. 메뉴 배경·폭·높이·테두리·강조선은 그대로(더보기 섹션 면이 남는 폭을 채워 객체 수정 영역을 계속 덮음)이고, 더보기 배지·⋯ 타일만 마지막으로 표시된 섹션 바로 오른쪽에 붙음. 전체가 들어가는 폭에서는 더보기 숨김. 더보기 Dropdown과 최근파일 목록은 동시에 열리지 않음: 더보기를 열면 열린 목록을 먼저 닫고, Dropdown에서 최근파일을 고르면 Dropdown을 닫고 목록을 엶. Dropdown = 텍스트 메뉴(SW brown Dropdown: 배경 `#27241D`·테두리 `#000`·radius 3, 제목 12px 700 `#D4CBBD`, 행 30px·14px 700·좌우 12, 그룹 구분선 = 레이어 footer 선), ⋯ 타일 아래 왼쪽 정렬(화면 안으로 맞춤). 행 Hover = Pressed = Active = `#62533D` + `#FF5C00`. 일반 행은 클릭 후 ON·✓ 없음, 최근파일 행(›)만 목록이 열린 동안 Active. ✓는 `toggle: true` 행에만. 클릭·Callback·상태 규칙은 타일과 같음. ↓(더보기) = 첫 행, ↑/↓ 이동. Escape: Dropdown → 하위 패널 → 메뉴. 더보기 안 최근파일은 Dropdown을 닫고 더보기 타일 아래(화면 안으로 맞춤)에 기존 최근파일 목록으로 열림, 미리보기는 오른쪽에 공간이 없으면 왼쪽, 좌우 모두 없으면 목록 위 행 아래(최근파일 패널 행 참고). 목록이 메뉴 밖으로 나가는 부분은 메뉴 하단 강조선(2px, 같은 gradient)이 목록 상단으로 이어짐(실측 위치 기준, `--hm-sw-home-menu-accent-outline-width`). 가로 스크롤 없음 | `--hm-sw-home-menu-more-*` |
|
||||||
|
|
||||||
- 열림 상태 Header 상·하단 `#5F4B0F` 2px 선(Figma home on)과 로고 열림 표시는 **적용하지 않습니다**(디자인 승인 대기). 임의로 추가하지 않습니다.
|
- Home On(Figma 662:67267, 2026-10-19 사용자 확정): 메뉴가 열려 있는 동안 SWHeader가 자동으로 로고에 노란 Hover 테두리를 유지하고, Header 전폭 상·하단에 `#5F4B0F` 2px 선(`--hm-sw-header-home-on-line-color` / `-width`)을 그립니다. 닫으면 기본 상태로 돌아갑니다. 크기·위치 변화가 없고 SW 일반 모드 전용입니다(뷰어 모드에는 없음). 소비 코드에서 별도 class나 상태를 추가하지 않습니다.
|
||||||
- 새 색·크기는 위 토큰만 `style`로 덮어씁니다. `hm-sw-home-menu*` class를 소비 코드에 쓰지 않습니다.
|
- 새 색·크기는 위 토큰만 `style`로 덮어씁니다. `hm-sw-home-menu*` class를 소비 코드에 쓰지 않습니다.
|
||||||
@@ -42,6 +42,34 @@ const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove",
|
|||||||
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
|
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
|
||||||
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
|
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order in which visible parts move into More when the measured object area has no room left (on top of the
|
||||||
|
* breakpoints): the breakpoint tier order — line / text / dimension style, 그리기모드, the property block, color,
|
||||||
|
* print / K — and within a tier the rightmost part first. The representative field never moves.
|
||||||
|
*/
|
||||||
|
const squeezeRank: Partial<Record<SWHeaderControlId, number>> = {
|
||||||
|
lineStyle: 0, textStyle: 0, dimensionStyle: 0, drawingMode: 1, details: 2, propertyBar: 2, propertyPanel: 2, color: 3, print: 4, popupGuide: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room left in the object area (negative: missing) when nothing in it shrinks, grows or takes free space through an
|
||||||
|
* auto margin (`data-measuring`, see sw.css "Measured More"). Read and reset in one layout effect, never painted.
|
||||||
|
* Only in-flow children count: dropdowns, tooltips and other positioned layers never change the result.
|
||||||
|
*/
|
||||||
|
function measureRoom(header: HTMLElement, area: HTMLElement) {
|
||||||
|
header.setAttribute("data-measuring", "")
|
||||||
|
const style = getComputedStyle(area)
|
||||||
|
const end = area.getBoundingClientRect().right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
|
||||||
|
let used = area.getBoundingClientRect().left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
|
||||||
|
for (const child of Array.from(area.children)) {
|
||||||
|
const childStyle = getComputedStyle(child)
|
||||||
|
if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
|
||||||
|
used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
|
||||||
|
}
|
||||||
|
header.removeAttribute("data-measuring")
|
||||||
|
return end - used
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Menu order: listed known ids first (duplicates and unknown ids ignored), missing ids after them in
|
* Menu order: listed known ids first (duplicates and unknown ids ignored), missing ids after them in
|
||||||
* the default order. Menus only swap places among the slots menus already take; other parts never move.
|
* the default order. Menus only swap places among the slots menus already take; other parts never move.
|
||||||
@@ -170,8 +198,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
|
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
|
||||||
// Default design with removed object parts: same structure and breakpoints, but the script decides what
|
// Default design with removed object parts: same structure and breakpoints, but the script decides what
|
||||||
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
|
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
|
||||||
// Sub-items live inside a part and never change the layout mode.
|
// Sub-items live inside a part and never change the layout mode. Parts measured into More also need the script.
|
||||||
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
|
const [squeezed, setSqueezed] = useState<readonly string[]>([])
|
||||||
|
const scripted = controls !== undefined || squeezed.length > 0 || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
|
||||||
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
|
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
|
||||||
const presentRef = useRef(new Set(configuration.map(item => item.id)))
|
const presentRef = useRef(new Set(configuration.map(item => item.id)))
|
||||||
presentRef.current = new Set(configuration.map(item => item.id))
|
presentRef.current = new Set(configuration.map(item => item.id))
|
||||||
@@ -214,6 +243,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
|
|
||||||
|
|
||||||
const [headerWidth, setHeaderWidth] = useState(1920)
|
const [headerWidth, setHeaderWidth] = useState(1920)
|
||||||
|
const [, setAreaWidth] = useState(0)
|
||||||
const [barOffset, setBarOffset] = useState(0)
|
const [barOffset, setBarOffset] = useState(0)
|
||||||
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
|
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
|
||||||
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
|
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
|
||||||
@@ -228,6 +258,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
const update = () => {
|
const update = () => {
|
||||||
const bounds = header.getBoundingClientRect()
|
const bounds = header.getBoundingClientRect()
|
||||||
setHeaderWidth(bounds.width)
|
setHeaderWidth(bounds.width)
|
||||||
|
// The object area can change alone (leading content, file name, window area): it re-runs the measured More.
|
||||||
|
setAreaWidth(panelAnchorRef.current?.getBoundingClientRect().width ?? 0)
|
||||||
setBarOffset((panelAnchorRef.current?.getBoundingClientRect().left ?? bounds.left) - bounds.left)
|
setBarOffset((panelAnchorRef.current?.getBoundingClientRect().left ?? bounds.left) - bounds.left)
|
||||||
}
|
}
|
||||||
const observer = new ResizeObserver(update)
|
const observer = new ResizeObserver(update)
|
||||||
@@ -237,8 +269,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
|
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
|
||||||
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
|
// --hm-sw-header-leading-width). Open and the other parts move into More by measurement (see "Measured More").
|
||||||
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
|
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
|
||||||
if (!header) return
|
if (!header) return
|
||||||
@@ -248,22 +279,6 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
|
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
|
||||||
else header.style.removeProperty("--hm-sw-header-leading-width")
|
else header.style.removeProperty("--hm-sw-header-leading-width")
|
||||||
}
|
}
|
||||||
if (!openInHeader) {
|
|
||||||
collapseRef.current = null
|
|
||||||
if (openCollapsed) setOpenCollapsed(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!openCollapsed) {
|
|
||||||
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
|
|
||||||
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
|
|
||||||
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
|
|
||||||
if (overflow && open) {
|
|
||||||
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
|
|
||||||
setOpenCollapsed(true)
|
|
||||||
}
|
|
||||||
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
|
|
||||||
setOpenCollapsed(false)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
|
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
|
||||||
@@ -303,9 +318,23 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
|
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
|
||||||
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
|
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
|
||||||
const validPromoted = promoted && enabled(promoted) ? promoted : null
|
const validPromoted = promoted && enabled(promoted) ? promoted : null
|
||||||
const isOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : false
|
const breakpointOverflow = (id: SWHeaderControlId) => id === "color" ? headerWidth < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? headerWidth < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? headerWidth < 1080 : ["print", "popupGuide"].includes(id) ? headerWidth < 640 : id === "drawingMode" ? headerWidth < 1180 : false
|
||||||
|
// Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in
|
||||||
|
// the window area there and keep the breakpoint). Keys of the moved parts are kept in move order.
|
||||||
|
const squeezeGroups = (controls === undefined
|
||||||
|
? [...menuIds.map(id => ({ key: id as string, ids: [id] as SWHeaderControlId[] })), { key: "drawingMode", ids: ["drawingMode"] as SWHeaderControlId[] },
|
||||||
|
{ key: "properties", ids: ["details", "propertyBar", "propertyPanel"] as SWHeaderControlId[] }]
|
||||||
|
: configuration.map(item => ({ key: item.id as string, ids: [item.id] })))
|
||||||
|
.map((group, index) => ({ ...group, ids: group.ids.filter(id => enabled(id) && squeezeRank[id] !== undefined), index }))
|
||||||
|
.filter(group => group.ids.length > 0)
|
||||||
|
const squeezedIds = new Set(squeezeGroups.filter(group => squeezed.includes(group.key)).flatMap(group => group.ids))
|
||||||
|
const isOverflow = (id: SWHeaderControlId) => breakpointOverflow(id) || squeezedIds.has(id)
|
||||||
const representative = validPromoted && isOverflow(validPromoted) ? validPromoted : baseMenu
|
const representative = validPromoted && isOverflow(validPromoted) ? validPromoted : baseMenu
|
||||||
const fieldHidden = (id: SWHeaderMenuId) => id !== representative && (isOverflow(id) || (id === baseMenu && representative !== baseMenu))
|
const fieldHidden = (id: SWHeaderMenuId) => id !== representative && (isOverflow(id) || (id === baseMenu && representative !== baseMenu))
|
||||||
|
// Next part to move: shown now, never the representative slot, lowest tier first, rightmost first within a tier.
|
||||||
|
const squeezeCandidates = squeezeGroups
|
||||||
|
.filter(group => !squeezed.includes(group.key) && !group.ids.some(id => id === baseMenu || id === validPromoted || (isMenu(id) ? fieldHidden(id) : isOverflow(id))))
|
||||||
|
.sort((a, b) => (squeezeRank[a.ids[0]] ?? 0) - (squeezeRank[b.ids[0]] ?? 0) || b.index - a.index)
|
||||||
// Removal is structural: close stale internal dropdown state silently, never send data callbacks.
|
// Removal is structural: close stale internal dropdown state silently, never send data callbacks.
|
||||||
const controlKey = configuration.map(item => item.id).join(",")
|
const controlKey = configuration.map(item => item.id).join(",")
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -315,6 +344,51 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
if (!enabled("propertyBar")) setInternalBarOpen(false)
|
if (!enabled("propertyBar")) setInternalBarOpen(false)
|
||||||
}, [controlKey, openMenu, promoted])
|
}, [controlKey, openMenu, promoted])
|
||||||
|
|
||||||
|
// Measured More (2026-10-19): on top of the breakpoints, parts move into More while the object area has no room for
|
||||||
|
// them (leading content, tools, labels, a side panel) and return once the room they freed is back, so the header
|
||||||
|
// never relies on fixed widths alone. Open next to the logo moves first and returns last. One part per pass; the
|
||||||
|
// passes are synchronous layout effects, so only the final layout is painted. Opening a menu never moves a part:
|
||||||
|
// dropdowns and the logo menu are positioned layers, which the measurement ignores.
|
||||||
|
const squeezeRef = useRef(new Map<string, { before: number; room?: number }>())
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
|
||||||
|
const area = panelAnchorRef.current
|
||||||
|
// Wait for the measured header width (breakpoint state) to be rendered first.
|
||||||
|
if (!header || !area || Math.abs(header.getBoundingClientRect().width - headerWidth) > 0.5) return
|
||||||
|
if (!openInHeader) {
|
||||||
|
collapseRef.current = null
|
||||||
|
if (openCollapsed) return setOpenCollapsed(false)
|
||||||
|
}
|
||||||
|
// Parts removed from the configuration leave the list silently.
|
||||||
|
const valid = squeezed.filter(key => squeezeGroups.some(group => group.key === key))
|
||||||
|
if (valid.length !== squeezed.length) return setSqueezed(valid)
|
||||||
|
const room = measureRoom(header, area)
|
||||||
|
const last = squeezed[squeezed.length - 1]
|
||||||
|
const metrics = last === undefined ? undefined : squeezeRef.current.get(last)
|
||||||
|
// The room a part frees is measured once, right after it moved (More appearing and padding changes included).
|
||||||
|
if (metrics && metrics.room === undefined) metrics.room = room - metrics.before
|
||||||
|
if (room < -0.5) {
|
||||||
|
const open = openShown ? leadingRef.current?.querySelector<HTMLElement>("[data-document-open]") : null
|
||||||
|
if (open) {
|
||||||
|
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
|
||||||
|
return setOpenCollapsed(true)
|
||||||
|
}
|
||||||
|
const next = squeezeCandidates[0]
|
||||||
|
if (next) {
|
||||||
|
squeezeRef.current.set(next.key, { before: room })
|
||||||
|
setSqueezed([...squeezed, next.key])
|
||||||
|
}
|
||||||
|
} else if (last !== undefined) {
|
||||||
|
if (room >= (metrics?.room ?? Infinity) - 0.5) {
|
||||||
|
squeezeRef.current.delete(last)
|
||||||
|
setSqueezed(squeezed.slice(0, -1))
|
||||||
|
}
|
||||||
|
} else if (openCollapsed && collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
|
||||||
|
// Open returns once the header is wider than when it moved by the Open width (no flicker at the edge).
|
||||||
|
setOpenCollapsed(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const hasMenu: Record<SWHeaderMenuId, boolean> = {
|
const hasMenu: Record<SWHeaderMenuId, boolean> = {
|
||||||
layer: layers !== undefined,
|
layer: layers !== undefined,
|
||||||
color: colors !== undefined,
|
color: colors !== undefined,
|
||||||
@@ -518,7 +592,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
|
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} data-home-menu-open={homeOpen || undefined} {...rest}>
|
||||||
<div className="hm-sw-header__bar">
|
<div className="hm-sw-header__bar">
|
||||||
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt}
|
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt}
|
||||||
aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} />
|
aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} />
|
||||||
@@ -527,19 +601,19 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
|
|||||||
{leading}
|
{leading}
|
||||||
</div>}
|
</div>}
|
||||||
{controls === undefined && enabled("document") && documentNode}
|
{controls === undefined && enabled("document") && documentNode}
|
||||||
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
|
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined} data-squeezed={squeezed.length ? squeezed.join(" ") : undefined}>
|
||||||
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
|
{controls === undefined && enabled("drawingMode") && !squeezedIds.has("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
|
||||||
<div ref={fieldsRef} className="hm-sw-header__fields" data-configured={controls !== undefined || undefined}>
|
<div ref={fieldsRef} className="hm-sw-header__fields" data-configured={controls !== undefined || undefined}>
|
||||||
{controls ? configuration.map(config => {
|
{controls ? configuration.map(config => {
|
||||||
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id) || (config.id === "drawingMode" && headerWidth < 1180)
|
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
|
||||||
return <div key={config.id} className="hm-sw-header__control" data-control={config.id} data-hidden={hidden || undefined}>{presence(config.id, configuredNodes[config.id])}</div>
|
return <div key={config.id} className="hm-sw-header__control" data-control={config.id} data-hidden={hidden || undefined}>{presence(config.id, configuredNodes[config.id])}</div>
|
||||||
}) : scripted ? menuIds.map(id => (
|
}) : scripted ? menuIds.map(id => (
|
||||||
<div key={id} className="hm-sw-header__control" data-control={id} data-hidden={fieldHidden(id) || undefined} data-representative={(id === representative && id !== baseMenu) || undefined}>{presence(id, fieldNodes[id])}</div>
|
<div key={id} className="hm-sw-header__control" data-control={id} data-hidden={fieldHidden(id) || undefined} data-representative={(id === representative && id !== baseMenu) || undefined}>{presence(id, fieldNodes[id])}</div>
|
||||||
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
|
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
|
||||||
{tools}
|
{tools}
|
||||||
</div>
|
</div>
|
||||||
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
|
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed || squeezed.length > 0} />}
|
||||||
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
|
{controls === undefined && !squeezed.includes("properties") && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
|
||||||
</div>
|
</div>
|
||||||
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
|
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
|
||||||
{actionsShown && <SWHeaderActions disabled={disabled} onPrint={onPrint} onPopupGuide={onPopupGuide} showPrint={enabled("print")} showPopupGuide={enabled("popupGuide")} />}
|
{actionsShown && <SWHeaderActions disabled={disabled} onPrint={onPrint} onPopupGuide={onPopupGuide} showPrint={enabled("print")} showPopupGuide={enabled("popupGuide")} />}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface SWHeaderHomeProps {
|
|||||||
* SWHeader always shows the logo; only the home action can be removed. Default true.
|
* SWHeader always shows the logo; only the home action can be removed. Default true.
|
||||||
*/
|
*/
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
/** Logo menu state (SWHeader `homeMenu`). No visual change: Figma has no open look for the logo. */
|
/** Logo menu state (SWHeader `homeMenu`). The Home On look is drawn by SWHeader (`data-home-menu-open`, Figma 662:67267). */
|
||||||
"aria-expanded"?: boolean
|
"aria-expanded"?: boolean
|
||||||
"aria-controls"?: string
|
"aria-controls"?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
|
Children, createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
|
||||||
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
|
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
|
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
|
||||||
@@ -19,13 +19,26 @@ interface WindowContextValue {
|
|||||||
}
|
}
|
||||||
const WindowContext = createContext<WindowContextValue | null>(null)
|
const WindowContext = createContext<WindowContextValue | null>(null)
|
||||||
const SectionContext = createContext<SWHomeMenuSectionData | undefined>(undefined)
|
const SectionContext = createContext<SWHomeMenuSectionData | undefined>(undefined)
|
||||||
|
/**
|
||||||
|
* How sections and items draw: `tile` (Figma row), `text` (inside the More dropdown: title + text rows, same
|
||||||
|
* selection / Active / panel logic) or `measure` (invisible tile copy that gives the natural widths; no registration).
|
||||||
|
*/
|
||||||
|
type Presentation = "tile" | "text" | "measure"
|
||||||
|
const PresentationContext = createContext<Presentation>("tile")
|
||||||
|
|
||||||
/** For content inside a panel (e.g. SWRecentFiles): close the whole menu after opening a file. */
|
/** For content inside a panel (e.g. SWRecentFiles): close the whole menu after opening a file. */
|
||||||
export function useSWHomeMenuClose() {
|
export function useSWHomeMenuClose() {
|
||||||
return useContext(WindowContext)?.close
|
return useContext(WindowContext)?.close
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_SELECTOR = ".hm-sw-home-menu__item:not(:disabled)"
|
const ITEM_SELECTOR = ":is(.hm-sw-home-menu__item, .hm-sw-home-menu__text-item):not(:disabled)"
|
||||||
|
const TEXT_ITEM_SELECTOR = ".hm-sw-home-menu__text-item:not(:disabled)"
|
||||||
|
/** Tiles / text rows that can take focus now (the closed More dropdown stays laid out but hidden; the measure copy is hidden). */
|
||||||
|
const focusableItems = (root: Element | null, selector = ITEM_SELECTOR) =>
|
||||||
|
Array.from(root?.querySelectorAll<HTMLElement>(selector) ?? []).filter(item => getComputedStyle(item).visibility !== "hidden")
|
||||||
|
const ROW = ":scope > .hm-sw-home-menu__sections"
|
||||||
|
const MORE_PANEL = ":scope > .hm-sw-home-menu__more-panel"
|
||||||
|
const MEASURE = ":scope > .hm-sw-home-menu__measure"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* While open, the menu is on the SW popup Escape stack (SWPopup / property window): Escape with the focus outside
|
* While open, the menu is on the SW popup Escape stack (SWPopup / property window): Escape with the focus outside
|
||||||
@@ -41,13 +54,14 @@ function SWHomeMenuEscapeEntry({ onEscape, raiseRef }: { onEscape: () => void; r
|
|||||||
* default; the host toggles `open` from SWHeader `onHomeClick` (logo click / re-click). Sections of 80 x 46 tiles;
|
* default; the host toggles `open` from SWHeader `onHomeClick` (logo click / re-click). Sections of 80 x 46 tiles;
|
||||||
* a tile may open an optional sub panel under the menu (Figma 최근파일 662:67331 → <SWRecentFiles>). Every text,
|
* a tile may open an optional sub panel under the menu (Figma 최근파일 662:67331 → <SWRecentFiles>). Every text,
|
||||||
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
|
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
|
||||||
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader).
|
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader). Narrower than its sections, it never scrolls: the
|
||||||
|
* trailing sections move into a More (⋯) section and return when the width allows (`moreLabel`).
|
||||||
*/
|
*/
|
||||||
export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function SWHomeMenu({
|
export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function SWHomeMenu({
|
||||||
sections, children, open: openProp, defaultOpen = false, onOpenChange, activeIds: activeProp, defaultActiveIds,
|
sections, children, open: openProp, defaultOpen = false, onOpenChange, activeIds: activeProp, defaultActiveIds,
|
||||||
onActiveChange, selection = "multiple", onItemSelect, closeOnBackgroundClick = true, closeOnSelect = false,
|
onActiveChange, selection = "multiple", onItemSelect, closeOnBackgroundClick = true, closeOnSelect = false,
|
||||||
closeOnEscape = true, closeOnOutsideClick = false, autoFocus = true, returnFocusRef, accent = "auto",
|
closeOnEscape = true, closeOnOutsideClick = false, autoFocus = true, returnFocusRef, accent = "auto",
|
||||||
className, style, onClick, onKeyDown, ...rest
|
moreLabel = "더보기", className, style, onClick, onKeyDown, ...rest
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
||||||
const open = openProp ?? internalOpen
|
const open = openProp ?? internalOpen
|
||||||
@@ -91,16 +105,104 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
onOpenChange?.(false, reason)
|
onOpenChange?.(false, reason)
|
||||||
}, [openProp, onOpenChange, returnFocusRef, setActive])
|
}, [openProp, onOpenChange, returnFocusRef, setActive])
|
||||||
|
|
||||||
// Opening (not the first render): remember the focus to return to, then focus the first tile.
|
// Sections in order (data or children). The leading ones that fit stay in the row; the rest move into More.
|
||||||
|
const nodes = sections
|
||||||
|
? sections.filter(section => !section.hidden).map(section => (
|
||||||
|
<SWHomeMenuSection key={section.id} section={section} title={section.title}>
|
||||||
|
{section.items.filter(item => !item.hidden).map(item => <SWHomeMenuItem key={item.id} item={item} />)}
|
||||||
|
</SWHomeMenuSection>
|
||||||
|
))
|
||||||
|
: Children.toArray(children)
|
||||||
|
const [fit, setFit] = useState(Number.POSITIVE_INFINITY)
|
||||||
|
const rowCount = Math.min(fit, nodes.length)
|
||||||
|
const overflowCount = nodes.length - rowCount
|
||||||
|
const [moreOpenState, setMoreOpen] = useState(false)
|
||||||
|
const moreOpen = moreOpenState && overflowCount > 0
|
||||||
|
const moreId = useId()
|
||||||
|
const moreButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const morePanelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [moreLeft, setMoreLeft] = useState(0)
|
||||||
|
const [, setResized] = useState(0)
|
||||||
|
const pendingFocus = useRef(false)
|
||||||
|
// Keyboard (ArrowDown on More): focus the first text row once the dropdown is shown.
|
||||||
|
const pendingMoreFocus = useRef(false)
|
||||||
|
|
||||||
|
// Opening (not the first render): remember the focus to return to; the first tile is focused once the row is measured.
|
||||||
const wasOpen = useRef(open)
|
const wasOpen = useRef(open)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (open && !wasOpen.current) {
|
if (open && !wasOpen.current) {
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
|
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
|
||||||
if (autoFocus) rootRef.current?.querySelector<HTMLElement>(ITEM_SELECTOR)?.focus()
|
pendingFocus.current = autoFocus
|
||||||
}
|
}
|
||||||
wasOpen.current = open
|
wasOpen.current = open
|
||||||
}, [open, autoFocus])
|
}, [open, autoFocus])
|
||||||
|
useEffect(() => { if (!open || overflowCount === 0) setMoreOpen(false) }, [open, overflowCount])
|
||||||
|
|
||||||
|
// Measured More (2026-10-19): one synchronous read before paint, like SWHeader's measured More. data-measuring lets
|
||||||
|
// the menu take all the room its host gives it (so the available width does not depend on what is shown). The
|
||||||
|
// natural tile widths come from the invisible measure copy (every section as a tile + the More section), because
|
||||||
|
// the moved sections are text rows in the dropdown.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const root = rootRef.current
|
||||||
|
const row = root?.querySelector<HTMLElement>(ROW)
|
||||||
|
const [tiles, moreCopy] = Array.from(root?.querySelector(MEASURE)?.children ?? [])
|
||||||
|
if (!open || !root || !row || !tiles || !moreCopy) return
|
||||||
|
root.setAttribute("data-measuring", "")
|
||||||
|
const available = root.getBoundingClientRect().width
|
||||||
|
const rowStyle = getComputedStyle(row)
|
||||||
|
const padding = parseFloat(rowStyle.paddingLeft) + parseFloat(rowStyle.paddingRight)
|
||||||
|
const widths = Array.from(tiles.children, section => section.getBoundingClientRect().width)
|
||||||
|
const moreWidth = moreCopy.firstElementChild?.getBoundingClientRect().width ?? 0
|
||||||
|
root.removeAttribute("data-measuring")
|
||||||
|
// 0: not laid out yet (e.g. SWHeader before its placement) — keep the current split.
|
||||||
|
if (available < 1) return
|
||||||
|
let next = widths.length
|
||||||
|
if (widths.reduce((sum, width) => sum + width, 0) + padding > available + 0.5) {
|
||||||
|
let room = available - padding - moreWidth
|
||||||
|
next = 0
|
||||||
|
while (next < widths.length && widths[next] <= room + 0.5) room -= widths[next++]
|
||||||
|
}
|
||||||
|
if (next !== rowCount) { setFit(next); return }
|
||||||
|
// Dropdown under the ⋯ tile (left edges aligned, like the sub panel at its tile), kept inside the viewport.
|
||||||
|
const panel = root.querySelector<HTMLElement>(MORE_PANEL)
|
||||||
|
if (moreOpen && pendingMoreFocus.current) {
|
||||||
|
pendingMoreFocus.current = false
|
||||||
|
focusableItems(panel, TEXT_ITEM_SELECTOR)[0]?.focus()
|
||||||
|
}
|
||||||
|
const moreTile = moreButtonRef.current
|
||||||
|
if (moreTile && panel) {
|
||||||
|
const origin = root.getBoundingClientRect()
|
||||||
|
const left = Math.max(Math.min(moreTile.getBoundingClientRect().left - origin.left, document.documentElement.clientWidth - origin.left - panel.offsetWidth), -origin.left)
|
||||||
|
if (Math.abs(left - moreLeft) > 0.1) setMoreLeft(left)
|
||||||
|
}
|
||||||
|
if (pendingFocus.current) {
|
||||||
|
pendingFocus.current = false
|
||||||
|
focusableItems(root)[0]?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// The host's room can change without a render (window resize, a standalone container): measure again.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current
|
||||||
|
if (!open || !root?.parentElement) return
|
||||||
|
const observer = new ResizeObserver(() => setResized(count => count + 1))
|
||||||
|
observer.observe(root.parentElement)
|
||||||
|
const header = root.closest(".hm-sw-header")
|
||||||
|
if (header) observer.observe(header)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// A click outside the dropdown and its More tile closes the dropdown (the menu stays open).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!moreOpen) return
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (morePanelRef.current?.contains(target) || moreButtonRef.current?.closest(".hm-sw-home-menu__more")?.contains(target)) return
|
||||||
|
setMoreOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown)
|
||||||
|
return () => document.removeEventListener("pointerdown", onPointerDown)
|
||||||
|
}, [moreOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !closeOnOutsideClick) return
|
if (!open || !closeOnOutsideClick) return
|
||||||
@@ -114,21 +216,42 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
}, [open, closeOnOutsideClick, close, returnFocusRef])
|
}, [open, closeOnOutsideClick, close, returnFocusRef])
|
||||||
|
|
||||||
// Panel position: under the menu, 1px right of its tile's left edge (Figma 최근파일 808 vs tile 807).
|
// Panel position: under the menu, 1px right of its tile's left edge (Figma 최근파일 808 vs tile 807).
|
||||||
|
// A tile inside More opens its panel under the More tile (the dropdown closes, see the dropdown click).
|
||||||
const [panelLeft, setPanelLeft] = useState(0)
|
const [panelLeft, setPanelLeft] = useState(0)
|
||||||
|
const [panelFromMore, setPanelFromMore] = useState(false)
|
||||||
|
// Accent joint: the parts of the panel's top edge outside the menu (narrow widths: the panel is kept in the
|
||||||
|
// viewport and may reach past the menu). Measured with the panel position; empty while it stays under the menu.
|
||||||
|
const [joint, setJoint] = useState<readonly { left: number; width: number }[]>([])
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!openPanelId || !rootRef.current) return
|
if (!openPanelId || !rootRef.current) return
|
||||||
const root = rootRef.current
|
const root = rootRef.current
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const tile = root.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)
|
const tile = root.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)
|
||||||
if (tile) setPanelLeft(tile.getBoundingClientRect().left - root.getBoundingClientRect().left + 1)
|
const inMore = !!tile?.closest(".hm-sw-home-menu__more-panel")
|
||||||
|
const anchor = inMore ? moreButtonRef.current : tile
|
||||||
|
setPanelFromMore(inMore)
|
||||||
|
if (!anchor) return
|
||||||
|
// Kept inside the viewport (narrow widths: no page scroll); at 1920 it stays at its tile as in Figma.
|
||||||
|
const origin = root.getBoundingClientRect()
|
||||||
|
const panel = root.querySelector<HTMLElement>(":scope > .hm-sw-home-menu__panel")
|
||||||
|
const tileLeft = anchor.getBoundingClientRect().left - origin.left + 1
|
||||||
|
const left = panel ? Math.max(Math.min(tileLeft, document.documentElement.clientWidth - origin.left - panel.offsetWidth), -origin.left) : tileLeft
|
||||||
|
setPanelLeft(left)
|
||||||
|
// Each joint starts inside the menu's rounded bottom corner, so the accent runs on without a notch.
|
||||||
|
const right = left + (panel?.offsetWidth ?? 0)
|
||||||
|
const corner = parseFloat(getComputedStyle(root, "::after").borderBottomRightRadius) || 0
|
||||||
|
const next: { left: number; width: number }[] = []
|
||||||
|
if (panel && left < 0) next.push({ left, width: Math.min(right, corner) - left })
|
||||||
|
if (panel && right > origin.width) { const start = Math.max(left, origin.width - corner); next.push({ left: start, width: right - start }) }
|
||||||
|
setJoint(current => JSON.stringify(current) === JSON.stringify(next) ? current : next)
|
||||||
}
|
}
|
||||||
measure()
|
measure()
|
||||||
const scroller = root.querySelector(".hm-sw-home-menu__sections")
|
|
||||||
scroller?.addEventListener("scroll", measure)
|
|
||||||
const observer = new ResizeObserver(measure)
|
const observer = new ResizeObserver(measure)
|
||||||
observer.observe(root)
|
observer.observe(root)
|
||||||
return () => { scroller?.removeEventListener("scroll", measure); observer.disconnect() }
|
const panel = root.querySelector(":scope > .hm-sw-home-menu__panel")
|
||||||
}, [openPanelId])
|
if (panel) observer.observe(panel)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [openPanelId, rowCount])
|
||||||
|
|
||||||
const context = useMemo<WindowContextValue>(() => ({
|
const context = useMemo<WindowContextValue>(() => ({
|
||||||
isActive: id => activeIds.includes(id),
|
isActive: id => activeIds.includes(id),
|
||||||
@@ -137,7 +260,8 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
close,
|
close,
|
||||||
select: (item, section) => {
|
select: (item, section) => {
|
||||||
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
|
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
|
||||||
const toggle = item.toggle !== false
|
// Action items (the default without a panel) only run: no Active after the click (2026-10-19).
|
||||||
|
const toggle = item.toggle ?? item.panel != null
|
||||||
const active = toggle && !current.includes(item.id)
|
const active = toggle && !current.includes(item.id)
|
||||||
const event: SWHomeMenuSelectEvent = { item, section, active }
|
const event: SWHomeMenuSelectEvent = { item, section, active }
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
@@ -156,13 +280,33 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
const raiseRef = useRef<(() => void) | null>(null)
|
const raiseRef = useRef<(() => void) | null>(null)
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
/** Escape: an open sub panel closes first (focus back on its tile when it was inside), then the menu. */
|
/** Closes the open sub panel: its tile returns to Default (same event as a second click on it). */
|
||||||
|
const closePanel = () => {
|
||||||
|
if (!openPanelId) return
|
||||||
|
setActive(activeIds.filter(id => id !== openPanelId), { item: { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }, active: false })
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The More dropdown and a sub panel never show together (they would cover each other): opening the dropdown closes
|
||||||
|
* the open panel first; a panel row inside the dropdown closes the dropdown (see the dropdown click); a click
|
||||||
|
* outside the dropdown (e.g. the 최근파일 tile in the row) closes it.
|
||||||
|
*/
|
||||||
|
const openMore = () => {
|
||||||
|
closePanel()
|
||||||
|
setMoreOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape: the More dropdown closes first, then an open sub panel (focus back on its tile when it was inside), then the menu. */
|
||||||
const escape = () => {
|
const escape = () => {
|
||||||
|
if (moreOpen) {
|
||||||
|
const focusInside = morePanelRef.current?.contains(document.activeElement)
|
||||||
|
setMoreOpen(false)
|
||||||
|
if (focusInside) moreButtonRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (openPanelId) {
|
if (openPanelId) {
|
||||||
const item = { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }
|
|
||||||
const focusInside = rootRef.current?.contains(document.activeElement)
|
const focusInside = rootRef.current?.contains(document.activeElement)
|
||||||
setActive(activeIds.filter(id => id !== openPanelId), { item, active: false })
|
closePanel()
|
||||||
if (focusInside) rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)?.focus()
|
if (focusInside) (panelFromMore ? moreButtonRef.current : rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`))?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
close("escape")
|
close("escape")
|
||||||
@@ -172,7 +316,7 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented || !closeOnBackgroundClick) return
|
if (event.defaultPrevented || !closeOnBackgroundClick) return
|
||||||
const target = event.target as Element
|
const target = event.target as Element
|
||||||
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel")) close("background")
|
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel, .hm-sw-home-menu__more-panel")) close("background")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
@@ -184,14 +328,34 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
escape()
|
escape()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// ArrowDown on the tile of the open sub panel moves into the panel (keyboard access, not in Figma).
|
const focused = document.activeElement as HTMLElement | null
|
||||||
if (event.key === "ArrowDown" && openPanelId && (document.activeElement as HTMLElement | null)?.dataset.itemId === openPanelId) {
|
const onMore = !!focused && focused === moreButtonRef.current
|
||||||
|
// ArrowDown on the tile of the open sub panel (or on More, for a panel opened from it) moves into the panel
|
||||||
|
// (keyboard access, not in Figma).
|
||||||
|
if (event.key === "ArrowDown" && openPanelId && (focused?.dataset.itemId === openPanelId || (onMore && panelFromMore))) {
|
||||||
const first = rootRef.current?.querySelector<HTMLElement>(".hm-sw-home-menu__panel :is(button, [href], input, [tabindex]):not(:disabled, [tabindex='-1'])")
|
const first = rootRef.current?.querySelector<HTMLElement>(".hm-sw-home-menu__panel :is(button, [href], input, [tabindex]):not(:disabled, [tabindex='-1'])")
|
||||||
if (first) { event.preventDefault(); first.focus() }
|
if (first) { event.preventDefault(); first.focus() }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// More dropdown: ArrowDown on More opens it on its first text row; ArrowUp / ArrowDown move through the rows and
|
||||||
|
// ArrowUp on the first row goes back to More.
|
||||||
|
if (event.key === "ArrowDown" && onMore) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (moreOpen) focusableItems(morePanelRef.current, TEXT_ITEM_SELECTOR)[0]?.focus()
|
||||||
|
else { pendingMoreFocus.current = true; openMore() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && focused && morePanelRef.current?.contains(focused)) {
|
||||||
|
const rows = focusableItems(morePanelRef.current, TEXT_ITEM_SELECTOR)
|
||||||
|
const index = rows.indexOf(focused)
|
||||||
|
if (index < 0) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.key === "ArrowUp" && index === 0) moreButtonRef.current?.focus()
|
||||||
|
else rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
|
||||||
const items = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
|
const items = focusableItems(rootRef.current)
|
||||||
const index = items.indexOf(document.activeElement as HTMLElement)
|
const index = items.indexOf(document.activeElement as HTMLElement)
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -202,6 +366,9 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
|
|
||||||
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
|
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
|
||||||
const openPanel = openPanelId ? panels[openPanelId] : undefined
|
const openPanel = openPanelId ? panels[openPanelId] : undefined
|
||||||
|
// More tile: Variant3 while its dropdown is open or a panel of a tile inside it is open.
|
||||||
|
const moreActive = moreOpen || (!!openPanel && panelFromMore)
|
||||||
|
const hiddenTitles = sections?.filter(section => !section.hidden).slice(rowCount).map(section => section.title)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WindowContext.Provider value={context}>
|
<WindowContext.Provider value={context}>
|
||||||
@@ -213,6 +380,7 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
className={`hm-sw-home-menu${className ? ` ${className}` : ""}`}
|
className={`hm-sw-home-menu${className ? ` ${className}` : ""}`}
|
||||||
data-accent={resolvedAccent}
|
data-accent={resolvedAccent}
|
||||||
data-panel-open={openPanel ? "true" : undefined}
|
data-panel-open={openPanel ? "true" : undefined}
|
||||||
|
data-overflow={overflowCount > 0 || undefined}
|
||||||
style={{ "--hm-sw-home-menu-title-art": `url("${titleBadge}")`, ...style } as CSSProperties}
|
style={{ "--hm-sw-home-menu-title-art": `url("${titleBadge}")`, ...style } as CSSProperties}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -221,19 +389,65 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
|
|||||||
>
|
>
|
||||||
{closeOnEscape && <SWHomeMenuEscapeEntry onEscape={escape} raiseRef={raiseRef} />}
|
{closeOnEscape && <SWHomeMenuEscapeEntry onEscape={escape} raiseRef={raiseRef} />}
|
||||||
<div className="hm-sw-home-menu__sections">
|
<div className="hm-sw-home-menu__sections">
|
||||||
{sections
|
{nodes.slice(0, rowCount)}
|
||||||
? sections.filter(section => !section.hidden).map(section => (
|
{overflowCount > 0 && (
|
||||||
<SWHomeMenuSection key={section.id} section={section} title={section.title}>
|
// The existing section + tile parts (badge, group, Hover / Variant3); only the ⋯ icon is new.
|
||||||
{section.items.filter(item => !item.hidden).map(item => <SWHomeMenuItem key={item.id} item={item} />)}
|
<SWHomeMenuSection title={moreLabel} className="hm-sw-home-menu__more">
|
||||||
</SWHomeMenuSection>
|
<button ref={moreButtonRef} type="button" className="hm-sw-home-menu__item" data-active={moreActive ? "true" : undefined}
|
||||||
))
|
aria-expanded={moreOpen} aria-controls={moreId} aria-label={`${moreLabel}: 그룹 ${overflowCount}개`}
|
||||||
: children}
|
title={hiddenTitles?.join(", ") ?? moreLabel} onClick={() => moreOpen ? setMoreOpen(false) : openMore()}>
|
||||||
|
<span className="hm-sw-home-menu__icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="4" viewBox="0 0 16 4" focusable="false">
|
||||||
|
<circle cx="2" cy="2" r="1.6" fill="currentColor" />
|
||||||
|
<circle cx="8" cy="2" r="1.6" fill="currentColor" />
|
||||||
|
<circle cx="14" cy="2" r="1.6" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="hm-sw-home-menu__label">{`${overflowCount}개 그룹`}</span>
|
||||||
|
</button>
|
||||||
|
</SWHomeMenuSection>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{overflowCount > 0 && (
|
||||||
|
// Always laid out (positioned before it shows), visible only while open. The moved sections draw as text
|
||||||
|
// groups (title + rows) with the same items: selection, Active, panels and callbacks are unchanged.
|
||||||
|
// A row with a sub panel (최근파일) closes the dropdown; its panel opens under More.
|
||||||
|
<div ref={morePanelRef} className="hm-sw-home-menu__more-panel" id={moreId} role="group" aria-label={moreLabel}
|
||||||
|
data-open={moreOpen || undefined} style={{ left: moreLeft }}
|
||||||
|
onClick={event => {
|
||||||
|
if (!(event.target as Element).closest(".hm-sw-home-menu__text-item[aria-expanded]")) return
|
||||||
|
setMoreOpen(false)
|
||||||
|
moreButtonRef.current?.focus()
|
||||||
|
}}>
|
||||||
|
<PresentationContext.Provider value="text">{nodes.slice(rowCount)}</PresentationContext.Provider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{openPanel && (
|
||||||
|
// The accent continues over the panel's top edge where it reaches past the menu (outline accent only).
|
||||||
|
resolvedAccent === "outline" && joint.map(part => (
|
||||||
|
<span key={part.left} className="hm-sw-home-menu__joint" aria-hidden="true" style={{ left: part.left, width: part.width }} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
{openPanel && (
|
{openPanel && (
|
||||||
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
|
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
|
||||||
{openPanel.panel}
|
{openPanel.panel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Natural tile widths for the measured split: every section and the More section as invisible, inert tiles
|
||||||
|
(no ids, no panel registration). Zero-size clip, so it adds no scroll area. */}
|
||||||
|
<div className="hm-sw-home-menu__measure" aria-hidden="true" inert>
|
||||||
|
<PresentationContext.Provider value="measure">
|
||||||
|
<div className="hm-sw-home-menu__measure-row">{nodes}</div>
|
||||||
|
<div className="hm-sw-home-menu__measure-row">
|
||||||
|
<SWHomeMenuSection title={moreLabel} className="hm-sw-home-menu__more">
|
||||||
|
<button type="button" className="hm-sw-home-menu__item" tabIndex={-1}>
|
||||||
|
<span className="hm-sw-home-menu__icon" />
|
||||||
|
<span className="hm-sw-home-menu__label">{`${nodes.length}개 그룹`}</span>
|
||||||
|
</button>
|
||||||
|
</SWHomeMenuSection>
|
||||||
|
</div>
|
||||||
|
</PresentationContext.Provider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</WindowContext.Provider>
|
</WindowContext.Provider>
|
||||||
)
|
)
|
||||||
@@ -250,6 +464,15 @@ export interface SWHomeMenuSectionProps {
|
|||||||
/** Figma section (e.g. Frame 2408): title badge + tile group. Its width follows the number of tiles. */
|
/** Figma section (e.g. Frame 2408): title badge + tile group. Its width follows the number of tiles. */
|
||||||
export function SWHomeMenuSection({ title, children, section, className }: SWHomeMenuSectionProps) {
|
export function SWHomeMenuSection({ title, children, section, className }: SWHomeMenuSectionProps) {
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
|
// Inside the More dropdown: the section title over a text list of its items (not in Figma, 2026-10-19).
|
||||||
|
if (useContext(PresentationContext) === "text") return (
|
||||||
|
<SectionContext.Provider value={section}>
|
||||||
|
<div className={`hm-sw-home-menu__text-group${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
|
||||||
|
<span className="hm-sw-home-menu__text-title" id={titleId}>{title}</span>
|
||||||
|
<div className="hm-sw-home-menu__text-list">{children}</div>
|
||||||
|
</div>
|
||||||
|
</SectionContext.Provider>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<SectionContext.Provider value={section}>
|
<SectionContext.Provider value={section}>
|
||||||
<div className={`hm-sw-home-menu__section${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
|
<div className={`hm-sw-home-menu__section${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
|
||||||
@@ -277,25 +500,32 @@ export interface SWHomeMenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButto
|
|||||||
onSelect?: (event: SWHomeMenuSelectEvent) => void
|
onSelect?: (event: SWHomeMenuSelectEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (Active, same look as Variant2). */
|
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (pressed, or Active for a panel tile; same look as Variant2). */
|
||||||
export function SWHomeMenuItem({
|
export function SWHomeMenuItem({
|
||||||
item: itemProp, itemId, label, icon, hoverIcon, toggle, panel, panelLabel, active: activeProp, defaultActive = false,
|
item: itemProp, itemId, label, icon, hoverIcon, toggle, panel, panelLabel, active: activeProp, defaultActive = false,
|
||||||
onActiveChange, onSelect, disabled, title, className, onClick, ...rest
|
onActiveChange, onSelect, disabled, title, className, onClick, ...rest
|
||||||
}: SWHomeMenuItemProps) {
|
}: SWHomeMenuItemProps) {
|
||||||
const host = useContext(WindowContext)
|
const host = useContext(WindowContext)
|
||||||
const section = useContext(SectionContext)
|
const section = useContext(SectionContext)
|
||||||
|
const presentation = useContext(PresentationContext)
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
const item: SWHomeMenuItemData = itemProp ?? {
|
const item: SWHomeMenuItemData = itemProp ?? {
|
||||||
id: itemId ?? generatedId, label: label ?? "", icon, hoverIcon, toggle, panel, panelLabel, disabled, title, onSelect,
|
id: itemId ?? generatedId, label: label ?? "", icon, hoverIcon, toggle, panel, panelLabel, disabled, title, onSelect,
|
||||||
"aria-label": rest["aria-label"],
|
"aria-label": rest["aria-label"],
|
||||||
}
|
}
|
||||||
const [ownActive, setOwnActive] = useState(defaultActive)
|
const [ownActive, setOwnActive] = useState(defaultActive)
|
||||||
const toggles = item.toggle !== false
|
// Toggle by default only for a tile with a sub panel (최근파일: Active while its panel is open) and, outside a menu,
|
||||||
const active = toggles && (host ? host.isActive(item.id) : activeProp ?? ownActive)
|
// for a tile given active / defaultActive / onActiveChange. Every other tile is an action button: Hover and the
|
||||||
|
// pressed moment (:active) only, nothing stays on after the click (2026-10-19 user decision).
|
||||||
|
const toggles = item.toggle ?? (host ? item.panel != null : activeProp !== undefined || defaultActive || onActiveChange !== undefined)
|
||||||
|
// Inside a menu, host-given activeIds still draw Variant3 (display only, e.g. the Figma Variant3 set); a click on an
|
||||||
|
// action tile never changes them.
|
||||||
|
const active = host ? host.isActive(item.id) : toggles && (activeProp ?? ownActive)
|
||||||
const isDisabled = item.disabled ?? disabled
|
const isDisabled = item.disabled ?? disabled
|
||||||
const hasPanel = host != null && item.panel != null
|
const hasPanel = host != null && item.panel != null
|
||||||
|
|
||||||
const register = host?.registerPanel
|
// The measure copy never registers: the visible tile or text row of the same item does.
|
||||||
|
const register = presentation === "measure" ? undefined : host?.registerPanel
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!register) return
|
if (!register) return
|
||||||
register(item.id, item.panel ?? undefined, item.panelLabel ?? item.label)
|
register(item.id, item.panel ?? undefined, item.panelLabel ?? item.label)
|
||||||
@@ -317,6 +547,31 @@ export function SWHomeMenuItem({
|
|||||||
item.onSelect?.({ item, active: next })
|
item.onSelect?.({ item, active: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (presentation === "measure") return (
|
||||||
|
<button type="button" className={`hm-sw-home-menu__item${className ? ` ${className}` : ""}`} tabIndex={-1} disabled={isDisabled}>
|
||||||
|
{item.icon != null && <span className="hm-sw-home-menu__icon">{item.icon}</span>}
|
||||||
|
<span className="hm-sw-home-menu__label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
// More dropdown row: the label only; Hover / Active / disabled and the click are the tile's (Active = ✓, panel = ›).
|
||||||
|
if (presentation === "text") return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...rest}
|
||||||
|
className={`hm-sw-home-menu__text-item${className ? ` ${className}` : ""}`}
|
||||||
|
data-item-id={item.id}
|
||||||
|
data-active={active ? "true" : undefined}
|
||||||
|
aria-pressed={toggles && !hasPanel ? active : undefined}
|
||||||
|
aria-expanded={hasPanel ? active : undefined}
|
||||||
|
aria-controls={hasPanel && active ? host.panelId : undefined}
|
||||||
|
aria-label={item["aria-label"]}
|
||||||
|
title={item.title ?? item.label}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<span className="hm-sw-home-menu__text-label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useId, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
import { useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||||
import "../../theme/sw-home-menu.css"
|
import "../../theme/sw-home-menu.css"
|
||||||
import { useSWHomeMenuClose } from "./SWHomeMenu"
|
import { useSWHomeMenuClose } from "./SWHomeMenu"
|
||||||
import type { SWFileEntry } from "./types"
|
import type { SWFileEntry } from "./types"
|
||||||
|
|
||||||
|
type PreviewSide = "right" | "left" | "below" | "above"
|
||||||
|
|
||||||
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||||
/** Host-owned list (most recent first). Nothing is read from disk here. */
|
/** Host-owned list (most recent first). Nothing is read from disk here. */
|
||||||
files: readonly SWFileEntry[]
|
files: readonly SWFileEntry[]
|
||||||
@@ -31,16 +33,33 @@ export function SWRecentFiles({
|
|||||||
const closeWindow = useSWHomeMenuClose()
|
const closeWindow = useSWHomeMenuClose()
|
||||||
const listId = useId()
|
const listId = useId()
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [hovered, setHovered] = useState<{ id: string; top: number; rowHeight: number; side: PreviewSide } | null>(null)
|
||||||
const current = hovered ? files.find(file => file.id === hovered.id) : undefined
|
const current = hovered ? files.find(file => file.id === hovered.id) : undefined
|
||||||
const preview = current ? current.preview ?? renderPreview?.(current) : undefined
|
const preview = current ? current.preview ?? renderPreview?.(current) : undefined
|
||||||
|
|
||||||
const show = (file: SWFileEntry | null, row?: HTMLElement) => {
|
const show = (file: SWFileEntry | null, row?: HTMLElement) => {
|
||||||
const list = listRef.current
|
const list = listRef.current
|
||||||
const top = row && list ? row.offsetTop - list.scrollTop + 1 : 0
|
const top = row && list ? row.offsetTop - list.scrollTop + 1 : 0
|
||||||
setHovered(file ? { id: file.id, top } : null)
|
// Beside the list on the right (Figma); on the left when the viewport has no room there (narrow widths, the list
|
||||||
|
// opened from More at the right edge); over the list under the row when neither side has room (e.g. 390px), so
|
||||||
|
// the preview never adds a page scroll and stays readable.
|
||||||
|
const width = list ? parseFloat(getComputedStyle(list).getPropertyValue("--hm-sw-recent-files-preview-width")) || 0 : 0
|
||||||
|
const rect = list?.getBoundingClientRect()
|
||||||
|
const side: PreviewSide = !rect || rect.right + 1 + width <= document.documentElement.clientWidth ? "right"
|
||||||
|
: rect.left - 1 - width >= 0 ? "left" : "below"
|
||||||
|
setHovered(file ? { id: file.id, top, rowHeight: row?.offsetHeight ?? 0, side } : null)
|
||||||
if ((file?.id ?? null) !== (hovered?.id ?? null)) onFileHover?.(file)
|
if ((file?.id ?? null) !== (hovered?.id ?? null)) onFileHover?.(file)
|
||||||
}
|
}
|
||||||
|
// Over the list: under the row, or above it when the viewport has no room below (and there is room above).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const preview = previewRef.current
|
||||||
|
if (hovered?.side !== "below" || !preview) return
|
||||||
|
const rect = preview.getBoundingClientRect()
|
||||||
|
if (rect.bottom > document.documentElement.clientHeight && rect.top - hovered.rowHeight - rect.height >= 0)
|
||||||
|
setHovered({ ...hovered, side: "above" })
|
||||||
|
}, [hovered])
|
||||||
|
|
||||||
const open = (file: SWFileEntry) => {
|
const open = (file: SWFileEntry) => {
|
||||||
if (file.disabled) return
|
if (file.disabled) return
|
||||||
onFileOpen?.(file)
|
onFileOpen?.(file)
|
||||||
@@ -82,7 +101,9 @@ export function SWRecentFiles({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{current && preview != null && (
|
{current && preview != null && (
|
||||||
<div className="hm-sw-recent-files__preview" style={{ top: hovered?.top }} aria-hidden="true">
|
<div ref={previewRef} className="hm-sw-recent-files__preview" data-side={hovered?.side} aria-hidden="true"
|
||||||
|
style={{ top: hovered?.side === "below" ? hovered.top - 1 + hovered.rowHeight
|
||||||
|
: hovered?.side === "above" ? hovered.top - 1 : hovered?.top }}>
|
||||||
<span className="hm-sw-recent-files__preview-title">{current.previewTitle ?? current.path ?? current.name}</span>
|
<span className="hm-sw-recent-files__preview-title">{current.previewTitle ?? current.path ?? current.name}</span>
|
||||||
<div className="hm-sw-recent-files__preview-body">{preview}</div>
|
<div className="hm-sw-recent-files__preview-body">{preview}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ export interface SWHomeMenuItemData {
|
|||||||
/** Replaces `icon` on Hover and Active, for fixed-color image pairs (Figma default / hover SVG). Same name as SWToolButton. */
|
/** Replaces `icon` on Hover and Active, for fixed-color image pairs (Figma default / hover SVG). Same name as SWToolButton. */
|
||||||
hoverIcon?: ReactNode
|
hoverIcon?: ReactNode
|
||||||
/**
|
/**
|
||||||
* true (default): a click toggles Active (Figma Default → Variant2 on hover → Variant3 on click → Default).
|
* Default: true only for an item with a `panel` (최근파일: Active = Variant3 while its panel is open, off when it
|
||||||
* false: a plain action button without Active state.
|
* closes). Every other item is an action button (2026-10-19 user decision): Hover (Variant2) and the pressed moment
|
||||||
|
* (Variant3 while held) only; nothing stays on after the click and no ✓ in the More text menu. Its `onSelect` /
|
||||||
|
* `onItemSelect` still run (active = false). true: opt in to a toggle that keeps Active (Default → Variant3 → Default).
|
||||||
*/
|
*/
|
||||||
toggle?: boolean
|
toggle?: boolean
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +46,7 @@ export interface SWHomeMenuSectionData {
|
|||||||
export interface SWHomeMenuSelectEvent {
|
export interface SWHomeMenuSelectEvent {
|
||||||
item: SWHomeMenuItemData
|
item: SWHomeMenuItemData
|
||||||
section?: SWHomeMenuSectionData
|
section?: SWHomeMenuSectionData
|
||||||
/** Active state after this click (always false for `toggle: false` items). */
|
/** Active state after this click (always false for action items, i.e. not toggling). */
|
||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +68,11 @@ export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "o
|
|||||||
/** Uncontrolled initial state. Default false: the menu is hidden until the logo is clicked. */
|
/** Uncontrolled initial state. Default false: the menu is hidden until the logo is clicked. */
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
onOpenChange?: (open: boolean, reason: SWHomeMenuCloseReason) => void
|
onOpenChange?: (open: boolean, reason: SWHomeMenuCloseReason) => void
|
||||||
/** Controlled Active (Figma Variant3) tile ids. */
|
/** Controlled Active (Figma Variant3) tile ids. A click changes them only for toggling items (by default the panel tile, 최근파일). */
|
||||||
activeIds?: readonly string[]
|
activeIds?: readonly string[]
|
||||||
defaultActiveIds?: readonly string[]
|
defaultActiveIds?: readonly string[]
|
||||||
onActiveChange?: (activeIds: string[], event: SWHomeMenuSelectEvent) => void
|
onActiveChange?: (activeIds: string[], event: SWHomeMenuSelectEvent) => void
|
||||||
/** `multiple` (default, each tile toggles on its own as in Figma) or `single` (one Active tile at a time). */
|
/** Toggling items: `multiple` (default, each toggles on its own) or `single` (one Active at a time). */
|
||||||
selection?: "multiple" | "single"
|
selection?: "multiple" | "single"
|
||||||
/** Every tile click, after the item's own `onSelect`. */
|
/** Every tile click, after the item's own `onSelect`. */
|
||||||
onItemSelect?: (event: SWHomeMenuSelectEvent) => void
|
onItemSelect?: (event: SWHomeMenuSelectEvent) => void
|
||||||
@@ -87,6 +89,12 @@ export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "o
|
|||||||
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
|
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
|
||||||
returnFocusRef?: RefObject<HTMLElement | null>
|
returnFocusRef?: RefObject<HTMLElement | null>
|
||||||
accent?: SWHomeMenuAccent
|
accent?: SWHomeMenuAccent
|
||||||
|
/**
|
||||||
|
* Not in Figma (1920px only): when the sections do not fit the width the host gives the menu, the trailing ones
|
||||||
|
* move into a More section (⋯ tile); its dropdown lists them as a text menu (section title + item rows, same
|
||||||
|
* selection, Active, panels and callbacks as the tiles). They return when there is room again. Badge text and accessible name of that section. Default "더보기".
|
||||||
|
*/
|
||||||
|
moreLabel?: string
|
||||||
"aria-label"?: string
|
"aria-label"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,22 @@
|
|||||||
/* Bottom accent: 26:10591 per-section strip (top → bottom) / 662:67267 2px outline (bottom → top). */
|
/* Bottom accent: 26:10591 per-section strip (top → bottom) / 662:67267 2px outline (bottom → top). */
|
||||||
--hm-sw-home-menu-accent-strip: linear-gradient(180deg, #5f4b0f -6.4%, #ff5100 96.8%);
|
--hm-sw-home-menu-accent-strip: linear-gradient(180deg, #5f4b0f -6.4%, #ff5100 96.8%);
|
||||||
--hm-sw-home-menu-accent-outline: linear-gradient(0deg, #ff5100 0%, #5f4b0f 76.7%);
|
--hm-sw-home-menu-accent-outline: linear-gradient(0deg, #ff5100 0%, #5f4b0f 76.7%);
|
||||||
|
--hm-sw-home-menu-accent-outline-width: 2px;
|
||||||
|
--hm-sw-home-menu-accent-outline-height: 30px;
|
||||||
|
/* More dropdown (not in Figma): a text menu in the SW brown dropdown family (header/menu/레이어 26:10940, rows
|
||||||
|
header/dropdown/brown/row 26:10687): surface, border, radius, shadow, 30px rows, row hover; group title in the
|
||||||
|
최근파일 path color, groups split by the layer footer divider. */
|
||||||
|
--hm-sw-home-menu-more-background: var(--hm-sw-header-layer-menu-background);
|
||||||
|
--hm-sw-home-menu-more-border-color: var(--hm-sw-header-layer-menu-border-color);
|
||||||
|
--hm-sw-home-menu-more-radius: var(--hm-sw-header-layer-menu-radius);
|
||||||
|
--hm-sw-home-menu-more-shadow: var(--hm-sw-header-layer-menu-shadow);
|
||||||
|
--hm-sw-home-menu-more-offset: 2px;
|
||||||
|
--hm-sw-home-menu-more-min-width: 180px;
|
||||||
|
--hm-sw-home-menu-more-divider-color: var(--hm-sw-header-layer-footer-border-color);
|
||||||
|
--hm-sw-home-menu-more-title-color: var(--hm-sw-recent-files-path-color);
|
||||||
|
--hm-sw-home-menu-more-row-height: 30px;
|
||||||
|
--hm-sw-home-menu-more-row-hover-background: var(--hm-sw-header-layer-row-hover-background);
|
||||||
|
--hm-sw-home-menu-more-row-hover-color: var(--hm-sw-header-layer-row-hover-color);
|
||||||
|
|
||||||
/* Optional sub panel (Figma 최근파일 662:67331 / 파일출력 2뎁스 662:67471). */
|
/* Optional sub panel (Figma 최근파일 662:67331 / 파일출력 2뎁스 662:67471). */
|
||||||
--hm-sw-recent-files-width: 354px;
|
--hm-sw-recent-files-width: 354px;
|
||||||
@@ -49,10 +65,108 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
/* 2px of the accent strip shows left, right and under the sections (menu 1305 x 77 in Figma). */
|
/* 2px of the accent strip shows left, right and under the sections (menu 1305 x 77 in Figma). */
|
||||||
padding: 0 2px 2px;
|
padding: 0 2px 2px;
|
||||||
/* Not in Figma (1920px only): narrower hosts scroll the menu horizontally, like the property bar. */
|
/* Not in Figma (1920px only): narrower hosts get the measured More section instead of a scroller (SWHomeMenu).
|
||||||
overflow-x: auto;
|
The clip only keeps the badge shadow and the strip inside the menu, as before; nothing is cut off. */
|
||||||
overflow-y: hidden;
|
overflow: hidden;
|
||||||
scrollbar-width: thin;
|
}
|
||||||
|
/* Measurement pass (one synchronous read, removed before paint) and overflow: the menu takes all the room of its host
|
||||||
|
(clamped by max-width), so with More it still covers the whole area SWHeader gives it, as the full menu does. */
|
||||||
|
.hm-sw-home-menu:is([data-measuring], [data-overflow]) { width: 100000px; }
|
||||||
|
/* More fills the rest of the row with the section surface (the menu background stays as it is); only its badge and
|
||||||
|
⋯ tile sit right after the last shown section (2026-10-19 user correction: start, not end). */
|
||||||
|
.hm-sw-home-menu__section.hm-sw-home-menu__more { flex: 1 0 auto; align-items: flex-start; }
|
||||||
|
|
||||||
|
/* Natural tile widths (SWHomeMenu measure copy): laid out, invisible, zero-size clip (no scroll area, no hit area). */
|
||||||
|
.hm-sw-home-menu__measure {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hm-sw-home-menu__measure-row { display: flex; align-items: flex-start; width: max-content; }
|
||||||
|
|
||||||
|
/* More (not in Figma): the section + tile parts above with a ⋯ icon. Its dropdown sits under the ⋯ tile, left edges aligned
|
||||||
|
(left set from the DOM): a text menu of the moved sections (title + rows). Above the sub panel; laid out while
|
||||||
|
closed so it is positioned before it shows. */
|
||||||
|
.hm-sw-home-menu__more-panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: calc(100% + var(--hm-sw-home-menu-more-offset));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: var(--hm-sw-home-menu-more-min-width);
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 1px solid var(--hm-sw-home-menu-more-border-color);
|
||||||
|
border-radius: var(--hm-sw-home-menu-more-radius);
|
||||||
|
background: var(--hm-sw-home-menu-more-background);
|
||||||
|
box-shadow: var(--hm-sw-home-menu-more-shadow);
|
||||||
|
color: var(--hm-sw-home-menu-item-color);
|
||||||
|
}
|
||||||
|
.hm-sw-home-menu__more-panel:not([data-open]) { visibility: hidden; pointer-events: none; }
|
||||||
|
.hm-sw-home-menu__text-group { display: flex; flex-direction: column; padding: 4px 0; }
|
||||||
|
.hm-sw-home-menu__text-group + .hm-sw-home-menu__text-group { border-top: 1px solid var(--hm-sw-home-menu-more-divider-color); }
|
||||||
|
.hm-sw-home-menu__text-title {
|
||||||
|
padding: 4px 12px;
|
||||||
|
overflow: clip;
|
||||||
|
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
|
||||||
|
color: var(--hm-sw-home-menu-more-title-color);
|
||||||
|
font-size: var(--hm-sw-header-label-size);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-shadow: var(--hm-sw-header-label-outline);
|
||||||
|
}
|
||||||
|
.hm-sw-home-menu__text-list { display: flex; flex-direction: column; }
|
||||||
|
/* Row = header/dropdown/brown/row: 30px, 12px in, 14px 700 with the menu text outline. */
|
||||||
|
.hm-sw-home-menu__text-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: var(--hm-sw-home-menu-more-row-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--hm-sw-home-menu-item-color);
|
||||||
|
font-size: var(--hm-sw-header-font-size);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.hm-sw-home-menu__text-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: clip;
|
||||||
|
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-shadow: var(--hm-sw-header-text-outline);
|
||||||
|
}
|
||||||
|
/* Indicator column: › for a row that opens a sub panel (최근파일); ✓ only for an opt-in toggle row (toggle: true). */
|
||||||
|
.hm-sw-home-menu__text-item::after { content: ""; flex: 0 0 14px; width: 14px; text-align: center; }
|
||||||
|
.hm-sw-home-menu__text-item[aria-pressed="true"]::after { content: "✓"; }
|
||||||
|
.hm-sw-home-menu__text-item[aria-expanded]::after { content: "›"; }
|
||||||
|
/* Hover = pressed = Active (the tiles' Variant2 = Variant3, the brown rows' hover = selected): brown row, orange
|
||||||
|
text. Action rows keep nothing after the click; only the 최근파일 row stays Active while its list is open. */
|
||||||
|
.hm-sw-home-menu__text-item:is(:hover, :active, [data-active="true"]):not(:disabled) {
|
||||||
|
background: var(--hm-sw-home-menu-more-row-hover-background);
|
||||||
|
color: var(--hm-sw-home-menu-more-row-hover-color);
|
||||||
|
}
|
||||||
|
/* Accent joint (narrow widths, 2026-10-19): where the sub panel reaches past the menu, the outline's bottom band runs
|
||||||
|
on over the panel's top edge — same 2px, same gradient slice (the outline gradient sized to its box, bottom), so
|
||||||
|
the menu's bottom line and the list's side borders meet without a gap. Left / width from the measured positions. */
|
||||||
|
.hm-sw-home-menu__joint {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: calc(100% - var(--hm-sw-home-menu-accent-outline-width));
|
||||||
|
height: var(--hm-sw-home-menu-accent-outline-width);
|
||||||
|
background: var(--hm-sw-home-menu-accent-outline) left bottom / 100% var(--hm-sw-home-menu-accent-outline-height) no-repeat;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* Sub panel under the menu, at its tile (position set from the DOM). */
|
/* Sub panel under the menu, at its tile (position set from the DOM). */
|
||||||
.hm-sw-home-menu__panel { position: absolute; z-index: 1; top: 100%; }
|
.hm-sw-home-menu__panel { position: absolute; z-index: 1; top: 100%; }
|
||||||
@@ -104,8 +218,8 @@
|
|||||||
top: 47px;
|
top: 47px;
|
||||||
right: 0;
|
right: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 30px;
|
height: var(--hm-sw-home-menu-accent-outline-height);
|
||||||
padding: 2px;
|
padding: var(--hm-sw-home-menu-accent-outline-width);
|
||||||
border-radius: 0 0 3px 3px;
|
border-radius: 0 0 3px 3px;
|
||||||
background: var(--hm-sw-home-menu-accent-outline);
|
background: var(--hm-sw-home-menu-accent-outline);
|
||||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
@@ -238,16 +352,17 @@
|
|||||||
text-shadow: var(--hm-sw-header-text-outline);
|
text-shadow: var(--hm-sw-header-text-outline);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Variant2 (hover) = Variant3 (active): orange stroke, #FF5C00 icon and label, art 1px up-left. */
|
/* Variant2 (hover) = Variant3 (pressed while held; stays only as Active of a panel tile / More): orange stroke,
|
||||||
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"])::after { opacity: 1; }
|
#FF5C00 icon and label, art 1px up-left. Action tiles keep nothing after the click (2026-10-19). */
|
||||||
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) {
|
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"])::after { opacity: 1; }
|
||||||
|
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) {
|
||||||
--hm-sw-home-menu-icon-color: var(--hm-sw-home-menu-item-accent-color);
|
--hm-sw-home-menu-icon-color: var(--hm-sw-home-menu-item-accent-color);
|
||||||
color: var(--hm-sw-home-menu-item-accent-color);
|
color: var(--hm-sw-home-menu-item-accent-color);
|
||||||
}
|
}
|
||||||
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
|
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
|
||||||
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
|
.hm-sw-home-menu__item:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
|
||||||
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
|
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
|
||||||
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
|
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), :active:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
|
||||||
|
|
||||||
/* SWRecentFiles — Figma 최근파일 662:67331 (354 x 750: rows 50px) + 2-depth preview 662:67471 (310 x 270). */
|
/* SWRecentFiles — Figma 최근파일 662:67331 (354 x 750: rows 50px) + 2-depth preview 662:67471 (310 x 270). */
|
||||||
.hm-sw-recent-files { position: relative; width: var(--hm-sw-recent-files-width); }
|
.hm-sw-recent-files { position: relative; width: var(--hm-sw-recent-files-width); }
|
||||||
@@ -321,6 +436,26 @@
|
|||||||
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.5));
|
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.5));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
/* No room on the right (narrow widths): the same preview mirrored on the left of the list. */
|
||||||
|
.hm-sw-recent-files__preview[data-side="left"] {
|
||||||
|
right: calc(100% + 1px);
|
||||||
|
left: auto;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 1px solid var(--hm-sw-recent-files-preview-background);
|
||||||
|
border-radius: 3px 0 0 4px;
|
||||||
|
box-shadow: inset -2px 0 2.5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.hm-sw-recent-files__preview[data-side="left"] .hm-sw-recent-files__preview-body { border-radius: 0 0 0 4px; }
|
||||||
|
/* No room on either side (e.g. 390px): the same preview over the list, right under the row (above it when the
|
||||||
|
viewport has no room below). Not in Figma (2026-10-19 사용자 확정). */
|
||||||
|
.hm-sw-recent-files__preview:is([data-side="below"], [data-side="above"]) {
|
||||||
|
left: 1px;
|
||||||
|
border-left: 1px solid var(--hm-sw-recent-files-preview-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.hm-sw-recent-files__preview[data-side="above"] { transform: translateY(-100%); }
|
||||||
|
.hm-sw-recent-files__preview:is([data-side="below"], [data-side="above"]) .hm-sw-recent-files__preview-body { border-radius: 0 0 4px 4px; }
|
||||||
.hm-sw-recent-files__preview-title {
|
.hm-sw-recent-files__preview-title {
|
||||||
width: 294px;
|
width: 294px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
/* Figma header/btn_home (26:10655) "hover" stroke: 1px, top -> bottom. */
|
/* Figma header/btn_home (26:10655) "hover" stroke: 1px, top -> bottom. */
|
||||||
--hm-sw-header-hover-border-gradient: linear-gradient(180deg, #a67d03 0%, #f2cc0f 16.41%, #fadd4f 37.19%, #f2cc0f 62.03%, #926e03 87.88%);
|
--hm-sw-header-hover-border-gradient: linear-gradient(180deg, #a67d03 0%, #f2cc0f 16.41%, #fadd4f 37.19%, #f2cc0f 62.03%, #926e03 87.88%);
|
||||||
--hm-sw-header-hover-border-width: 1px;
|
--hm-sw-header-hover-border-width: 1px;
|
||||||
|
/* Figma 662:67267 "home on" (logo menu open): header border-top / border-bottom, 2px, full width. */
|
||||||
|
--hm-sw-header-home-on-line-color: #5f4b0f;
|
||||||
|
--hm-sw-header-home-on-line-width: 2px;
|
||||||
|
|
||||||
/* Figma header/document_tit (26:10662) document dropdown */
|
/* Figma header/document_tit (26:10662) document dropdown */
|
||||||
--hm-sw-header-document-menu-width: 310px;
|
--hm-sw-header-document-menu-width: 310px;
|
||||||
@@ -1883,7 +1886,44 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
|
|||||||
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
|
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
|
||||||
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
|
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
|
||||||
|
|
||||||
|
/* Measured More (SWHeader, 2026-10-19): on top of the breakpoints, parts move into More while the object area has no
|
||||||
|
room for them and return when it has. data-measuring is set for one synchronous layout read and removed before
|
||||||
|
paint: nothing in the object area shrinks, grows or takes free space through an auto margin, so the in-flow content
|
||||||
|
shows the room it really needs. A configured file name counts with the smallest default file-name width (96px).
|
||||||
|
!important: measurement only, it must win over every tier and representative-slot rule above. */
|
||||||
|
.hm-sw-header[data-measuring] :is(.hm-sw-header__object-area, .hm-sw-header__fields, .hm-sw-header__control) > * { flex-grow: 0 !important; flex-shrink: 0 !important; }
|
||||||
|
.hm-sw-header[data-measuring] .hm-sw-header__properties,
|
||||||
|
.hm-sw-header[data-measuring] .hm-sw-header__object-area[data-squeezed~="properties"] > .hm-sw-header__more { margin-left: 0 !important; }
|
||||||
|
@container hm-sw-header (max-width: 1079.98px) {
|
||||||
|
.hm-sw-header[data-measuring] .hm-sw-header__more { margin-left: 0 !important; }
|
||||||
|
}
|
||||||
|
.hm-sw-header[data-measuring] .hm-sw-header__control[data-control="document"] { flex: 0 0 96px !important; min-width: 0 !important; }
|
||||||
|
/* Measured parts take the place the breakpoints give them: without 그리기모드 the fields start 4px in (as < 1180),
|
||||||
|
without the property block More sits at the end of the object area (as < 1080). */
|
||||||
|
.hm-sw-header__object-area[data-squeezed~="drawingMode"]:not([data-configured]) > .hm-sw-header__fields { padding-left: 4px; }
|
||||||
|
.hm-sw-header__object-area[data-squeezed~="properties"] > .hm-sw-header__more { margin-left: auto; margin-right: 4px; }
|
||||||
|
|
||||||
/* Logo (home) menu layer (SWHeader homeMenu, 2026-10-19). Top of the header, left / max-width measured from the
|
/* Logo (home) menu layer (SWHeader homeMenu, 2026-10-19). Top of the header, left / max-width measured from the
|
||||||
object area (−2px) to the window area. Above the property bar anchor (2), under the dropdowns (30) and the
|
object area (−2px) to the window area. Above the property bar anchor (2), under the dropdowns (30) and the
|
||||||
property window (40). The logo, the file name and the window area stay visible beside it. */
|
property window (40). The logo, the file name and the window area stay visible beside it. */
|
||||||
.hm-sw-header__home-menu { position: absolute; top: 0; z-index: 20; }
|
.hm-sw-header__home-menu { position: absolute; top: 0; z-index: 20; }
|
||||||
|
|
||||||
|
/* Home On (Figma 662:67267 btn_home + border-top / border-bottom, 2026-10-19): only while the logo menu is open.
|
||||||
|
The logo keeps the yellow hover border (the Home On asset draws the same 1px inner gradient border) and the header
|
||||||
|
gets the 2px #5F4B0F lines across its full width. Figma layer order: border-bottom under the menu (20) over the
|
||||||
|
file name / window area, border-top over the menu, the logo over both. Lines are overlays: no size or position
|
||||||
|
changes, and closing the menu removes them. SW normal mode only (SWViewerHeader has no logo menu). */
|
||||||
|
.hm-sw-header[data-home-menu-open] .hm-sw-header__home-bg-hover { opacity: 1; }
|
||||||
|
.hm-sw-header[data-home-menu-open] > .hm-sw-header__bar > .hm-sw-header__home { z-index: 22; }
|
||||||
|
.hm-sw-header[data-home-menu-open]::before,
|
||||||
|
.hm-sw-header[data-home-menu-open]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--hm-sw-header-home-on-line-width);
|
||||||
|
background: var(--hm-sw-header-home-on-line-color);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hm-sw-header[data-home-menu-open]::before { top: 0; z-index: 21; }
|
||||||
|
.hm-sw-header[data-home-menu-open]::after { top: calc(var(--hm-sw-header-bar-height) - var(--hm-sw-header-home-on-line-width)); z-index: 1; }
|
||||||
@@ -21,7 +21,8 @@ page.on('pageerror', e => report.errors.push(e.message))
|
|||||||
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
|
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, ok || data === undefined ? '' : JSON.stringify(data)) }
|
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
|
||||||
const goto = async id => {
|
const goto = async id => {
|
||||||
await page.goto(`${base}/iframe.html?id=sw-home-menu--${id}&viewMode=story`)
|
// 2026-10-19: the Home Menu Stories moved under SW/Header (sw-home-menu--<id> -> sw-header--home-menu-<id>).
|
||||||
|
await page.goto(`${base}/iframe.html?id=sw-header--home-menu-${id}&viewMode=story`)
|
||||||
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
|
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
|
||||||
await page.evaluate(() => document.fonts.ready)
|
await page.evaluate(() => document.fonts.ready)
|
||||||
await page.mouse.move(1, 1)
|
await page.mouse.move(1, 1)
|
||||||
@@ -36,7 +37,7 @@ const activeIsHome = () => page.evaluate(() => document.activeElement?.classList
|
|||||||
const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8'))
|
const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8'))
|
||||||
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW/')))].sort()
|
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW/')))].sort()
|
||||||
report.data.titles = titles
|
report.data.titles = titles
|
||||||
check('Storybook SW titles: Header, Popup, Home Menu, Viewer (SW/Tool removed 2026-10-19); no SW/Header/Options', ['SW/Header', 'SW/Popup', 'SW/Home Menu', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.some(t => t.startsWith('SW/Header/')), titles)
|
check('Storybook SW titles: Header, Popup, Viewer (SW/Tool removed, SW/Home Menu merged into SW/Header 2026-10-19); no SW/Header/Options; sw-header--home-menu listed', ['SW/Header', 'SW/Popup', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.includes('SW/Home Menu') && !titles.some(t => t.startsWith('SW/Header/')) && !!index.entries['sw-header--home-menu'], titles)
|
||||||
|
|
||||||
// 2. Figma 26:10591 geometry at 1920 through SWHeader's measured placement
|
// 2. Figma 26:10591 geometry at 1920 through SWHeader's measured placement
|
||||||
await goto('capture-default')
|
await goto('capture-default')
|
||||||
@@ -73,9 +74,10 @@ await page.waitForTimeout(50)
|
|||||||
const hover = await tile.evaluate(e => getComputedStyle(e.querySelector('.hm-sw-home-menu__label')).color)
|
const hover = await tile.evaluate(e => getComputedStyle(e.querySelector('.hm-sw-home-menu__label')).color)
|
||||||
check('tile Hover: label #FF5C00', hover === 'rgb(255, 92, 0)', hover)
|
check('tile Hover: label #FF5C00', hover === 'rgb(255, 92, 0)', hover)
|
||||||
await tile.click()
|
await tile.click()
|
||||||
const pressedOn = await tile.getAttribute('aria-pressed')
|
await page.mouse.move(1, 1)
|
||||||
await tile.click()
|
// 2026-10-19: tiles without a panel are action buttons — the click runs (onItemSelect) and nothing stays on.
|
||||||
check('tile click → Active (aria-pressed true), re-click → Default', pressedOn === 'true' && await tile.getAttribute('aria-pressed') === 'false', pressedOn)
|
const afterClick = await tile.evaluate(e => ({ pressed: e.getAttribute('aria-pressed'), active: e.dataset.active ?? null, ring: getComputedStyle(e, '::after').opacity }))
|
||||||
|
check('action tile click: onItemSelect, no Active / aria-pressed / ring after the click', afterClick.pressed === null && afterClick.active === null && afterClick.ring === '0' && (await log()).includes('onItemSelect(working / save, active=false)'), afterClick)
|
||||||
await home().click()
|
await home().click()
|
||||||
const text = await log()
|
const text = await log()
|
||||||
check('logo re-click closes; onHomeClick never called while homeMenu is set', await menu().count() === 0 && !text.includes('onHomeClick') && text.includes('onHomeMenuOpenChange(false, home)'), text)
|
check('logo re-click closes; onHomeClick never called while homeMenu is set', await menu().count() === 0 && !text.includes('onHomeClick') && text.includes('onHomeMenuOpenChange(false, home)'), text)
|
||||||
@@ -136,7 +138,7 @@ await page.keyboard.press('Escape')
|
|||||||
const step5 = { menu: await menu().count(), popup: await popup.count(), focusHome: await activeIsHome() }
|
const step5 = { menu: await menu().count(), popup: await popup.count(), focusHome: await activeIsHome() }
|
||||||
check('Escape inside the menu closes only the menu (popup stays), focus → logo', step5.menu === 0 && step5.popup === 1 && step5.focusHome, step5)
|
check('Escape inside the menu closes only the menu (popup stays), focus → logo', step5.menu === 0 && step5.popup === 1 && step5.focusHome, step5)
|
||||||
|
|
||||||
// 7. Responsive placement (measured), internal scroll reported
|
// 7. Responsive placement (measured); narrow widths use the measured More instead of a scroller (2026-10-19)
|
||||||
const responsive = []
|
const responsive = []
|
||||||
for (const width of [1920, 1440, 1080, 768, 520, 390]) {
|
for (const width of [1920, 1440, 1080, 768, 520, 390]) {
|
||||||
await page.setViewportSize({ width, height: 900 })
|
await page.setViewportSize({ width, height: 900 })
|
||||||
@@ -145,7 +147,7 @@ for (const width of [1920, 1440, 1080, 768, 520, 390]) {
|
|||||||
const r = s => document.querySelector(s)?.getBoundingClientRect()
|
const r = s => document.querySelector(s)?.getBoundingClientRect()
|
||||||
const header = r('.hm-sw-header'), area = r('.hm-sw-header__object-area'), win = r('.hm-sw-header__window'), m = r('.hm-sw-home-menu'), homeBox = r('.hm-sw-header__home'), doc = r('.hm-sw-header__document')
|
const header = r('.hm-sw-header'), area = r('.hm-sw-header__object-area'), win = r('.hm-sw-header__window'), m = r('.hm-sw-home-menu'), homeBox = r('.hm-sw-header__home'), doc = r('.hm-sw-header__document')
|
||||||
const scroller = document.querySelector('.hm-sw-home-menu__sections')
|
const scroller = document.querySelector('.hm-sw-home-menu__sections')
|
||||||
return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth }
|
return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth, rowSections: scroller.querySelectorAll(':scope > .hm-sw-home-menu__section:not(.hm-sw-home-menu__more)').length, moved: document.querySelectorAll('.hm-sw-home-menu__more-panel > .hm-sw-home-menu__text-group').length }
|
||||||
})
|
})
|
||||||
responsive.push({ width, ...box })
|
responsive.push({ width, ...box })
|
||||||
if (width === 390 || width === 768) await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: 0, y: 0, width, height: 90 } })
|
if (width === 390 || width === 768) await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: 0, y: 0, width, height: 90 } })
|
||||||
@@ -153,6 +155,8 @@ for (const width of [1920, 1440, 1080, 768, 520, 390]) {
|
|||||||
report.data.responsive = responsive
|
report.data.responsive = responsive
|
||||||
check('every width: menu starts at object area −2px, ends at the window area, covers neither logo nor file name, header 49, no page scroll',
|
check('every width: menu starts at object area −2px, ends at the window area, covers neither logo nor file name, header 49, no page scroll',
|
||||||
responsive.every(r => Math.abs(r.start) < 0.6 && r.endGap > -0.6 && !r.coversHome && !r.coversDocument && r.headerHeight === 49 && !r.pageScroll), responsive)
|
responsive.every(r => Math.abs(r.start) < 0.6 && r.endGap > -0.6 && !r.coversHome && !r.coversDocument && r.headerHeight === 49 && !r.pageScroll), responsive)
|
||||||
|
check('every width: no horizontal scroll in the menu; 5 sections = row + More (1920: all in the row)',
|
||||||
|
responsive.every(r => !r.scrolls && r.rowSections + r.moved === 5 && (r.width !== 1920 || r.moved === 0)), responsive)
|
||||||
|
|
||||||
// 8. Without homeMenu (existing Header): the logo still calls onHomeClick only, no menu, no aria-expanded
|
// 8. Without homeMenu (existing Header): the logo still calls onHomeClick only, no menu, no aria-expanded
|
||||||
await page.setViewportSize({ width: 1920, height: 1000 })
|
await page.setViewportSize({ width: 1920, height: 1000 })
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidde
|
|||||||
// 2026-10-19 (viewer worktree, user request): the SW/Tool Story menu was removed; SW/Popup (the common SWPopup
|
// 2026-10-19 (viewer worktree, user request): the SW/Tool Story menu was removed; SW/Popup (the common SWPopup
|
||||||
// examples) stays. SWToolButton / SWToolGroup stay public; integrated-improvement.browser.mjs opening this id is historical evidence only.
|
// examples) stays. SWToolButton / SWToolGroup stay public; integrated-improvement.browser.mjs opening this id is historical evidence only.
|
||||||
export const retiredToolIds = ["sw-tool--tool-buttons"]
|
export const retiredToolIds = ["sw-tool--tool-buttons"]
|
||||||
|
// 2026-10-19 (Header home menu merge, user request): the SW/Home Menu sidebar menu was removed; its Stories moved under
|
||||||
|
// SW/Header as sw-header--home-menu (sidebar) and sw-header--home-menu-* (verification tag, reachable by URL).
|
||||||
|
export const movedHomeMenuIds = Object.fromEntries(["figma-home-on:figma", "capture-default", "capture-active", "capture-outline", "capture-recent", "header-composition", "variants", "dynamic-data", "standalone", "recent-files-panel:recent-files", "property-bar-check", "capture-property-bar", "integration", "capture-responsive"]
|
||||||
|
.map(entry => entry.split(":")).map(([from, to = from]) => [`sw-home-menu--${from}`, `sw-header--home-menu-${to}`]))
|
||||||
export const integratedChangedFiles = [
|
export const integratedChangedFiles = [
|
||||||
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
|
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
|
||||||
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
|
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type ReactNode } from "react"
|
import { useCallback, useRef, useState, type ReactNode } from "react"
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||||
// Public API only, like a consuming project.
|
// Public API only, like a consuming project.
|
||||||
import {
|
import {
|
||||||
@@ -20,10 +20,12 @@ import figmaPreview from "./assets/home-menu/662-67471_file-preview-2depth.png"
|
|||||||
import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png"
|
import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png"
|
||||||
|
|
||||||
// Owner: docs/sw/figma-additional/analysis-report.md (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 / 662:67267).
|
// Owner: docs/sw/figma-additional/analysis-report.md (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 / 662:67267).
|
||||||
|
// 2026-10-19: the former SW/Home Menu Stories live under SW/Header (SW normal mode only; SW/Viewer has no logo menu).
|
||||||
|
// "Home Menu" is the sidebar Story; the other Stories keep their checks reachable by URL (verification tag).
|
||||||
const KICKER = "HM DESIGN SYSTEM / SW"
|
const KICKER = "HM DESIGN SYSTEM / SW"
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: "SW/Home Menu",
|
title: "SW/Header",
|
||||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||||
} satisfies Meta
|
} satisfies Meta
|
||||||
export default meta
|
export default meta
|
||||||
@@ -54,9 +56,10 @@ const describe = (event: SWHomeMenuSelectEvent) => `onItemSelect(${event.section
|
|||||||
/**
|
/**
|
||||||
* SWHeader `homeMenu`: the logo toggles the menu (no onHomeClick), SWHeader places it from the measured header
|
* SWHeader `homeMenu`: the logo toggles the menu (no onHomeClick), SWHeader places it from the measured header
|
||||||
* (object area − 2px to the window area; Figma x 375 → 1680 at 1920). New / Open stay in the document dropdown.
|
* (object area − 2px to the window area; Figma x 375 → 1680 at 1920). New / Open stay in the document dropdown.
|
||||||
|
* `autoFocus={false}`: a menu open on load does not take focus (several menus share the Home Menu page).
|
||||||
*/
|
*/
|
||||||
function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], accent, recent = true, children }: {
|
function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], accent, recent = true, autoFocus, children }: {
|
||||||
width?: number; defaultOpen?: boolean; defaultActiveIds?: string[]; accent?: SWHomeMenuAccent; recent?: boolean
|
width?: number; defaultOpen?: boolean; defaultActiveIds?: string[]; accent?: SWHomeMenuAccent; recent?: boolean; autoFocus?: boolean
|
||||||
children?: (log: ReactNode) => ReactNode
|
children?: (log: ReactNode) => ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
@@ -74,7 +77,7 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
|
|||||||
onHomeClick={() => record("onHomeClick (homeMenu 사용 중에는 호출되지 않음)")}
|
onHomeClick={() => record("onHomeClick (homeMenu 사용 중에는 호출되지 않음)")}
|
||||||
homeMenuOpen={open} onHomeMenuOpenChange={(next, reason) => { setOpen(next); record(`onHomeMenuOpenChange(${next}, ${reason})`) }}
|
homeMenuOpen={open} onHomeMenuOpenChange={(next, reason) => { setOpen(next); record(`onHomeMenuOpenChange(${next}, ${reason})`) }}
|
||||||
homeMenu={{
|
homeMenu={{
|
||||||
sections, accent, activeIds,
|
sections, accent, activeIds, autoFocus,
|
||||||
onActiveChange: next => { setActiveIds(next); record(`onActiveChange([${next.join(", ")}])`) },
|
onActiveChange: next => { setActiveIds(next); record(`onActiveChange([${next.join(", ")}])`) },
|
||||||
onItemSelect: event => record(describe(event)),
|
onItemSelect: event => record(describe(event)),
|
||||||
}} />
|
}} />
|
||||||
@@ -84,13 +87,86 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FigmaHomeOn: Story = {
|
/**
|
||||||
name: "Figma 재현 (26:10591)",
|
* Header width set by a range input (Storybook control only): the header measures its own width. The range ends at
|
||||||
|
* the room the page gives (no page scroll), so the browser width and the slider both drive it.
|
||||||
|
*/
|
||||||
|
function ResizableHeaderWithMenu() {
|
||||||
|
const [width, setWidth] = useState(1280)
|
||||||
|
const [room, setRoom] = useState(1920)
|
||||||
|
const observed = useRef<ResizeObserver | null>(null)
|
||||||
|
const measureRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
observed.current?.disconnect()
|
||||||
|
if (!element) return
|
||||||
|
observed.current = new ResizeObserver(() => setRoom(Math.floor(element.clientWidth)))
|
||||||
|
observed.current.observe(element)
|
||||||
|
}, [])
|
||||||
|
const shown = Math.min(width, room)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="sw-home-menu-story__width">
|
||||||
|
헤더 폭 <input type="range" min={Math.min(390, room)} max={room} step={1} value={shown} onChange={event => setWidth(Number(event.target.value))} aria-label="헤더 폭" />
|
||||||
|
<output>{shown}px</output>
|
||||||
|
</label>
|
||||||
|
<div ref={measureRef}><HeaderWithMenu width={shown}>{log => log}</HeaderWithMenu></div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileTable = (
|
||||||
|
<table className="sw-home-menu-story__table">
|
||||||
|
<thead><tr><th>섹션</th><th>제목 노드</th><th>타일 (노드)</th></tr></thead>
|
||||||
|
<tbody>{figmaHomeMenuSections.map(section => (
|
||||||
|
<tr key={section.id}><td>{section.title}</td><td>{figmaTitleNodes[section.id]}</td>
|
||||||
|
<td>{section.items.map(item => `${item.label} (${figmaTileNodes[item.id]})`).join(" · ")}</td></tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The sidebar Story: the logo menu of SWHeader (SW normal mode) with its Figma states, sub panel and Escape stack. */
|
||||||
|
export const HomeMenu: Story = {
|
||||||
|
name: "Home Menu",
|
||||||
|
render: () => (
|
||||||
|
<StoryPage>
|
||||||
|
<StoryPageHeader kicker={KICKER} title="Home Menu — 로고(홈) 버튼 메뉴"
|
||||||
|
description="SWHeader의 homeMenu 하나로 연결합니다(SW 일반 모드 전용, SW 뷰어 모드에는 적용하지 않음). 로고 클릭 = 열기, 다시 클릭 = 닫기(onHomeClick은 호출하지 않음), 메뉴 빈 곳 클릭 = 닫기(Figma CLOSE), 타일 클릭 = 실행(누르는 동안만 Variant3, 클릭 후 ON 없음), 최근파일만 목록이 열린 동안 Variant3. Escape는 더보기 → 하위 패널 → 메뉴 순으로 닫고 로고로 Focus를 돌립니다. ←/→/Home/End로 타일을 이동합니다. New / Open은 문서 Dropdown 안에 그대로 있고, Dropdown을 열면 메뉴가 닫힙니다. 메뉴는 Header 위에 겹치는 층이라 열고 닫아도 Header 요소의 위치·너비가 바뀌지 않습니다." />
|
||||||
|
<StorySection title="로고 클릭 · 열기 / 닫기 (현재 폭)" description="5개 섹션(파일 · 손상된 파일 · Display · 설정 · 궁금하면) 15개 타일. 마우스를 올리면 Hover(Variant2), 누르는 동안 Pressed(Variant3)이며 클릭 후 ON 상태는 남지 않습니다. 최근파일 타일만 하위 패널을 열고, 목록이 열린 동안 Variant3를 유지합니다.">
|
||||||
|
<HeaderWithMenu>{log => log}</HeaderWithMenu>
|
||||||
|
</StorySection>
|
||||||
|
<StorySection title="반응형 · 헤더 폭 조절 · 더보기(⋯)" description="메뉴 위치와 폭은 고정 x가 아니라 Header를 측정해 정합니다(객체 수정 영역 −2px ~ 창 영역). 폭이 부족한 Header에서는 기존 더보기(…) 규칙이 그대로 동작하고, 메뉴는 가로 스크롤 없이 들어가는 섹션만 앞에서부터 보여 주며 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다. 더보기를 누르면 옮긴 섹션이 텍스트 메뉴(섹션 제목 + 기능 목록, 그룹 사이 구분선)로 아래에 열리고(Hover·Active·클릭은 타일과 같음, 최근파일은 기존 목록을 엶), 폭이 넓어지면 원래 자리로 돌아갑니다. 브라우저 폭이나 슬라이더로 확인합니다(슬라이더 최대 = 현재 페이지 폭).">
|
||||||
|
<ResizableHeaderWithMenu />
|
||||||
|
</StorySection>
|
||||||
|
<StorySection title="Figma 26:10591 비교 (현재 폭)" description="위는 구현(열림, 현재 폭), 아래는 Figma 스크린샷(1920px을 현재 폭에 맞춰 축소)입니다. 1:1 비교는 'Home Menu · Figma 재현 (26:10591)' Story(1920px)에서 합니다. 열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 662:67267).">
|
||||||
|
<HeaderWithMenu defaultOpen autoFocus={false} />
|
||||||
|
<img className="sw-home-menu-story__figma-fit" src={figmaHomeOn} alt="Figma 26:10591 home on" width={1920} height={78} />
|
||||||
|
</StorySection>
|
||||||
|
<StorySection title="Default / Variant2 · Hover / Variant3 · Active" description="Figma 버튼 세트 15개(26:11355 … 26:11631)는 모두 같은 SWHomeMenuItem입니다. Variant2(Hover)는 마우스를 올려 확인하며 Figma에서 Variant3와 시각 속성이 같습니다. Variant3는 activeIds로 지정합니다.">
|
||||||
|
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Default" /></SWTheme>
|
||||||
|
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} activeIds={figmaHomeMenuItemIds} closeOnBackgroundClick={false} aria-label="Active" /></SWTheme>
|
||||||
|
{tileTable}
|
||||||
|
</StorySection>
|
||||||
|
<StorySection title="최근파일 하위 패널 (662:67267)" description="최근파일 타일을 누르면(Variant3) 메뉴 하단 강조가 전체 2px 외곽선으로 바뀌고 그 아래에 최근파일 목록이 열립니다. 행에 마우스를 올리면 2뎁스 미리보기가 옆에 나타납니다. 다시 누르거나 Escape로 닫습니다. 최근파일이 더보기 안에 있으면 목록은 더보기 타일 아래에 열리고 더보기 타일이 Variant3가 됩니다. 목록과 미리보기 이미지는 Story 데이터입니다. 1920px 1:1은 'Home Menu · 최근파일 하위 패널' Story입니다.">
|
||||||
|
<div className="sw-home-menu-story__tall"><HeaderWithMenu defaultOpen autoFocus={false} defaultActiveIds={["recent"]}>{log => log}</HeaderWithMenu></div>
|
||||||
|
<div className="sw-home-menu-story__figma-row">
|
||||||
|
<img src={figmaRecent} alt="Figma 662:67331 최근파일" width={362} height={758} />
|
||||||
|
<img src={figmaPreview} alt="Figma 662:67471 파일출력 2뎁스" width={315} height={276} />
|
||||||
|
</div>
|
||||||
|
</StorySection>
|
||||||
|
<StorySection title="통합 · 속성창 · SWPopup · Escape" description="Escape는 Focus가 있는 곳을 먼저 닫습니다(메뉴 안: 하위 패널 → 메뉴, Popup 안: 그 Popup). Focus가 모두의 밖이면 가장 최근에 열거나 사용한 하나만 닫습니다.">
|
||||||
|
<IntegrationDemo />
|
||||||
|
</StorySection>
|
||||||
|
</StoryPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HomeMenuFigma: Story = {
|
||||||
|
name: "Home Menu · Figma 재현 (26:10591)",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="로고 클릭 메뉴 — Figma 26:10591 재현"
|
<StoryPageHeader kicker={KICKER} title="로고 클릭 메뉴 — Figma 26:10591 재현"
|
||||||
description="1920px Header 위에 SWHomeMenu를 겹친 상태입니다. 위는 구현, 아래는 Figma 스크린샷(1x)입니다. 로고를 다시 누르면 닫힙니다." />
|
description="1920px Header 위에 SWHomeMenu를 겹친 상태입니다. 위는 구현, 아래는 Figma 스크린샷(1x)입니다. 로고를 다시 누르면 닫힙니다." />
|
||||||
<StorySection title="구현 (1920px)" description="SWHeader가 객체 수정 영역 시작(−2px)~창 영역 시작을 DOM에서 측정해 메뉴를 놓습니다. Figma 열림 상태의 Header #5F4B0F 상·하단 선은 별도 승인 전까지 적용하지 않습니다.">
|
<StorySection title="구현 (1920px)" description="SWHeader가 객체 수정 영역 시작(−2px)~창 영역 시작을 DOM에서 측정해 메뉴를 놓습니다. 열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 662:67267).">
|
||||||
<div className="sw-home-menu-story__scroll"><HeaderWithMenu width={1920} defaultOpen /></div>
|
<div className="sw-home-menu-story__scroll"><HeaderWithMenu width={1920} defaultOpen /></div>
|
||||||
</StorySection>
|
</StorySection>
|
||||||
<StorySection title="Figma 26:10591">
|
<StorySection title="Figma 26:10591">
|
||||||
@@ -111,17 +187,18 @@ function FigmaCanvas({ active, accent, recent }: { active?: boolean; accent?: SW
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export const CaptureDefault: Story = { name: "비교 캔버스 · Default", tags: ["!autodocs"], render: () => <FigmaCanvas /> }
|
export const HomeMenuCaptureDefault: Story = { name: "Home Menu · 비교 캔버스 · Default", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas /> }
|
||||||
export const CaptureActive: Story = { name: "비교 캔버스 · Variant3", tags: ["!autodocs"], render: () => <FigmaCanvas active /> }
|
export const HomeMenuCaptureActive: Story = { name: "Home Menu · 비교 캔버스 · Variant3", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas active /> }
|
||||||
export const CaptureOutline: Story = { name: "비교 캔버스 · outline", tags: ["!autodocs"], render: () => <FigmaCanvas accent="outline" /> }
|
export const HomeMenuCaptureOutline: Story = { name: "Home Menu · 비교 캔버스 · outline", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas accent="outline" /> }
|
||||||
export const CaptureRecent: Story = { name: "비교 캔버스 · 최근파일", tags: ["!autodocs"], render: () => <FigmaCanvas recent /> }
|
export const HomeMenuCaptureRecent: Story = { name: "Home Menu · 비교 캔버스 · 최근파일", tags: ["!autodocs", "verification"], render: () => <FigmaCanvas recent /> }
|
||||||
|
|
||||||
export const HeaderComposition: Story = {
|
export const HomeMenuHeaderComposition: Story = {
|
||||||
name: "Header 조합 · 로고 클릭",
|
name: "Home Menu · Header 조합 · 로고 클릭",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="SWHeader homeMenu"
|
<StoryPageHeader kicker={KICKER} title="SWHeader homeMenu"
|
||||||
description="SWHeader의 homeMenu 하나로 연결합니다. 로고 클릭 = 열기/닫기(onHomeClick은 호출하지 않음), 메뉴 빈 곳 클릭 = 닫기(Figma CLOSE), 타일 클릭 = Active 전환, 다시 클릭 = 기본. Escape는 하위 패널 → 메뉴 순으로 닫고 로고로 Focus를 돌립니다. ←/→/Home/End로 타일을 이동합니다. New / Open은 문서 Dropdown 안에 그대로 있고, Dropdown을 열면 메뉴가 닫힙니다." />
|
description="SWHeader의 homeMenu 하나로 연결합니다. 로고 클릭 = 열기/닫기(onHomeClick은 호출하지 않음), 메뉴 빈 곳 클릭 = 닫기(Figma CLOSE), 타일 클릭 = 실행(누르는 동안만 Variant3, 클릭 후 ON 없음), 최근파일만 목록이 열린 동안 Variant3. Escape는 하위 패널 → 메뉴 순으로 닫고 로고로 Focus를 돌립니다. ←/→/Home/End로 타일을 이동합니다. New / Open은 문서 Dropdown 안에 그대로 있고, Dropdown을 열면 메뉴가 닫힙니다." />
|
||||||
<StorySection title="현재 폭">
|
<StorySection title="현재 폭">
|
||||||
<HeaderWithMenu>{log => log}</HeaderWithMenu>
|
<HeaderWithMenu>{log => log}</HeaderWithMenu>
|
||||||
</StorySection>
|
</StorySection>
|
||||||
@@ -129,8 +206,9 @@ export const HeaderComposition: Story = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Variants: Story = {
|
export const HomeMenuVariants: Story = {
|
||||||
name: "3가지 Variant",
|
name: "Home Menu · 3가지 Variant",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="Default / Variant2 / Variant3"
|
<StoryPageHeader kicker={KICKER} title="Default / Variant2 / Variant3"
|
||||||
@@ -138,21 +216,13 @@ export const Variants: Story = {
|
|||||||
<StorySection title="Default">
|
<StorySection title="Default">
|
||||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Default" /></SWTheme>
|
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Default" /></SWTheme>
|
||||||
</StorySection>
|
</StorySection>
|
||||||
<StorySection title="Variant2 · Hover" description="마우스를 올린 타일만 Variant2입니다. 클릭하면 Variant3, 다시 클릭하면 Default입니다.">
|
<StorySection title="Variant2 · Hover" description="마우스를 올린 타일만 Variant2입니다. 누르는 동안 Variant3이고 클릭 후에는 Default로 돌아갑니다(실행 버튼). 최근파일만 목록이 열린 동안 Variant3입니다.">
|
||||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Hover" /></SWTheme>
|
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Hover" /></SWTheme>
|
||||||
</StorySection>
|
</StorySection>
|
||||||
<StorySection title="Variant3 · Active (activeIds = 15개 전체)">
|
<StorySection title="Variant3 · Active (activeIds = 15개 전체)" description="Figma Variant3 시각 확인용으로 host가 activeIds를 직접 지정한 상태입니다(표시 전용). 클릭으로는 바뀌지 않습니다.">
|
||||||
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} activeIds={figmaHomeMenuItemIds} closeOnBackgroundClick={false} aria-label="Active" /></SWTheme>
|
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen sections={figmaHomeMenuSections} activeIds={figmaHomeMenuItemIds} closeOnBackgroundClick={false} aria-label="Active" /></SWTheme>
|
||||||
</StorySection>
|
</StorySection>
|
||||||
<StorySection title="Figma 노드 대응" description="섹션 제목 tit_* 와 타일 세트">
|
<StorySection title="Figma 노드 대응" description="섹션 제목 tit_* 와 타일 세트">{tileTable}</StorySection>
|
||||||
<table className="sw-home-menu-story__table">
|
|
||||||
<thead><tr><th>섹션</th><th>제목 노드</th><th>타일 (노드)</th></tr></thead>
|
|
||||||
<tbody>{figmaHomeMenuSections.map(section => (
|
|
||||||
<tr key={section.id}><td>{section.title}</td><td>{figmaTitleNodes[section.id]}</td>
|
|
||||||
<td>{section.items.map(item => `${item.label} (${figmaTileNodes[item.id]})`).join(" · ")}</td></tr>
|
|
||||||
))}</tbody>
|
|
||||||
</table>
|
|
||||||
</StorySection>
|
|
||||||
</StoryPage>
|
</StoryPage>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -161,7 +231,7 @@ function DynamicDemo() {
|
|||||||
const [sections, setSections] = useState<SWHomeMenuSectionData[]>(figmaHomeMenuSections)
|
const [sections, setSections] = useState<SWHomeMenuSectionData[]>(figmaHomeMenuSections)
|
||||||
const [selection, setSelection] = useState<"multiple" | "single">("multiple")
|
const [selection, setSelection] = useState<"multiple" | "single">("multiple")
|
||||||
const [accent, setAccent] = useState<SWHomeMenuAccent>("auto")
|
const [accent, setAccent] = useState<SWHomeMenuAccent>("auto")
|
||||||
const [activeIds, setActiveIds] = useState<string[]>(["save"])
|
const [activeIds, setActiveIds] = useState<string[]>([])
|
||||||
const [count, setCount] = useState(1)
|
const [count, setCount] = useState(1)
|
||||||
const { entries, record } = useLog()
|
const { entries, record } = useLog()
|
||||||
const update = (sectionId: string, change: (items: readonly SWHomeMenuItemData[]) => SWHomeMenuItemData[]) =>
|
const update = (sectionId: string, change: (items: readonly SWHomeMenuItemData[]) => SWHomeMenuItemData[]) =>
|
||||||
@@ -195,8 +265,9 @@ function DynamicDemo() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicData: Story = {
|
export const HomeMenuDynamicData: Story = {
|
||||||
name: "Props · 데이터 변경",
|
name: "Home Menu · Props · 데이터 변경",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="데이터 기반 항목 · 선택 상태 제어"
|
<StoryPageHeader kicker={KICKER} title="데이터 기반 항목 · 선택 상태 제어"
|
||||||
@@ -213,7 +284,7 @@ function StandaloneDemo() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SWTheme className="sw-home-menu-story__stage">
|
<SWTheme className="sw-home-menu-story__stage">
|
||||||
<SWHomeMenu defaultOpen aria-label="조합형 메뉴" defaultActiveIds={["b"]} closeOnBackgroundClick={false} onItemSelect={event => record(describe(event))}>
|
<SWHomeMenu defaultOpen aria-label="조합형 메뉴" closeOnBackgroundClick={false} onItemSelect={event => record(describe(event))}>
|
||||||
<SWHomeMenuSection title="조합형 섹션">
|
<SWHomeMenuSection title="조합형 섹션">
|
||||||
<SWHomeMenuItem itemId="a" label="도구 A" icon={mono} />
|
<SWHomeMenuItem itemId="a" label="도구 A" icon={mono} />
|
||||||
<SWHomeMenuItem itemId="b" label="도구 B" icon={mono} />
|
<SWHomeMenuItem itemId="b" label="도구 B" icon={mono} />
|
||||||
@@ -231,8 +302,9 @@ function StandaloneDemo() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Standalone: Story = {
|
export const HomeMenuStandalone: Story = {
|
||||||
name: "단독 사용 · 조합형",
|
name: "Home Menu · 단독 사용 · 조합형",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="SWTheme 안에서 단독 사용"
|
<StoryPageHeader kicker={KICKER} title="SWTheme 안에서 단독 사용"
|
||||||
@@ -242,8 +314,9 @@ export const Standalone: Story = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RecentFilesPanel: Story = {
|
export const HomeMenuRecentFiles: Story = {
|
||||||
name: "최근파일 하위 패널 (662:67267)",
|
name: "Home Menu · 최근파일 하위 패널 (662:67267)",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="최근파일 — Figma 662:67267 + 662:67331 + 662:67471"
|
<StoryPageHeader kicker={KICKER} title="최근파일 — Figma 662:67267 + 662:67331 + 662:67471"
|
||||||
@@ -279,8 +352,9 @@ function PropertyBarCompare() {
|
|||||||
</SWTheme>
|
</SWTheme>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export const PropertyBarCheck: Story = {
|
export const HomeMenuPropertyBarCheck: Story = {
|
||||||
name: "속성Bar 비교 (26:10492)",
|
name: "Home Menu · 속성Bar 비교 (26:10492)",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="속성Bar — Figma 26:10492 비교"
|
<StoryPageHeader kicker={KICKER} title="속성Bar — Figma 26:10492 비교"
|
||||||
@@ -292,8 +366,8 @@ export const PropertyBarCheck: Story = {
|
|||||||
</StoryPage>
|
</StoryPage>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
export const CapturePropertyBar: Story = {
|
export const HomeMenuCapturePropertyBar: Story = {
|
||||||
name: "비교 캔버스 · 속성Bar", tags: ["!autodocs"],
|
name: "Home Menu · 비교 캔버스 · 속성Bar", tags: ["!autodocs", "verification"],
|
||||||
render: () => <SWTheme style={{ background: "#060606", width: 1222, padding: 0 }}><SWHeaderPropertyBar selection={{ id: "circle", title: "원", kind: "single" }} properties={barProperties} onPropertyChange={() => {}} /></SWTheme>,
|
render: () => <SWTheme style={{ background: "#060606", width: 1222, padding: 0 }}><SWHeaderPropertyBar selection={{ id: "circle", title: "원", kind: "single" }} properties={barProperties} onPropertyChange={() => {}} /></SWTheme>,
|
||||||
}
|
}
|
||||||
/** Integration: logo menu + property window + SWPopup at the same time (Escape stack and focus). */
|
/** Integration: logo menu + property window + SWPopup at the same time (Escape stack and focus). */
|
||||||
@@ -327,8 +401,9 @@ function IntegrationDemo() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export const Integration: Story = {
|
export const HomeMenuIntegration: Story = {
|
||||||
name: "통합 · Popup · Escape",
|
name: "Home Menu · 통합 · Popup · Escape",
|
||||||
|
tags: ["verification"],
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryPage>
|
<StoryPage>
|
||||||
<StoryPageHeader kicker={KICKER} title="로고 메뉴 + 속성창 + SWPopup"
|
<StoryPageHeader kicker={KICKER} title="로고 메뉴 + 속성창 + SWPopup"
|
||||||
@@ -339,4 +414,4 @@ export const Integration: Story = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Responsive check: the header at the viewport width with the menu open (no story page chrome). */
|
/** Responsive check: the header at the viewport width with the menu open (no story page chrome). */
|
||||||
export const CaptureResponsive: Story = { name: "비교 캔버스 · 반응형", tags: ["!autodocs"], render: () => <HeaderWithMenu defaultOpen /> }
|
export const HomeMenuCaptureResponsive: Story = { name: "Home Menu · 비교 캔버스 · 반응형", tags: ["!autodocs", "verification"], render: () => <HeaderWithMenu defaultOpen /> }
|
||||||
@@ -2,6 +2,7 @@ import { useId, useLayoutEffect, useRef, useState, type CSSProperties } from "re
|
|||||||
import {
|
import {
|
||||||
SWHeader, parseSWHeaderColor, type SWHeaderProps, type SWHeaderProperty, type SWHeaderPropertyGroup,
|
SWHeader, parseSWHeaderColor, type SWHeaderProps, type SWHeaderProperty, type SWHeaderPropertyGroup,
|
||||||
type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderColorMode, type SWHeaderLayer, type SWHeaderLineWeight,
|
type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderColorMode, type SWHeaderLayer, type SWHeaderLineWeight,
|
||||||
|
type SWHomeMenuSectionData,
|
||||||
} from "@hm-design/sw"
|
} from "@hm-design/sw"
|
||||||
import { StoryPage, StoryPageHeader } from "@/components/story-page"
|
import { StoryPage, StoryPageHeader } from "@/components/story-page"
|
||||||
|
|
||||||
@@ -13,10 +14,12 @@ interface OverviewProps {
|
|||||||
propertyFixture: (selection: Selection) => SWHeaderProperty[]
|
propertyFixture: (selection: Selection) => SWHeaderProperty[]
|
||||||
propertySelection: (selection: Selection) => SWHeaderPropertySelection
|
propertySelection: (selection: Selection) => SWHeaderPropertySelection
|
||||||
barFixture: SWHeaderProperty[]
|
barFixture: SWHeaderProperty[]
|
||||||
|
/** Logo (home) menu of the SW normal mode: the logo opens and closes it (SWHeader homeMenu). */
|
||||||
|
homeMenuSections?: readonly SWHomeMenuSectionData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
|
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
|
||||||
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture }: OverviewProps) {
|
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture, homeMenuSections }: OverviewProps) {
|
||||||
const controlId = useId()
|
const controlId = useId()
|
||||||
const slotRef = useRef<HTMLDivElement>(null)
|
const slotRef = useRef<HTMLDivElement>(null)
|
||||||
const headerRef = useRef<HTMLElement>(null)
|
const headerRef = useRef<HTMLElement>(null)
|
||||||
@@ -185,7 +188,9 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
|
|||||||
expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus,
|
expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus,
|
||||||
shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"),
|
shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"),
|
||||||
}}
|
}}
|
||||||
onHomeClick={() => record("onHomeClick")} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
onHomeClick={() => record("onHomeClick")}
|
||||||
|
homeMenu={homeMenuSections && { sections: homeMenuSections, onItemSelect: event => record(`onItemSelect(${event.item.id}, ${event.active})`) }}
|
||||||
|
onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
||||||
onMinimize={() => record("onMinimize")} onClose={() => record("onClose")} onWindowMoveStart={() => record("onWindowMoveStart")}
|
onMinimize={() => record("onMinimize")} onClose={() => record("onClose")} onWindowMoveStart={() => record("onWindowMoveStart")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
|
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
|
||||||
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
|
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
|
||||||
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent,
|
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent,
|
||||||
|
SWRecentFiles, SWToolButton,
|
||||||
} from "@hm-design/sw"
|
} from "@hm-design/sw"
|
||||||
import "@hm-design/sw/styles.css"
|
import "@hm-design/sw/styles.css"
|
||||||
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
|
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
|
||||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||||
import "./sw-stories.css"
|
import "./sw-stories.css"
|
||||||
import { SWHeaderOverview } from "./sw-header-overview"
|
import { SWHeaderOverview } from "./sw-header-overview"
|
||||||
|
import { figmaHomeMenuSections, figmaRecentFiles, withRecentFiles } from "./sw-home-menu-fixture"
|
||||||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||||||
|
|
||||||
// Owner: docs/sw/sw-header-design-spec.md (Figma rj6wpsfk24lOWvWiVRAANP, node 26:8944 "header_green").
|
// Owner: docs/sw/sw-header-design-spec.md (Figma rj6wpsfk24lOWvWiVRAANP, node 26:8944 "header_green").
|
||||||
@@ -179,12 +181,16 @@ function InteractiveHeader(props: Partial<SWHeaderProps>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logo menu of the Overview (Figma 26:10591 sample, same fixture as the Home Menu Story).
|
||||||
|
const OVERVIEW_HOME_MENU = withRecentFiles(figmaHomeMenuSections, <SWRecentFiles files={figmaRecentFiles} />)
|
||||||
|
|
||||||
export const Overview: Story = {
|
export const Overview: Story = {
|
||||||
parameters: { controls: { disable: true } },
|
parameters: { controls: { disable: true } },
|
||||||
render: () => <SWHeaderOverview initial={{ ...figmaArgs, ...callbacks }}
|
render: () => <SWHeaderOverview initial={{ ...figmaArgs, ...callbacks }}
|
||||||
fixtures={{ documents: SAMPLE_DOCUMENTS, layers: PROPERTY_LAYERS, colors: FIGMA_COLORS, userColors: FIGMA_USER_COLORS,
|
fixtures={{ documents: SAMPLE_DOCUMENTS, layers: PROPERTY_LAYERS, colors: FIGMA_COLORS, userColors: FIGMA_USER_COLORS,
|
||||||
lineTypes: PROPERTY_LINE_TYPES, lineWeights: FIGMA_LINE_WEIGHTS, textStyles: FIGMA_TEXT_STYLES, dimensionStyles: FIGMA_DIMENSION_STYLES }}
|
lineTypes: PROPERTY_LINE_TYPES, lineWeights: FIGMA_LINE_WEIGHTS, textStyles: FIGMA_TEXT_STYLES, dimensionStyles: FIGMA_DIMENSION_STYLES }}
|
||||||
propertyGroups={PROPERTY_GROUPS} propertyFixture={propertyFixture} propertySelection={propertySelection} barFixture={BAR_FIXTURE} />,
|
propertyGroups={PROPERTY_GROUPS} propertyFixture={propertyFixture} propertySelection={propertySelection} barFixture={BAR_FIXTURE}
|
||||||
|
homeMenuSections={OVERVIEW_HOME_MENU} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InteractiveStates: Story = {
|
export const InteractiveStates: Story = {
|
||||||
@@ -776,6 +782,44 @@ function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
|
|||||||
|
|
||||||
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
|
const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measured More (2026-10-19): the header width comes from a range input and the host adds content the breakpoints
|
||||||
|
* do not know about (buttons next to the logo, extra tools). Parts move into More only while the real room is
|
||||||
|
* missing and return to their own place, in their own order, once it is back. Range / checkboxes are Storybook controls.
|
||||||
|
*/
|
||||||
|
function MeasuredMoreDemo() {
|
||||||
|
const [width, setWidth] = useState(1920)
|
||||||
|
const [leading, setLeading] = useState(true)
|
||||||
|
const [tools, setTools] = useState(true)
|
||||||
|
const [homeMenu, setHomeMenu] = useState(true)
|
||||||
|
const [barOpen, setBarOpen] = useState(false)
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false)
|
||||||
|
const [log, setLog] = useState<string[]>([])
|
||||||
|
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sw-story-measured-controls" role="group" aria-label="실측 More 조건">
|
||||||
|
<label>헤더 폭 <input type="range" min={390} max={1920} step={1} value={width} onChange={event => setWidth(Number(event.target.value))} aria-label="헤더 폭" /> <output data-measured-width="">{width}px</output></label>
|
||||||
|
<label><input type="checkbox" checked={leading} onChange={event => setLeading(event.target.checked)} />로고 오른쪽 버튼(leading)</label>
|
||||||
|
<label><input type="checkbox" checked={tools} onChange={event => setTools(event.target.checked)} />추가 도구(tools)</label>
|
||||||
|
<label><input type="checkbox" checked={homeMenu} onChange={event => setHomeMenu(event.target.checked)} />홈 메뉴(homeMenu)</label>
|
||||||
|
</div>
|
||||||
|
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
|
||||||
|
<MenuFrame width={width}>
|
||||||
|
<StyleHeader onLog={record}
|
||||||
|
leading={leading ? <><SWToolButton appearance="document" onClick={() => record("leading: 저장")}>저장</SWToolButton><SWToolButton appearance="document" onClick={() => record("leading: 내보내기")}>내보내기</SWToolButton></> : undefined}
|
||||||
|
tools={tools ? <SWToolButton onClick={() => record("tools: 측정")}>측정</SWToolButton> : undefined}
|
||||||
|
homeMenu={homeMenu ? { sections: OVERVIEW_HOME_MENU, onItemSelect: event => record(`onItemSelect(${event.item.id})`) } : undefined}
|
||||||
|
onHomeMenuOpenChange={(open, reason) => record(`homeMenuOpen(${open}, ${reason})`)}
|
||||||
|
onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
|
||||||
|
propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }}
|
||||||
|
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }}
|
||||||
|
propertyPanel={{ selection: { id: "none", title: "선택된 객체 없음", kind: "none" } }} />
|
||||||
|
</MenuFrame>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const ResponsiveMore: Story = {
|
export const ResponsiveMore: Story = {
|
||||||
name: "Responsive More",
|
name: "Responsive More",
|
||||||
parameters: { controls: { disable: true } },
|
parameters: { controls: { disable: true } },
|
||||||
@@ -784,8 +828,11 @@ export const ResponsiveMore: Story = {
|
|||||||
<StoryPageHeader
|
<StoryPageHeader
|
||||||
kicker={KICKER}
|
kicker={KICKER}
|
||||||
title="Responsive More — 대표 필드 전환"
|
title="Responsive More — 대표 필드 전환"
|
||||||
description="좁은 헤더에서 더보기(…)의 필드 항목을 고르면, 그 필드가 객체 수정 영역의 첫 칸(레이어 필드 자리, 대표 슬롯)에 표시되고 Dropdown이 바로 아래에 열립니다. 레이어는 더보기로 이동해 다시 고를 수 있습니다. 메뉴 전환은 UI 상태일 뿐이며 데이터 Callback은 호출하지 않습니다(필드 클릭과 같은 on*Open 1회만). 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)는 그대로입니다."
|
description="좁은 헤더에서 더보기(…)의 필드 항목을 고르면, 그 필드가 객체 수정 영역의 첫 칸(레이어 필드 자리, 대표 슬롯)에 표시되고 Dropdown이 바로 아래에 열립니다. 레이어는 더보기로 이동해 다시 고를 수 있습니다. 메뉴 전환은 UI 상태일 뿐이며 데이터 Callback은 호출하지 않습니다(필드 클릭과 같은 on*Open 1회만). 기존 Breakpoint(1800 / 1180 / 1080 / 900 / 640 / 520)는 그대로이고, 그 위에 실제 남은 공간을 측정해 부족한 메뉴만 더보기로 옮깁니다."
|
||||||
/>
|
/>
|
||||||
|
<StorySection title="실제 사용 가능한 공간 기준 (폭 조절)" description="고정 폭만 보지 않고 객체 수정 영역에 실제로 남은 공간을 측정합니다. 로고 오른쪽 버튼·추가 도구처럼 Breakpoint가 모르는 요소가 공간을 쓰면, 표시할 수 없는 메뉴가 선·문자·치수스타일 → 그리기모드 → 속성Bar·속성창 → 색상 순(같은 단계는 오른쪽부터)으로 더보기에 들어갑니다. 공간이 다시 생기면 원래 자리·순서로 돌아옵니다. 로고 오른쪽 Open 버튼은 가장 먼저 옮겨지고 가장 늦게 돌아옵니다. Dropdown·홈 메뉴·더보기를 열고 닫아도 Header 요소의 위치와 너비는 바뀌지 않습니다.">
|
||||||
|
<MeasuredMoreDemo />
|
||||||
|
</StorySection>
|
||||||
<StorySection title="전환 · Callback 기록 (1440)" description="… → 선스타일: 대표 칸이 선스타일(미리보기·이름·가중치·스케일)로 바뀌고 선스타일 Dropdown이 열립니다. … → 문자스타일: 선스타일 Dropdown이 닫히고 대표 칸이 문자스타일로. … → 레이어: 원래 배치로 복귀. 값은 모두 유지됩니다.">
|
<StorySection title="전환 · Callback 기록 (1440)" description="… → 선스타일: 대표 칸이 선스타일(미리보기·이름·가중치·스케일)로 바뀌고 선스타일 Dropdown이 열립니다. … → 문자스타일: 선스타일 Dropdown이 닫히고 대표 칸이 문자스타일로. … → 레이어: 원래 배치로 복귀. 값은 모두 유지됩니다.">
|
||||||
<StyleDemo width={1440} />
|
<StyleDemo width={1440} />
|
||||||
</StorySection>
|
</StorySection>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Storybook layout only for SW / Home Menu (frames, logs, fixtures). The menu itself uses no story class. */
|
/* Storybook layout only for the SW / Header Home Menu Stories (frames, logs, fixtures). The menu itself uses no story class. */
|
||||||
.sw-home-menu-story__scroll { overflow-x: auto; }
|
.sw-home-menu-story__scroll { overflow-x: auto; }
|
||||||
.sw-home-menu-story__frame { position: relative; min-height: 80px; }
|
.sw-home-menu-story__frame { position: relative; min-height: 80px; }
|
||||||
.sw-home-menu-story__overlay { position: absolute; top: 0; z-index: 3; }
|
.sw-home-menu-story__overlay { position: absolute; top: 0; z-index: 3; }
|
||||||
@@ -11,4 +11,8 @@
|
|||||||
.sw-home-menu-story__table { border-collapse: collapse; font-size: 13px; }
|
.sw-home-menu-story__table { border-collapse: collapse; font-size: 13px; }
|
||||||
.sw-home-menu-story__table :is(th, td) { padding: 4px 8px; border: 1px solid #ccc; text-align: left; }
|
.sw-home-menu-story__table :is(th, td) { padding: 4px 8px; border: 1px solid #ccc; text-align: left; }
|
||||||
.sw-home-menu-story__tall .sw-home-menu-story__frame { height: 860px; }
|
.sw-home-menu-story__tall .sw-home-menu-story__frame { height: 860px; }
|
||||||
.sw-home-menu-story__figma-row { display: flex; align-items: flex-start; gap: 16px; margin-top: 12px; }
|
.sw-home-menu-story__figma-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px; margin-top: 12px; }
|
||||||
|
.sw-home-menu-story__figma-row > img { max-width: 100%; height: auto; }
|
||||||
|
.sw-home-menu-story__figma-fit { display: block; width: 100%; height: auto; margin-top: 12px; }
|
||||||
|
.sw-home-menu-story__width { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; }
|
||||||
|
.sw-home-menu-story__width input { width: min(480px, 60vw); }
|
||||||
@@ -70,3 +70,7 @@
|
|||||||
.sw-overview-stage .hm-sw-header__document:has(.hm-sw-header__document-menu) { z-index: 3; }
|
.sw-overview-stage .hm-sw-header__document:has(.hm-sw-header__document-menu) { z-index: 3; }
|
||||||
.sw-overview-status { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; min-width: 0; }
|
.sw-overview-status { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; min-width: 0; }
|
||||||
.sw-overview-status output { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sw-overview-status output { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* Responsive More measured section: Storybook controls (width range, host content toggles) above the frame. */
|
||||||
|
.sw-story-measured-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-bottom: 8px; font-size: 13px; }
|
||||||
|
.sw-story-measured-controls label { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.sw-story-measured-controls input[type="range"] { width: min(480px, 60vw); }
|
||||||