@operato/menu 10.6.0 → 10.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/src/index.d.ts +18 -0
  3. package/dist/src/index.js +4 -1
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/menu-active.d.ts +89 -0
  6. package/dist/src/menu-active.js +115 -0
  7. package/dist/src/menu-active.js.map +1 -0
  8. package/dist/src/menu-mode.d.ts +98 -0
  9. package/dist/src/menu-mode.js +100 -0
  10. package/dist/src/menu-mode.js.map +1 -0
  11. package/dist/src/menu-portrait-styles.d.ts +27 -0
  12. package/dist/src/menu-portrait-styles.js +516 -92
  13. package/dist/src/menu-portrait-styles.js.map +1 -1
  14. package/dist/src/menu-privilege.d.ts +34 -0
  15. package/dist/src/menu-privilege.js +22 -0
  16. package/dist/src/menu-privilege.js.map +1 -0
  17. package/dist/src/ox-menu-part.d.ts +103 -0
  18. package/dist/src/ox-menu-part.js +224 -23
  19. package/dist/src/ox-menu-part.js.map +1 -1
  20. package/dist/src/ox-menu-portrait.d.ts +158 -3
  21. package/dist/src/ox-menu-portrait.js +511 -13
  22. package/dist/src/ox-menu-portrait.js.map +1 -1
  23. package/dist/src/types.d.ts +38 -1
  24. package/dist/src/types.js.map +1 -1
  25. package/dist/test/menu-active.test.d.ts +1 -0
  26. package/dist/test/menu-active.test.js +131 -0
  27. package/dist/test/menu-active.test.js.map +1 -0
  28. package/dist/test/menu-mode.test.d.ts +1 -0
  29. package/dist/test/menu-mode.test.js +108 -0
  30. package/dist/test/menu-mode.test.js.map +1 -0
  31. package/dist/test/menu-privilege.test.d.ts +1 -0
  32. package/dist/test/menu-privilege.test.js +94 -0
  33. package/dist/test/menu-privilege.test.js.map +1 -0
  34. package/dist/test/ox-menu-portrait.test.d.ts +1 -0
  35. package/dist/test/ox-menu-portrait.test.js +339 -0
  36. package/dist/test/ox-menu-portrait.test.js.map +1 -0
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +10 -10
  39. package/test/menu-active.test.ts +162 -0
  40. package/test/menu-mode.test.ts +135 -0
  41. package/test/menu-privilege.test.ts +138 -0
  42. package/test/ox-menu-portrait.test.ts +431 -0
@@ -1 +1 @@
1
- {"version":3,"file":"menu-portrait-styles.js","sourceRoot":"","sources":["../../src/menu-portrait-styles.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAA;AAEzB,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoKpC,CAAA","sourcesContent":["import { css } from 'lit'\n\nexport const MenuPortraitStyles = css`\n :host {\n display: flex;\n overflow-y: auto;\n flex-direction: column;\n height: 100%;\n background-color: var(--md-sys-color-on-primary);\n }\n\n :host > ul {\n margin-block-end: 1.5em;\n }\n\n ul {\n list-style: none;\n margin: var(--spacing-none);\n padding: var(--spacing-none);\n }\n\n [group-label] {\n padding: var(--spacing-huge) var(--spacing-none) var(--spacing-small) var(--spacing-large);\n border-bottom: var(--border-dim-color);\n font: var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)) 12px var(--theme-font);\n color: var(--md-sys-color-primary);\n text-transform: uppercase;\n }\n\n a {\n display: flex;\n align-items: center;\n border-bottom: 1px solid rgba(0, 0, 0, 0.07);\n padding: var(--spacing-medium);\n padding-left: var(--spacing-large);\n text-decoration: none;\n font: normal var(--md-sys-typescale-label-large-size, 0.875rem) var(--theme-font);\n color: var(--md-sys-color-on-surface-variant);\n text-transform: capitalize;\n\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n a:hover {\n color: var(--md-sys-color-primary);\n font-weight: bold;\n }\n\n a * {\n vertical-align: middle;\n }\n\n a md-icon {\n --md-icon-size: 1.1em;\n opacity: 0.7;\n margin-right: var(--spacing-small);\n color: var(--md-sys-color-primary);\n }\n\n md-icon[filled] {\n --md-icon-size: 1.1em;\n font-variation-settings: 'FILL' 1;\n }\n\n a [submenu-button] {\n max-height: 1em;\n margin-left: auto;\n transition: transform 0.7s ease;\n }\n\n a [submenu-button]::before {\n content: 'chevron_right';\n }\n\n li[active] > a [submenu-button] {\n transform: rotate(90deg);\n }\n\n li[active] > a [submenu-button]::before {\n content: 'chevron_right';\n }\n\n a > span {\n flex: 1;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n li[active] > a {\n color: var(--md-sys-color-on-primary-container);\n font-weight: bold;\n border-left: 3px solid var(--md-sys-color-primary);\n }\n\n li li a {\n padding: var(--spacing-medium);\n padding-left: var(--spacing-huge);\n font: normal 13px var(--theme-font);\n color: var(--md-sys-color-on-surface-variant);\n }\n\n li li[active] a {\n background-color: var(--md-sys-color-primary-container);\n font: var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500))\n var(--md-sys-typescale-label-large-size, 0.875rem) var(--theme-font);\n color: var(--md-sys-color-primary);\n }\n\n li > ul {\n overflow-y: hidden;\n max-height: 0;\n background-color: var(--md-sys-color-surface);\n\n transition-property: all;\n transition-duration: 0.7s;\n }\n\n li[active] > ul {\n max-height: 550px;\n }\n\n li[active] > ul[settled] {\n overflow-y: auto;\n }\n\n li li a::before {\n margin-right: var(--spacing-small);\n }\n\n a [badge] {\n margin-left: auto;\n background-color: var(--md-sys-color-primary);\n border-radius: var(--md-sys-shape-corner-full);\n padding: 0 var(--spacing-small);\n color: var(--md-sys-color-on-primary);\n font-size: 0.9em;\n }\n\n @media only screen and (max-width: 460px) {\n :host {\n min-width: 100vw;\n }\n\n a {\n padding: var(--spacing-large);\n font: normal 15px var(--theme-font);\n }\n\n li li a {\n display: block;\n padding: var(--spacing-medium);\n padding-left: var(--spacing-huge);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font: normal var(--md-sys-typescale-label-large-size, 0.875rem) var(--theme-font);\n }\n\n li li[active] a {\n font: var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500))\n var(--md-sys-typescale-label-large-size, 0.875rem) var(--theme-font);\n }\n }\n`\n"]}
1
+ {"version":3,"file":"menu-portrait-styles.js","sourceRoot":"","sources":["../../src/menu-portrait-styles.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAA;AAEzB;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAijBpC,CAAA","sourcesContent":["import { css } from 'lit'\n\n/**\n * 왼쪽 메뉴의 모양 — **한 벌이다.**\n *\n * ## 왜 한 벌인가\n *\n * 한때 둘이었다. 기존 모양(줄마다 밑줄 · 왼쪽 여백 16px · hover 에 굵기 변화)과 레일 모양\n * (라운드 알약 · 밑줄 없음 · 접을 수 있음). 같은 메뉴가 설정 하나로 딴 물건처럼 보였고, 무엇을\n * 어느 쪽에 두어야 하는지를 매번 판단해야 해서 **일곱 번 조용히 깨졌다.**\n *\n * 기존 모양은 접을 수도 없었다 — 줄마다 폭 전체를 쓰는 밑줄이 있고 왼쪽 여백이 16px 이라\n * 56px 에서 무너진다. 그래서 레일 펼친 모양을 기준으로 통일했다.\n *\n * ## 안에 세 변형이 있다\n *\n * ```\n * (기본) 늘 보이는 모양. 서랍도 이것이다\n * [collapsed] 접힌 레일 — 아이콘만. 하위는 팝업으로\n * .railpop 팝업 — 접힌 레일에서 하위로 가는 길, 그리고 시스템 항목의 자리\n * ```\n *\n * 무엇이 어느 변형에 걸리나는 `menu-mode.ts` 가 답한다. 여기서 그때그때 판단하지 않는다.\n *\n * ## 값의 출처\n *\n * `operato-twin` 의 레일(`twin-nav-rail`)이 쓰던 값이다. 값을 베끼지 않고 조정 지점으로 노출해\n * 앱이 자기 테마에 맞출 수 있게 둔다.\n */\nexport const MenuPortraitStyles = css`\n :host {\n /*\n * ── 조정 지점 ─────────────────────────────────────────────────────────\n * 폭은 여기 없다 — ox-menu-part 가 정한다. 셸이 크기를 재는 요소도 그것이고, 리사이즈\n * 손잡이가 인라인 폭을 쓰는 요소도 그것이다. 여기서 또 정하면 둘이 싸운다.\n * 폭 토큰(--menu-rail-width · --menu-rail-width-collapsed)은 앱이 위에서 준다.\n */\n --menu-rail-radius: 8px;\n --menu-rail-gap: 11px;\n --menu-rail-font-size: 13px;\n --menu-rail-font-weight: 560;\n --menu-rail-icon-size: 19px;\n /*\n * ── 줄의 세로 여백 ────────────────────────────────────────────────────\n * 줄 높이는 아이콘(20px)에 이 값의 두 배를 더한 것이다. 8px 이면 36px 줄이다.\n *\n * 항목이 스물 남짓인 레일은 그 높이로 화면을 넘긴다. 그때는 줄여 앉힌다 — 다 보이는 것이\n * 넉넉한 것보다 낫다. 다만 **줄이는 것은 앱이 고르는 값**이고, 화면 높이에 따라 저절로\n * 눌리게 두지 않는다.\n *\n * 저절로 눌리게 두면 창 높이마다 줄 높이가 달라진다. 실제로 그런 레일이 있었다 —\n * 창 1100 에서 33px · 800 에서 28px · 620 에서 16px 이 되어 아이콘이 뭉개졌다.\n *\n * 기본은 6.5px — 33px 줄이다. 두 제품이 같은 값을 골랐고, 앱마다 적어 두면 한쪽만 고쳐진다.\n */\n --menu-rail-row-pad: 6.5px;\n --menu-rail-surface: var(--md-sys-color-surface, #fff);\n --menu-rail-ink: var(--md-sys-color-on-surface, #17242c);\n --menu-rail-muted: var(--md-sys-color-on-surface-variant, #57676f);\n --menu-rail-faint: var(--md-sys-color-outline, #8a959c);\n --menu-rail-hover: var(--md-sys-color-surface-container-low, rgba(0, 0, 0, 0.04));\n --menu-rail-accent: var(--md-sys-color-primary, #0e8a56);\n --menu-rail-accent-wash: var(--md-sys-color-primary-container, rgba(14, 138, 86, 0.1));\n\n /*\n * ── 세로 단이다 ───────────────────────────────────────────────────────\n * 이 셋이 없으면 gap 이 안 먹고, **바닥에 붙이는 빈 자리가 자라지 않아** 시스템 줄이 목록\n * 바로 뒤에 붙는다. 두 스타일 파일을 합칠 때 이것을 잃어 그 증상이 났다.\n */\n display: flex;\n flex-direction: column;\n height: 100%;\n\n box-sizing: border-box;\n width: 100%;\n background-color: var(--menu-rail-surface);\n padding: 10px 8px;\n gap: 2px;\n overflow-x: hidden;\n overflow-y: auto;\n }\n\n /*\n * 하위가 펼쳐져 있으면 최상위의 합계를 감춘다.\n *\n * 그 아래에 낱개 건수가 다 보이므로 같은 말을 두 번 하는 것이고, 합계와 낱개가 나란히 있으면\n * 「합계가 저 셋과 다른 무엇인가」를 한 번 생각하게 만든다.\n *\n * 최상위에만 건다 — 하위 항목도 지금 페이지이면 active 를 단다. 그래서 [top] 으로 가른다.\n *\n * 접혔을 때는 감추지 않는다 — 낱개가 안 보이므로 합계가 유일한 신호다.\n */\n :host(:not([collapsed])) li[top][active] > a [badge] {\n display: none;\n }\n\n :host ul {\n display: flex;\n flex-direction: column;\n gap: 2px;\n list-style: none;\n margin: 0;\n padding: 0;\n }\n\n /* 묶음 이름 — 접히면 자리가 없다. */\n :host [group-label] {\n padding: 10px 10px 4px;\n border: none;\n font-family: var(--theme-font-mono, monospace);\n font-size: 9.5px;\n font-weight: 400;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--menu-rail-faint);\n }\n\n /* 접히면 자리가 없다 — 레일 안쪽만. 팝업 안의 묶음 이름은 그대로 보인다. */\n :host([collapsed]) > [group-label],\n :host([collapsed]) > ul [group-label] {\n display: none;\n }\n\n :host a {\n display: flex;\n align-items: center;\n gap: var(--menu-rail-gap);\n padding: var(--menu-rail-row-pad) 11px;\n /*\n * **줄어들지 않는다.** 목록이 화면보다 길면 겉 상자가 구르면 되고, 줄이 눌릴 일이 아니다.\n * 이것이 없으면 창 높이에 따라 줄 높이가 달라진다(위 --menu-rail-row-pad 머리말).\n */\n flex: none;\n border: none;\n border-radius: var(--menu-rail-radius);\n font: var(--menu-rail-font-weight) var(--menu-rail-font-size) var(--theme-font, inherit);\n color: var(--menu-rail-muted);\n text-decoration: none;\n text-transform: none;\n white-space: nowrap;\n overflow: hidden;\n }\n\n /*\n * hover 에서 굵기를 바꾸지 않는다. 기존 모양은 a:hover { font-weight: bold } 여서 마우스가\n * 지날 때마다 글자 폭이 늘어 줄이 흔들렸다. 색만 바꾼다.\n */\n :host a:hover {\n background-color: var(--menu-rail-hover);\n color: var(--menu-rail-ink);\n font-weight: var(--menu-rail-font-weight);\n }\n\n :host a md-icon {\n --md-icon-size: var(--menu-rail-icon-size);\n\n flex: none;\n width: 20px;\n min-width: 20px;\n height: 20px;\n margin: 0;\n opacity: 1;\n overflow: hidden;\n color: var(--menu-rail-faint);\n }\n\n /*\n * ── 지금 페이지 ─────────────────────────────────────────────────────────\n * 어느 층이든 **지금 보고 있는 그 줄**에만 강조를 준다. 층으로 가르지 않는다 — 최상위가 그\n * 자리일 수도 있고(하위가 없는 묶음) 하위가 그 자리일 수도 있다.\n */\n :host li[current] > a {\n border-left: none;\n background-color: var(--menu-rail-accent-wash);\n color: var(--menu-rail-ink);\n font-weight: 700;\n }\n\n :host li[current] > a md-icon {\n color: var(--menu-rail-accent);\n }\n\n /*\n * ── 펼쳐진 최상위 ───────────────────────────────────────────────────────\n * **강조하지 않는다.** 최상위의 active 는 「하위가 펼쳐져 있다」는 여닫힘이고, 지금 페이지는\n * 그 안의 한 줄이다. 둘에 같은 강조를 주면 두 줄이 나란히 강조되어 어느 쪽이 지금 자리인지\n * 흐려진다.\n *\n * 그래도 옅게는 둔다 — 접힌 화살표와 함께 「여기가 열려 있다」를 말한다.\n */\n :host(:not([collapsed])) li[top][active]:not([current]) > a {\n background-color: transparent;\n color: var(--menu-rail-ink);\n }\n\n :host(:not([collapsed])) li[top][active]:not([current]) > a md-icon {\n color: var(--menu-rail-ink);\n }\n\n /*\n * ── 접히면 최상위가 대신 말한다 ─────────────────────────────────────────\n * 하위가 감춰지므로 지금 자리를 말할 줄이 최상위뿐이다. 그때는 최상위가 강조를 받는다.\n */\n :host([collapsed]) li[top][active] > a {\n background-color: var(--menu-rail-accent-wash);\n color: var(--menu-rail-ink);\n }\n\n :host([collapsed]) li[top][active] > a md-icon {\n color: var(--menu-rail-accent);\n }\n\n /*\n * ── 팝업을 연 항목 ─────────────────────────────────────────────────────\n * active(지금 페이지)와 **다른 뜻**이라 다른 표시를 준다. 접힌 레일에서 강조된 아이콘은\n * 「지금 페이지」만 뜻하므로, 이 표시가 없으면 어느 항목의 팝업인지 알 수 없다.\n *\n * 강조색을 쓰지 않는다 — 그 색은 지금 페이지의 것이다. 대신 글자·아이콘을 짙게 하고 팝업과\n * 같은 면색으로 칠해 팝업의 연장으로 읽히게 한다.\n */\n :host li[popped] > a {\n background-color: var(--menu-rail-hover);\n color: var(--menu-rail-ink);\n }\n\n :host li[popped] > a md-icon {\n color: var(--menu-rail-ink);\n }\n\n /* 지금 페이지이면서 팝업까지 연 경우 — 강조색이 이긴다(그것이 더 드문 사실이다). */\n :host li[current][popped] > a,\n :host([collapsed]) li[top][active][popped] > a {\n background-color: var(--menu-rail-accent-wash);\n }\n\n :host li[current][popped] > a md-icon,\n :host([collapsed]) li[top][active][popped] > a md-icon {\n color: var(--menu-rail-accent);\n }\n\n /*\n * ── 하위가 있다는 표시 ──────────────────────────────────────────────────\n * **이 화살표가 곧 「나는 묶음이다」이다.** 없으면 최상위 줄만 보고 하위를 가졌는지, 자기가\n * 끝인지 알 수 없다 — 눌러 봐야 안다.\n *\n * 글자는 md-icon 의 내용이 아니라 여기서 준다. 두 스타일 파일을 합칠 때 이 ::before 를\n * 잃었고, 그래서 <md-icon submenu-button> 이 **빈 채로 남아 화살표가 사라졌다.**\n * 아이콘 이름을 컴포넌트로 옮기지 않는 이유는, 접힘·팝업마다 다르게 두는 것이 이 파일의\n * 일이기 때문이다.\n */\n :host a [submenu-button] {\n margin-left: auto;\n --md-icon-size: 16px;\n width: 16px;\n min-width: 16px;\n color: var(--menu-rail-faint);\n transition: transform 0.2s ease;\n }\n\n :host a [submenu-button]::before {\n content: 'chevron_right';\n }\n\n /* 펼쳐지면 아래를 가리킨다 — 화살표 하나로 여닫힘까지 말한다. */\n :host li[active] > a [submenu-button] {\n transform: rotate(90deg);\n color: var(--menu-rail-ink);\n }\n\n /* 접히면 하위가 팝업으로 뜬다. 56px 안에 화살표를 둘 자리도 없다. */\n :host([collapsed]) > ul li a [submenu-button] {\n display: none;\n }\n\n :host a > span {\n flex: 1;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n /*\n * ── 접으면 이름을 감춘다 — **레일 안쪽만** ─────────────────────────────\n * 팝업은 같은 shadow root 안의 **형제**다(.railpop). 그래서 li 로만 좁히면 팝업 줄도\n * 함께 걸려 **아이콘만 남는다** — 접힌 레일에서 팝업은 이름을 읽으러 여는 자리인데.\n *\n * > ul 로 좁힌다. 레일의 목록과 시스템 목록은 host 의 직계 자식이고, 팝업의 목록은\n * .railpop 안에 있다. 이 한 칸이 두 자리를 가른다.\n */\n :host([collapsed]) > ul li a > span {\n display: none;\n }\n\n :host([collapsed]) > ul li a {\n justify-content: center;\n gap: 0;\n padding: var(--menu-rail-row-pad) 0;\n }\n\n /* ── 하위 메뉴 — 펼친 상태에서는 그 자리에서 열린다 ────────────────── */\n :host li > ul {\n overflow: hidden;\n max-height: 0;\n background: none;\n padding-left: 20px;\n transition: max-height 0.24s ease;\n }\n\n :host li[active] > ul {\n max-height: 640px;\n }\n\n :host li[active] > ul[settled] {\n overflow-y: auto;\n }\n\n :host li li a {\n padding: calc(var(--menu-rail-row-pad) - 2px) 11px;\n font-size: 12.5px;\n color: var(--menu-rail-muted);\n }\n\n\n /*\n * 접히면 하위를 그 자리에서 열지 않는다. 56px 안에서는 들여쓴 목록이 읽히지 않는다.\n * 대신 팝업으로 뜬다 — .railpop 이 그 자리다.\n */\n :host([collapsed]) > ul li > ul {\n display: none;\n }\n\n /* ── 건수 ──────────────────────────────────────────────────────────── */\n /*\n * **메뉴에서 유일하게 색을 갖는 자리다.**\n *\n * 나머지는 무채색이다 — 틀은 자리를 말하고, 색은 「무엇이 나를 기다리나」에만 쓴다. 건수가 바로\n * 그것이라 여기에 제품의 강조색을 준다.\n *\n * 색을 --menu-rail-accent 에서 가져오지 않는다. 그 이름은 지금 페이지 표시에도 쓰이고, 그것까지\n * 강조색이 되면 메뉴 전체가 물든다. 배지만 따로 든다.\n *\n * 순서는 앱 토큰이 먼저다(--ui-accent). md 의 primary 는 앱마다 뜻이 다르다 — operato-twin 에서는\n * 브랜드 청록이지만 operato-plant 에서는 **진회색**이라, primary 를 쓰면 한쪽에서만 색이 나온다.\n * 실제로 그랬다.\n *\n * 글자색은 짝으로 온다. 강조색 위에 흰 글자를 늘 올리면 어두운 테마의 밝은 초록에서 대비가\n * 2.24 로 떨어진다 — 그 테마는 짙은 글자를 짝으로 둔다.\n */\n :host a [badge] {\n margin-left: auto;\n min-width: 18px;\n padding: 0 5px;\n border-radius: 9px;\n background-color: var(--menu-rail-badge, var(--ui-accent, var(--md-sys-color-primary, #0c7a4c)));\n color: var(--menu-rail-badge-ink, var(--ui-on-accent, var(--md-sys-color-on-primary, #ffffff)));\n font-family: var(--theme-font-mono, monospace);\n font-size: 10.5px;\n font-weight: 700;\n line-height: 17px;\n text-align: center;\n }\n\n /*\n * 접히면 라벨이 없어 옆에 둘 자리가 없다. 아이콘 위에 겹친다.\n *\n * 이것도 **레일 안쪽만**이다. 팝업에는 이름이 있으니 건수가 그 오른쪽에 알약으로 선다 —\n * 위의 기본 규칙이 그대로 맞다.\n */\n :host([collapsed]) > ul li[menu] {\n position: relative;\n }\n\n :host([collapsed]) > ul li a [badge] {\n position: absolute;\n top: 0;\n right: 2px;\n margin-left: 0;\n /*\n * 글자를 9.5px 에서 10px 로 올린다. 링이 아이콘을 밀어내 준 만큼 숫자에 자리를 줄 수 있다 —\n * 접힌 레일에서 이 숫자가 유일한 신호이므로 읽히는 것이 먼저다.\n */\n min-width: 15px;\n padding: 0 3px;\n font-size: 10px;\n line-height: 15px;\n /*\n * **배경색 링으로 아이콘을 밀어낸다.**\n *\n * 56px 안에서는 배지가 아이콘 위에 겹칠 수밖에 없다. 겹친 자리에서 아이콘의 선이 배지의\n * 글자를 지나가면 숫자가 안 읽힌다. 링이 그 사이를 끊는다 — 아이콘을 지운 것처럼 보인다.\n *\n * 링 색은 레일의 면색이다. 고른 항목은 배경이 accent-wash 여서 정확히 같지는 않지만, 그\n * 색이 아주 옅어 눈에 걸리지 않는다.\n */\n box-shadow: 0 0 0 2px var(--menu-rail-surface);\n }\n\n /*\n * **값이 아직 안 온 동안**만 자리를 비운다.\n *\n * 알약을 그릴지는 배지 정의가 있나로 정해지고, 그 안의 값은 조금 뒤에 채워진다. 그 사이 빈\n * 알약이 아이콘 위에 뜻 없는 점으로 남는다.\n *\n * **0 은 값이다.** 0 이면 0 을 보인다 — 「기다리는 것이 없다」도 사람이 알아야 하는 사실이고,\n * 그 자리가 비어 있으면 「아직 안 왔나」와 구별되지 않는다.\n */\n :host a [badge]:empty {\n display: none;\n }\n\n /* ── 시스템 자리 ───────────────────────────────────────────────────── */\n /*\n * 빈 자리로 벌린다 — 시스템 항목이 바닥에 붙는다.\n *\n * 목록이 화면보다 길면 이 자리가 0 이 되고 시스템은 목록 끝에 그대로 이어진다.\n * flex: 1 1 auto 가 아니라 flex: 1 1 0 이라야 그렇게 된다 — auto 면 이 빈 칸이 자기\n * 높이를 주장해 목록이 밀린다.\n *\n * **레일 전용이 아니다.** 한때 [rail] 에 걸어 두고 「바닥에 붙이는 것은 폭이 좁을 때의\n * 배치」라고 적었는데, 그건 지어낸 이유였다. 서랍도 화면 높이를 다 쓰므로 시스템이 바닥에\n * 붙는 뜻이 같다.\n */\n :host .railspacer {\n flex: 1 1 0;\n min-height: 0;\n }\n\n /*\n * 시스템 단추의 스타일은 여기에 없다 — **보통 메뉴 줄로 그린다.**\n *\n * 전에는 button.railitem 으로 따로 그리고 그 꾸밈을 여기에 두었다. 그러면 레일이 아닌\n * 모양에서는 꾸밈이 없다. ul > li > a 로 두면 위의 규칙들이 그대로 붙는다 — 여백 · 색 ·\n * hover · 접힘 · 화살표 · 팝업 표시까지.\n */\n\n /* ── 접기 단추 ─────────────────────────────────────────────────────── */\n :host .railtoggle {\n display: flex;\n align-items: center;\n gap: var(--menu-rail-gap);\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 4px;\n padding: 8px 11px;\n border: none;\n border-radius: var(--menu-rail-radius);\n background: none;\n color: var(--menu-rail-faint);\n font: var(--menu-rail-font-weight) var(--menu-rail-font-size) var(--theme-font, inherit);\n text-align: left;\n cursor: pointer;\n }\n\n :host .railtoggle:hover {\n background-color: var(--menu-rail-hover);\n color: var(--menu-rail-ink);\n }\n\n :host .railtoggle md-icon {\n --md-icon-size: var(--menu-rail-icon-size);\n\n flex: none;\n width: 20px;\n min-width: 20px;\n color: inherit;\n }\n\n :host([collapsed]) .railtoggle {\n justify-content: center;\n gap: 0;\n padding: 8px 0;\n }\n\n :host([collapsed]) .railtoggle span {\n display: none;\n }\n\n /*\n * ── 하위 메뉴 팝업 ──────────────────────────────────────────────────────\n * **레일 전용이 아니다.** 접힌 레일에서 하위로 가는 길이기도 하고, 시스템 항목(주소가 아니라\n * 함수)의 자리이기도 하다. 시스템은 어느 모양에서든 팝업으로 열리므로 [rail] 에 걸지 않는다.\n */\n /*\n * fixed 다 — 레일은 overflow-x: hidden 이라 안에서 띄우면 잘린다.\n *\n * 그리고 팝업은 같은 shadow root 안에 있다. 그래서 위의 접힘 규칙들은 **> ul 로 좁혀 둔다** —\n * 좁히지 않으면 a > span 의 display: none 이 팝업 항목의 이름까지 지우고, 가운데 정렬과\n * 여백 0 이 같이 걸려 **아이콘만 남는다.** 한때 li 로만 좁혀 두었는데, 팝업 줄도 li 라서\n * 아무것도 가리지 못했다.\n */\n :host .railpop {\n position: fixed;\n z-index: 60;\n min-width: 190px;\n /*\n * 70vh 였다. 그러면 열여섯 줄짜리 목록이 화면에 다 들어가는데도 스크롤이 생긴다.\n * 자리는 fitPopup 이 맞추므로, 여기서는 **화면이 허락하는 만큼**만 잘라 둔다.\n */\n max-height: calc(100vh - 16px);\n overflow-y: auto;\n box-sizing: border-box;\n padding: 6px;\n /*\n * 테두리는 **선 색**이다. 표면색으로 두면 경계가 안 보여 레일에서 흘러나온 것처럼 읽힌다.\n * 그림자만으로는 밝은 테마에서 약하다.\n */\n border: 1px solid var(--menu-rail-line, var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.15)));\n border-radius: 10px;\n background-color: var(--menu-rail-surface);\n box-shadow: 0 8px 30px rgba(0, 0, 0, 0.22);\n }\n\n /*\n * 노브 — 팝업이 어느 항목에서 열렸는지 가리킨다.\n *\n * 45도 돌린 정사각이다. 왼쪽 두 변만 테두리를 두어 팝업의 왼쪽 선에서 뾰족하게 나오고,\n * 배경은 팝업과 같은 면색이라 팝업의 선이 그 자리에서 끊긴 것처럼 보인다.\n *\n * 자리(top)는 fitKnob 이 정한다. 팝업이 위로 밀려 그 항목이 팝업의 세로 범위 밖에 있으면\n * hidden 이 붙는다 — 아무 데도 가리키지 못하는 화살은 없는 것이 낫다.\n */\n :host .railknob {\n /*\n * **fixed 다 — 팝업 안에 두면 잘린다.** 팝업은 목록이 길 때 스크롤해야 해서\n * overflow-y: auto 이고, 그러면 브라우저가 overflow-x 도 auto 로 계산한다. 노브는 팝업의\n * 왼쪽 선 밖으로 나가야 하므로 그 스크롤 박스에 잘렸다. 자리는 fitKnob 이 화면 좌표로 준다.\n */\n position: fixed;\n z-index: 61;\n width: 10px;\n height: 10px;\n transform: rotate(45deg);\n /*\n * 선과 면이 **팝업과 같다.** 팝업의 왼쪽 선에 반쯤 물리면 그 선이 그 자리에서 뾰족하게\n * 꺾인 것처럼 보인다 — 따로 붙은 장식이 아니라 팝업의 일부다.\n *\n * 선을 한 단 진하게 하고 크게 하고 그림자까지 얹어 봤다. 눈에는 더 잡히는데 팝업에서\n * 떨어진 부품처럼 보였다. 선을 아예 없애 봤더니 팝업의 선이 끊긴 틈으로만 남아 약했다.\n *\n * 안 보였던 원인은 색도 크기도 아니라 **잘림**이었다(position: fixed 로 고쳤다).\n */\n border-left: 1px solid var(--menu-rail-line, var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.15)));\n border-bottom: 1px solid var(--menu-rail-line, var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.15)));\n background-color: var(--menu-rail-surface);\n }\n\n :host .railpop .phead {\n padding: 6px 10px 4px;\n font-family: var(--theme-font-mono, monospace);\n font-size: 9.5px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--menu-rail-faint);\n }\n\n :host .railpop a {\n padding: 8px 10px;\n border-radius: 7px;\n font-size: 12.5px;\n }\n\n /*\n * 팝업 안의 지금 페이지에 대한 규칙은 **여기 없다.**\n *\n * 팝업도 renderMenus 가 그리므로 줄이 li[current] > a 다. 위의 규칙이 그대로 닿는다.\n * 한때 팝업 줄을 a[current] 로 따로 그렸고 그 규칙이 여기 남아 있었는데, 모양이 바뀐 뒤\n * 아무것에도 걸리지 않는 죽은 줄이었다.\n */\n\n /*\n * ── 손가락으로 누르는 화면 ──────────────────────────────────────────────\n * 폭이 아니라 **입력 장치**로 가른다. 좁은 창에서 마우스를 쓰는 사람에게 줄을 키울 이유는\n * 없고, 넓은 태블릿에서 손가락으로 누르는 사람에게는 키워야 한다.\n *\n * 기존 모양은 max-width: 460px 으로 갈랐다. 그건 「좁으면 휴대폰일 것」이라는 짐작이다.\n */\n @media (pointer: coarse) {\n :host {\n --menu-rail-font-size: 15px;\n }\n\n :host a,\n :host .railitem {\n padding: 12px 11px;\n }\n\n :host li li a {\n padding: 10px 11px;\n }\n }\n`\n"]}
@@ -0,0 +1,34 @@
1
+ import { Menu } from './types.js';
2
+ /**
3
+ * 쓸 수 있는 메뉴만 남긴다.
4
+ *
5
+ * ## 왜 순수 함수인가
6
+ *
7
+ * 거르는 일은 **메뉴를 만드는 쪽**이 한다(lite-menu). 그리는 부품은 받은 것을 그릴 뿐이다 —
8
+ * 부품이 권한을 물으면 메뉴를 쓰는 모든 앱이 인증 의존을 물려받고, 자기 게이팅을 가진 앱은
9
+ * 문이 둘이 된다.
10
+ *
11
+ * 그런데 규칙 자체는 두 제품이 같이 쓴다. 그래서 판단만 여기 두고, **누가 권한을 가졌는지는
12
+ * 밖에서 받는다**(`can`). 그러면 이 파일은 auth 를 모르고, 시험은 프로필 없이 돌릴 수 있다.
13
+ *
14
+ * ## 두 규칙
15
+ *
16
+ * ```
17
+ * 부모가 걸리면 자식까지 뺀다 묶음에 못 들어가면 그 안도 갈 수 없다
18
+ * 자식이 다 빠진 부모도 뺀다 눌러도 아무 데도 못 가는 묶음이 남는다
19
+ * ```
20
+ *
21
+ * ## 감추지 않고 뺀다
22
+ *
23
+ * 감춘 메뉴를 두면 주소로는 들어가진다. 그러면 **「안 보이는 것」과 「못 하는 것」이 달라지고**,
24
+ * 사람은 보이는 것으로 무엇을 할 수 있는지 배운다.
25
+ *
26
+ * ## 메뉴는 문이 아니다
27
+ *
28
+ * 이것은 편의다. 못 쓰는 것을 목록에서 빼서 헛걸음을 줄이는 것뿐이고, 실제로 막는 것은 화면과
29
+ * 리졸버다(GraphQL `@privilege`). 그래서 `Menu.privilege` 는 그 지시자의 인자를 **그대로** 받는다 —
30
+ * 이름을 새로 지으면 두 언어가 되고, 두 언어는 갈린다.
31
+ */
32
+ /** 이 요구를 이 사람이 갖췄나. 앱이 준다(auth-base 의 `hasPrivilege`). */
33
+ export type CanUse = (privilege: NonNullable<Menu['privilege']>) => boolean | Promise<boolean>;
34
+ export declare function permittedMenus(menus: Menu[], can: CanUse): Promise<Menu[]>;
@@ -0,0 +1,22 @@
1
+ export async function permittedMenus(menus, can) {
2
+ var _a, _b;
3
+ const permitted = [];
4
+ for (const menu of menus) {
5
+ if ((menu === null || menu === void 0 ? void 0 : menu.privilege) && !(await can(menu.privilege))) {
6
+ continue;
7
+ }
8
+ const children = ((_a = menu === null || menu === void 0 ? void 0 : menu.menus) === null || _a === void 0 ? void 0 : _a.length) ? await permittedMenus(menu.menus, can) : undefined;
9
+ /*
10
+ * 자식을 갖고 있었는데 하나도 안 남았으면 이 줄도 뺀다.
11
+ *
12
+ * **원래 자식이 없던 줄은 그대로 둔다.** 그 줄은 자기 주소로 가는 잎이지 묶음이 아니다 —
13
+ * 여기서 가르지 않으면 잎이 전부 사라진다.
14
+ */
15
+ if (((_b = menu === null || menu === void 0 ? void 0 : menu.menus) === null || _b === void 0 ? void 0 : _b.length) && !(children === null || children === void 0 ? void 0 : children.length)) {
16
+ continue;
17
+ }
18
+ permitted.push(children ? { ...menu, menus: children } : menu);
19
+ }
20
+ return permitted;
21
+ }
22
+ //# sourceMappingURL=menu-privilege.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"menu-privilege.js","sourceRoot":"","sources":["../../src/menu-privilege.ts"],"names":[],"mappings":"AAoCA,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,KAAa,EAAE,GAAW;;IAC7D,MAAM,SAAS,GAAW,EAAE,CAAA;IAE5B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,CAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,SAAS,KAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC;YACpD,SAAQ;QACV,CAAC;QAED,MAAM,QAAQ,GAAG,CAAA,MAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,KAAK,0CAAE,MAAM,EAAC,CAAC,CAAC,MAAM,cAAc,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QAExF;;;;;WAKG;QACH,IAAI,CAAA,MAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,KAAK,0CAAE,MAAM,KAAI,CAAC,CAAA,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAE,MAAM,CAAA,EAAE,CAAC;YAC7C,SAAQ;QACV,CAAC;QAED,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAChE,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC","sourcesContent":["import { Menu } from './types.js'\n\n/**\n * 쓸 수 있는 메뉴만 남긴다.\n *\n * ## 왜 순수 함수인가\n *\n * 거르는 일은 **메뉴를 만드는 쪽**이 한다(lite-menu). 그리는 부품은 받은 것을 그릴 뿐이다 —\n * 부품이 권한을 물으면 메뉴를 쓰는 모든 앱이 인증 의존을 물려받고, 자기 게이팅을 가진 앱은\n * 문이 둘이 된다.\n *\n * 그런데 규칙 자체는 두 제품이 같이 쓴다. 그래서 판단만 여기 두고, **누가 권한을 가졌는지는\n * 밖에서 받는다**(`can`). 그러면 이 파일은 auth 를 모르고, 시험은 프로필 없이 돌릴 수 있다.\n *\n * ## 두 규칙\n *\n * ```\n * 부모가 걸리면 자식까지 뺀다 묶음에 못 들어가면 그 안도 갈 수 없다\n * 자식이 다 빠진 부모도 뺀다 눌러도 아무 데도 못 가는 묶음이 남는다\n * ```\n *\n * ## 감추지 않고 뺀다\n *\n * 감춘 메뉴를 두면 주소로는 들어가진다. 그러면 **「안 보이는 것」과 「못 하는 것」이 달라지고**,\n * 사람은 보이는 것으로 무엇을 할 수 있는지 배운다.\n *\n * ## 메뉴는 문이 아니다\n *\n * 이것은 편의다. 못 쓰는 것을 목록에서 빼서 헛걸음을 줄이는 것뿐이고, 실제로 막는 것은 화면과\n * 리졸버다(GraphQL `@privilege`). 그래서 `Menu.privilege` 는 그 지시자의 인자를 **그대로** 받는다 —\n * 이름을 새로 지으면 두 언어가 되고, 두 언어는 갈린다.\n */\n\n/** 이 요구를 이 사람이 갖췄나. 앱이 준다(auth-base 의 `hasPrivilege`). */\nexport type CanUse = (privilege: NonNullable<Menu['privilege']>) => boolean | Promise<boolean>\n\nexport async function permittedMenus(menus: Menu[], can: CanUse): Promise<Menu[]> {\n const permitted: Menu[] = []\n\n for (const menu of menus) {\n if (menu?.privilege && !(await can(menu.privilege))) {\n continue\n }\n\n const children = menu?.menus?.length ? await permittedMenus(menu.menus, can) : undefined\n\n /*\n * 자식을 갖고 있었는데 하나도 안 남았으면 이 줄도 뺀다.\n *\n * **원래 자식이 없던 줄은 그대로 둔다.** 그 줄은 자기 주소로 가는 잎이지 묶음이 아니다 —\n * 여기서 가르지 않으면 잎이 전부 사라진다.\n */\n if (menu?.menus?.length && !children?.length) {\n continue\n }\n\n permitted.push(children ? { ...menu, menus: children } : menu)\n }\n\n return permitted\n}\n"]}
@@ -8,11 +8,114 @@ export declare class OxMenuPart extends LitElement {
8
8
  private _route;
9
9
  private _menu;
10
10
  orientation?: 'landscape' | 'portrait';
11
+ /**
12
+ * 레일 모드 — 접을 수 있고, 접히면 하위가 팝업으로 뜬다.
13
+ *
14
+ * **옵트인이다.** 켜지 않은 앱(dataset · ecs · gangsters · pms)은 지금과 한 글자도 다르지 않다.
15
+ *
16
+ * 모양이 함께 바뀌는 것은 취향이 아니라 요건이다. 기존 모양은 줄마다 폭 전체를 쓰는 밑줄이 있고
17
+ * 왼쪽 여백이 16px 이라 56px 에서 무너진다(§`menu-rail-styles.ts`).
18
+ *
19
+ * 가로 메뉴(`landscape`)에는 뜻이 없다 — 접을 축이 다르다. 그때는 무시한다.
20
+ */
21
+ rail: boolean;
22
+ /** 접힘 — 아이콘만. 사용자가 고른 것은 브라우저에 남긴다. */
23
+ collapsed: boolean;
24
+ /**
25
+ * 이 메뉴가 **서랍**인가 — 화면 위에 겹쳐 떴다가 닫히는 상태.
26
+ *
27
+ * 메뉴를 감추는 설정을 켰거나 휴대폰이면 그렇다(`lite-menu` 가 판단해 준다).
28
+ *
29
+ * ── 왜 알아야 하나 ──────────────────────────────────────────────────────
30
+ * 최상위 항목은 자기 대표 화면의 주소를 갖는다. 늘 보이는 메뉴에서는 그것이 도움이 된다 —
31
+ * 누르면 어디든 도착한다.
32
+ *
33
+ * 그런데 서랍에서는 **누르는 순간 서랍이 닫힌다.** 그러면 그 묶음의 나머지 하위를 볼 기회가
34
+ * 없다. 휴대폰에서 「작업」을 누르면 작업지시로 튀고 나머지 다섯은 못 본다.
35
+ *
36
+ * 그래서 서랍에서는 최상위가 **펼치기만** 한다. 하위를 눌러야 이동한다.
37
+ */
38
+ drawer: boolean;
39
+ private static readonly LS_KEY;
40
+ /**
41
+ * 손잡이가 박아 둔 인라인 폭.
42
+ *
43
+ * `ox-nav-bar` 의 리사이즈 손잡이는 `div[navbar]` 의 **자식에 직접** `style.width` 를 쓴다
44
+ * (`ox-nav-bar.ts` 의 `resizeDrag`). 인라인 값은 이 컴포넌트의 `:host([collapsed])` 규칙을
45
+ * 이기므로, **한 번이라도 폭을 끌어 놓은 사용자는 접어도 폭이 그대로다.** 접을 때 치우고 펼칠 때
46
+ * 되돌린다.
47
+ */
48
+ private _draggedWidth;
49
+ private _watchWidth?;
50
+ connectedCallback(): void;
51
+ disconnectedCallback(): void;
52
+ /**
53
+ * 너무 좁아졌으면 접는다.
54
+ *
55
+ * ── 끌어 놓은 폭을 기억하지 않는다 ──────────────────────────────────────
56
+ * 손으로 접을 때는 그 폭을 적어 두고 펼칠 때 되돌린다. 그런데 **스스로 접은 경우에는 그
57
+ * 폭이 「너무 좁은 폭」이다.** 되돌리면 펼치는 순간 다시 접혀 아무리 눌러도 펼쳐지지 않는다.
58
+ */
59
+ private collapseIfTooNarrow;
60
+ /**
61
+ * 접혔으면 폭 손잡이를 감춘다.
62
+ *
63
+ * ── 왜 감추나 ──────────────────────────────────────────────────────────
64
+ * 아이콘만 남은 레일의 폭을 바꿀 이유가 없다. 그리고 **끌면 실제로 망가진다** — 손잡이는
65
+ * 인라인 `style.width` 를 쓰고(`ox-nav-bar` 의 `resizeDrag`), 인라인은 이 컴포넌트의
66
+ * `:host([collapsed])` 규칙을 이긴다. 그래서 접힌 레일을 300px 로 늘려 빈 칸을 만들 수 있다.
67
+ *
68
+ * ── 왜 남의 요소를 건드리나 ────────────────────────────────────────────
69
+ * 손잡이는 `ox-nav-bar` 가 `div[navbar]` 의 **형제**로 그린다. 그 층은 안에 무엇이 들었는지
70
+ * 모르므로 접힘을 알 수 없다. 접힘을 아는 쪽은 여기다. 이미 손잡이가 쓴 인라인 폭을 여기서
71
+ * 치우고 있으므로, 그 짝을 같은 자리에 둔다.
72
+ */
73
+ private syncSplitter;
74
+ protected updated(): void;
75
+ /**
76
+ * 접기/펼치기.
77
+ *
78
+ * 폭이 바뀌면 캔버스(씬)와 그리스트가 자기 크기를 다시 재야 하므로 `resize` 를 낸다.
79
+ * `twin-nav-rail` 이 같은 자리에서 같은 일을 한다.
80
+ */
81
+ private toggleCollapsed;
82
+ /** 접힘을 정하고 남긴다. 스스로 접을 때와 손으로 접을 때가 이 뒷부분을 함께 쓴다. */
83
+ private setCollapsed;
11
84
  _activeTopLevel?: Menu;
12
85
  _activeMenu?: Menu;
13
86
  _path?: string;
87
+ /**
88
+ * 사용자가 손으로 정한 펼침.
89
+ *
90
+ * ```
91
+ * undefined 정한 적 없다 — 주소가 가리키는 것을 쓴다
92
+ * null 닫았다
93
+ * Menu 그 묶음을 열었다
94
+ * ```
95
+ *
96
+ * ── 왜 이 값이 필요한가 ─────────────────────────────────────────────────
97
+ * 펼침 상태는 **주소에서 계산된다** — `willUpdate` 가 매 렌더마다 `findActivePage()` 를 불러
98
+ * `_activeTopLevel` 을 주소가 가리키는 메뉴로 다시 넣는다. 그래서 손으로 무엇을 열거나 닫아도
99
+ * **그리기 전에 되돌아왔다.** 여닫는 코드는 처음부터 있었는데 한 번도 살아 있지 못했다.
100
+ *
101
+ * 다른 최상위를 누를 때 열리는 것처럼 보였던 것은 여닫힘이 아니라 **이동**이었다. 링크가 주소를
102
+ * 바꾸고, 바뀐 주소로 다른 메뉴가 계산된 것이다. 그래서 화살표만 눌러 이동 없이 열려고 하면
103
+ * 아무 일도 안 일어났다.
104
+ *
105
+ * 한때 「닫았다」만 기억했다. 그러면 접기는 되지만 **다른 묶음을 열 수는 없었다** — 그 방향도
106
+ * 같은 이유로 되돌려진다. 세 값으로 두면 두 방향이 다 산다.
107
+ *
108
+ * **주소가 바뀌면 잊는다** — 새 자리에서는 그 자리의 메뉴가 열려 있어야 한다.
109
+ */
110
+ private _userOpened;
14
111
  render(): import("lit-html").TemplateResult<1>;
15
112
  firstUpdated(): void;
16
113
  protected willUpdate(): void;
114
+ /**
115
+ * 지금 자리의 메뉴를 다시 정한다.
116
+ *
117
+ * 규칙은 `menu-active.ts` 에 있다 — 순수 함수라 시험으로 못박혀 있다. 여기서는 라우터가 준
118
+ * 것을 넘기고 결과를 상태에 넣는 일만 한다.
119
+ */
17
120
  private findActivePage;
18
121
  }
@@ -1,3 +1,4 @@
1
+ var OxMenuPart_1;
1
2
  import { __decorate } from "tslib";
2
3
  import '@material/web/icon/icon.js';
3
4
  import './ox-menu-portrait.js';
@@ -6,16 +7,167 @@ import { css, html, LitElement } from 'lit';
6
7
  import { customElement, property, state } from 'lit/decorators.js';
7
8
  import { RouteController, MenuController } from '@operato/shell';
8
9
  import { ScrollbarStyles } from '@operato/styles';
9
- function isActiveMenu(menu, path) {
10
- var _a;
11
- return (((_a = menu.path) === null || _a === void 0 ? void 0 : _a.split('?')[0]) === path ||
12
- (menu.active && typeof menu.active === 'function' && menu.active.call(menu, { path })));
13
- }
14
- let OxMenuPart = class OxMenuPart extends LitElement {
10
+ import { resolveActive, joinedPath } from './menu-active.js';
11
+ import { shouldAutoCollapse } from './menu-mode.js';
12
+ let OxMenuPart = OxMenuPart_1 = class OxMenuPart extends LitElement {
15
13
  constructor() {
16
14
  super(...arguments);
17
15
  this._route = new RouteController(this);
18
16
  this._menu = new MenuController(this);
17
+ /**
18
+ * 레일 모드 — 접을 수 있고, 접히면 하위가 팝업으로 뜬다.
19
+ *
20
+ * **옵트인이다.** 켜지 않은 앱(dataset · ecs · gangsters · pms)은 지금과 한 글자도 다르지 않다.
21
+ *
22
+ * 모양이 함께 바뀌는 것은 취향이 아니라 요건이다. 기존 모양은 줄마다 폭 전체를 쓰는 밑줄이 있고
23
+ * 왼쪽 여백이 16px 이라 56px 에서 무너진다(§`menu-rail-styles.ts`).
24
+ *
25
+ * 가로 메뉴(`landscape`)에는 뜻이 없다 — 접을 축이 다르다. 그때는 무시한다.
26
+ */
27
+ this.rail = false;
28
+ /** 접힘 — 아이콘만. 사용자가 고른 것은 브라우저에 남긴다. */
29
+ this.collapsed = false;
30
+ /**
31
+ * 이 메뉴가 **서랍**인가 — 화면 위에 겹쳐 떴다가 닫히는 상태.
32
+ *
33
+ * 메뉴를 감추는 설정을 켰거나 휴대폰이면 그렇다(`lite-menu` 가 판단해 준다).
34
+ *
35
+ * ── 왜 알아야 하나 ──────────────────────────────────────────────────────
36
+ * 최상위 항목은 자기 대표 화면의 주소를 갖는다. 늘 보이는 메뉴에서는 그것이 도움이 된다 —
37
+ * 누르면 어디든 도착한다.
38
+ *
39
+ * 그런데 서랍에서는 **누르는 순간 서랍이 닫힌다.** 그러면 그 묶음의 나머지 하위를 볼 기회가
40
+ * 없다. 휴대폰에서 「작업」을 누르면 작업지시로 튀고 나머지 다섯은 못 본다.
41
+ *
42
+ * 그래서 서랍에서는 최상위가 **펼치기만** 한다. 하위를 눌러야 이동한다.
43
+ */
44
+ this.drawer = false;
45
+ /**
46
+ * 손잡이가 박아 둔 인라인 폭.
47
+ *
48
+ * `ox-nav-bar` 의 리사이즈 손잡이는 `div[navbar]` 의 **자식에 직접** `style.width` 를 쓴다
49
+ * (`ox-nav-bar.ts` 의 `resizeDrag`). 인라인 값은 이 컴포넌트의 `:host([collapsed])` 규칙을
50
+ * 이기므로, **한 번이라도 폭을 끌어 놓은 사용자는 접어도 폭이 그대로다.** 접을 때 치우고 펼칠 때
51
+ * 되돌린다.
52
+ */
53
+ this._draggedWidth = null;
54
+ }
55
+ connectedCallback() {
56
+ super.connectedCallback();
57
+ try {
58
+ this.collapsed = localStorage.getItem(OxMenuPart_1.LS_KEY) === '1';
59
+ }
60
+ catch (_a) {
61
+ /* 막힐 수 있다(사생활 보호 모드). 펼쳐진 상태가 안전한 기본이다. */
62
+ }
63
+ /*
64
+ * 손잡이로 좁게 끌면 스스로 접는다.
65
+ *
66
+ * 폭을 재는 자리는 여기다 — 손잡이는 이 요소에 인라인 폭을 쓴다. 판정은
67
+ * `shouldAutoCollapse` 가 하고(시험으로 못박혀 있다) 여기서는 폭을 넘기고 결과를 따른다.
68
+ */
69
+ if (typeof ResizeObserver !== 'undefined') {
70
+ this._watchWidth = new ResizeObserver(() => this.collapseIfTooNarrow());
71
+ this._watchWidth.observe(this);
72
+ }
73
+ }
74
+ disconnectedCallback() {
75
+ var _a;
76
+ super.disconnectedCallback();
77
+ (_a = this._watchWidth) === null || _a === void 0 ? void 0 : _a.disconnect();
78
+ }
79
+ /**
80
+ * 너무 좁아졌으면 접는다.
81
+ *
82
+ * ── 끌어 놓은 폭을 기억하지 않는다 ──────────────────────────────────────
83
+ * 손으로 접을 때는 그 폭을 적어 두고 펼칠 때 되돌린다. 그런데 **스스로 접은 경우에는 그
84
+ * 폭이 「너무 좁은 폭」이다.** 되돌리면 펼치는 순간 다시 접혀 아무리 눌러도 펼쳐지지 않는다.
85
+ */
86
+ collapseIfTooNarrow() {
87
+ if (!this.rail) {
88
+ return;
89
+ }
90
+ /*
91
+ * ── 접힌 뒤에는 손잡이가 더 써도 듣지 않는다 ──────────────────────────
92
+ * 손잡이는 **끄는 동안 매 이동마다** 인라인 폭을 쓴다(`ox-nav-bar` 의 `resizeDrag`). 그래서
93
+ * 접으면서 그 폭을 한 번 치워도 다음 이동이 곧바로 다시 쓴다 — 접힌 채로 폭이 계속 따라와
94
+ * 「접혔는데 안 접힌」 상태가 된다.
95
+ *
96
+ * 들어오는 대로 치운다. 사용자가 손을 떼지 않아도 그 자리에서 멈춘다.
97
+ */
98
+ if (this.collapsed) {
99
+ if (this.style.width) {
100
+ this.style.width = '';
101
+ }
102
+ return;
103
+ }
104
+ if (!shouldAutoCollapse(this.offsetWidth, { rail: true, collapsed: false })) {
105
+ return;
106
+ }
107
+ /*
108
+ * 스스로 접을 때는 끌어 놓은 폭을 **기억하지 않는다.** 그 폭이 「너무 좁은 폭」이므로,
109
+ * 되돌리면 펼치는 순간 다시 접혀 아무리 눌러도 펼쳐지지 않는다.
110
+ */
111
+ this._draggedWidth = null;
112
+ this.style.width = '';
113
+ this.setCollapsed(true);
114
+ }
115
+ /**
116
+ * 접혔으면 폭 손잡이를 감춘다.
117
+ *
118
+ * ── 왜 감추나 ──────────────────────────────────────────────────────────
119
+ * 아이콘만 남은 레일의 폭을 바꿀 이유가 없다. 그리고 **끌면 실제로 망가진다** — 손잡이는
120
+ * 인라인 `style.width` 를 쓰고(`ox-nav-bar` 의 `resizeDrag`), 인라인은 이 컴포넌트의
121
+ * `:host([collapsed])` 규칙을 이긴다. 그래서 접힌 레일을 300px 로 늘려 빈 칸을 만들 수 있다.
122
+ *
123
+ * ── 왜 남의 요소를 건드리나 ────────────────────────────────────────────
124
+ * 손잡이는 `ox-nav-bar` 가 `div[navbar]` 의 **형제**로 그린다. 그 층은 안에 무엇이 들었는지
125
+ * 모르므로 접힘을 알 수 없다. 접힘을 아는 쪽은 여기다. 이미 손잡이가 쓴 인라인 폭을 여기서
126
+ * 치우고 있으므로, 그 짝을 같은 자리에 둔다.
127
+ */
128
+ syncSplitter() {
129
+ var _a, _b;
130
+ if (!this.rail) {
131
+ return;
132
+ }
133
+ const splitter = (_a = this.parentElement) === null || _a === void 0 ? void 0 : _a.nextElementSibling;
134
+ if (((_b = splitter === null || splitter === void 0 ? void 0 : splitter.tagName) === null || _b === void 0 ? void 0 : _b.toLowerCase()) === 'ox-resize-splitter') {
135
+ splitter.hidden = this.collapsed;
136
+ }
137
+ }
138
+ updated() {
139
+ this.syncSplitter();
140
+ }
141
+ /**
142
+ * 접기/펼치기.
143
+ *
144
+ * 폭이 바뀌면 캔버스(씬)와 그리스트가 자기 크기를 다시 재야 하므로 `resize` 를 낸다.
145
+ * `twin-nav-rail` 이 같은 자리에서 같은 일을 한다.
146
+ */
147
+ toggleCollapsed() {
148
+ const next = !this.collapsed;
149
+ if (next) {
150
+ if (this.style.width) {
151
+ this._draggedWidth = this.style.width;
152
+ this.style.width = '';
153
+ }
154
+ }
155
+ else if (this._draggedWidth) {
156
+ this.style.width = this._draggedWidth;
157
+ this._draggedWidth = null;
158
+ }
159
+ this.setCollapsed(next);
160
+ }
161
+ /** 접힘을 정하고 남긴다. 스스로 접을 때와 손으로 접을 때가 이 뒷부분을 함께 쓴다. */
162
+ setCollapsed(next) {
163
+ this.collapsed = next;
164
+ try {
165
+ localStorage.setItem(OxMenuPart_1.LS_KEY, next ? '1' : '0');
166
+ }
167
+ catch (_a) {
168
+ /* 못 남기면 다음에 펼쳐진 채로 뜬다. 기능은 그대로다. */
169
+ }
170
+ window.dispatchEvent(new Event('resize'));
19
171
  }
20
172
  render() {
21
173
  const menus = this._menu.menus;
@@ -27,6 +179,9 @@ let OxMenuPart = class OxMenuPart extends LitElement {
27
179
  .activeTopLevel=${this._activeTopLevel}
28
180
  .activeMenu=${this._activeMenu}
29
181
  .path=${this._path}
182
+ ?rail=${this.rail}
183
+ ?collapsed=${this.rail && this.collapsed}
184
+ ?drawer=${this.drawer}
30
185
  ></ox-menu-portrait>`
31
186
  : html `<ox-menu-landscape
32
187
  .menus=${menus}
@@ -41,7 +196,21 @@ let OxMenuPart = class OxMenuPart extends LitElement {
41
196
  this.renderRoot.addEventListener('active-toplevel', (e) => {
42
197
  e.stopPropagation();
43
198
  e.preventDefault();
44
- this._activeTopLevel = e.detail;
199
+ /*
200
+ * **`detail: undefined` 는 `null` 로 도착한다.**
201
+ *
202
+ * `CustomEvent` 의 `detail` 은 기본값이 `null` 이라, 보내는 쪽이 `undefined` 를 넣어도 받는
203
+ * 쪽에서는 `null` 이다. 그래서 `next === undefined` 로 「접겠다는 뜻」을 가리면 한 번도 맞지
204
+ * 않는다 — 값이 있나 없나로 가른다.
205
+ */
206
+ const next = e.detail || undefined;
207
+ /* 열었든 닫았든 손으로 정한 것을 적어 둔다 — 안 적으면 주소가 곧 되돌린다. */
208
+ this._userOpened = next !== null && next !== void 0 ? next : null;
209
+ this._activeTopLevel = next;
210
+ });
211
+ this.renderRoot.addEventListener('collapse-toggle', (e) => {
212
+ e.stopPropagation();
213
+ this.toggleCollapsed();
45
214
  });
46
215
  }
47
216
  willUpdate() {
@@ -57,23 +226,24 @@ let OxMenuPart = class OxMenuPart extends LitElement {
57
226
  this.replaceChild(slotTemplate, this.renderRoot);
58
227
  }
59
228
  }
229
+ /**
230
+ * 지금 자리의 메뉴를 다시 정한다.
231
+ *
232
+ * 규칙은 `menu-active.ts` 에 있다 — 순수 함수라 시험으로 못박혀 있다. 여기서는 라우터가 준
233
+ * 것을 넘기고 결과를 상태에 넣는 일만 한다.
234
+ */
60
235
  findActivePage() {
61
- const { page, resourceId } = this._route;
62
- var path = resourceId ? `${page}/${resourceId}` : page;
63
- var menus = this._menu.menus || [];
64
- var activeMenu;
65
- this._activeTopLevel = menus.find(menu => {
66
- if (isActiveMenu(menu, path)) {
67
- activeMenu = menu;
68
- return true;
69
- }
70
- else if (menu.menus) {
71
- activeMenu = menu.menus.find((menu) => isActiveMenu(menu, path));
72
- return !!activeMenu;
73
- }
74
- });
236
+ const { page, resourceId, context } = this._route;
237
+ const where = { page, resourceId, context };
238
+ const path = joinedPath(where);
239
+ /* 자리를 옮겼으면 손으로 정한 것을 잊는다 — 새 자리에서는 그 자리의 메뉴가 열려 있어야 한다. */
240
+ if (this._path !== undefined && path !== this._path) {
241
+ this._userOpened = undefined;
242
+ }
243
+ const { top, menu } = resolveActive(this._menu.menus || [], where, this._userOpened);
75
244
  this._path = path;
76
- this._activeMenu = activeMenu || this._activeTopLevel;
245
+ this._activeTopLevel = top;
246
+ this._activeMenu = menu;
77
247
  }
78
248
  };
79
249
  OxMenuPart.styles = [
@@ -95,15 +265,46 @@ OxMenuPart.styles = [
95
265
  min-height: 20px;
96
266
  }
97
267
 
268
+ /*
269
+ * 레일 모드 — 폭의 주인이 여기다.
270
+ *
271
+ * min-width: 200px 이 접힘을 막는다. 안쪽(ox-menu-portrait)만 줄여도 겉이 200px 로 남아
272
+ * 아이콘 옆이 빈 칸이 된다. 그래서 접힘 상태를 이 요소가 갖는다 — 셸이 크기를 재는 요소도
273
+ * 이것이고, 리사이즈 손잡이가 인라인 폭을 쓰는 요소도 이것이다.
274
+ *
275
+ * 가로만 감춘다. overflow: hidden 으로 두면 **긴 메뉴의 아래쪽에 갈 수 없다** — 이 앱의
276
+ * 메뉴는 최상위가 일곱이고 그 아래가 더 있다.
277
+ */
278
+ :host([rail]) {
279
+ min-width: 0;
280
+ width: var(--menu-rail-width, 208px);
281
+ overflow-x: hidden;
282
+ overflow-y: auto;
283
+ }
284
+
285
+ :host([rail][collapsed]) {
286
+ width: var(--menu-rail-width-collapsed, 56px);
287
+ }
288
+
98
289
  ox-menu-portrait,
99
290
  ox-menu-landscape {
100
291
  flex: 1;
101
292
  }
102
293
  `
103
294
  ];
295
+ OxMenuPart.LS_KEY = 'ox-menu.collapsed';
104
296
  __decorate([
105
297
  property({ type: String })
106
298
  ], OxMenuPart.prototype, "orientation", void 0);
299
+ __decorate([
300
+ property({ type: Boolean, reflect: true })
301
+ ], OxMenuPart.prototype, "rail", void 0);
302
+ __decorate([
303
+ property({ type: Boolean, reflect: true })
304
+ ], OxMenuPart.prototype, "collapsed", void 0);
305
+ __decorate([
306
+ property({ type: Boolean, reflect: true })
307
+ ], OxMenuPart.prototype, "drawer", void 0);
107
308
  __decorate([
108
309
  state()
109
310
  ], OxMenuPart.prototype, "_activeTopLevel", void 0);
@@ -113,7 +314,7 @@ __decorate([
113
314
  __decorate([
114
315
  state()
115
316
  ], OxMenuPart.prototype, "_path", void 0);
116
- OxMenuPart = __decorate([
317
+ OxMenuPart = OxMenuPart_1 = __decorate([
117
318
  customElement('ox-menu-part')
118
319
  ], OxMenuPart);
119
320
  export { OxMenuPart };
@@ -1 +1 @@
1
- {"version":3,"file":"ox-menu-part.js","sourceRoot":"","sources":["../../src/ox-menu-part.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport './ox-menu-portrait.js'\nimport './ox-menu-landscape.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\nimport { RouteController, MenuController } from '@operato/shell'\nimport { ScrollbarStyles } from '@operato/styles'\n\nimport { Menu } from './types.js'\n\nfunction isActiveMenu(menu: Menu, path: string) {\n return (\n menu.path?.split('?')[0] === path ||\n (menu.active && typeof menu.active === 'function' && menu.active.call(menu, { path }))\n )\n}\n\n@customElement('ox-menu-part')\nexport class OxMenuPart extends LitElement {\n static styles = [\n ScrollbarStyles,\n css`\n :host {\n display: flex;\n overflow-y: auto;\n flex-direction: column;\n height: 100%;\n min-width: 200px;\n background-color: var(--md-sys-color-surface);\n }\n\n :host([landscape]) {\n overflow-x: auto;\n flex-direction: row;\n width: 100%;\n min-height: 20px;\n }\n\n ox-menu-portrait,\n ox-menu-landscape {\n flex: 1;\n }\n `\n ]\n\n private _route = new RouteController(this)\n private _menu = new MenuController(this)\n\n @property({ type: String }) orientation?: 'landscape' | 'portrait'\n\n @state() _activeTopLevel?: Menu\n @state() _activeMenu?: Menu\n @state() _path?: string\n\n render() {\n const menus = this._menu.menus\n\n return html`\n <slot name=\"head\"></slot>\n ${this.orientation !== 'landscape'\n ? html`<ox-menu-portrait\n .menus=${menus}\n .activeTopLevel=${this._activeTopLevel}\n .activeMenu=${this._activeMenu}\n .path=${this._path}\n ></ox-menu-portrait>`\n : html`<ox-menu-landscape\n .menus=${menus}\n .activeTopLevel=${this._activeTopLevel}\n .activeMenu=${this._activeMenu}\n .path=${this._path}\n ></ox-menu-landscape>`}\n <slot name=\"tail\"></slot>\n `\n }\n\n firstUpdated() {\n this.renderRoot.addEventListener('active-toplevel', (e: Event) => {\n e.stopPropagation()\n e.preventDefault()\n\n this._activeTopLevel = (e as CustomEvent).detail\n })\n }\n\n protected willUpdate() {\n this.findActivePage()\n\n if (this.orientation == 'portrait') {\n this.removeAttribute('landscape')\n } else if (this.orientation == 'landscape') {\n this.setAttribute('landscape', '')\n }\n\n const { slotTemplate } = this._menu\n if (slotTemplate) {\n this.replaceChild(slotTemplate, this.renderRoot)\n }\n }\n\n private findActivePage() {\n const { page, resourceId } = this._route\n var path = resourceId ? `${page}/${resourceId}` : page\n var menus = this._menu.menus || []\n var activeMenu\n\n this._activeTopLevel = menus.find(menu => {\n if (isActiveMenu(menu, path)) {\n activeMenu = menu\n return true\n } else if (menu.menus) {\n activeMenu = menu.menus.find((menu: Menu) => isActiveMenu(menu, path))\n return !!activeMenu\n }\n })\n\n this._path = path\n this._activeMenu = activeMenu || this._activeTopLevel\n }\n}\n"]}
1
+ {"version":3,"file":"ox-menu-part.js","sourceRoot":"","sources":["../../src/ox-menu-part.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport './ox-menu-portrait.js'\nimport './ox-menu-landscape.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\nimport { RouteController, MenuController } from '@operato/shell'\nimport { ScrollbarStyles } from '@operato/styles'\n\nimport { Menu } from './types.js'\nimport { resolveActive, joinedPath, type UserOpened } from './menu-active.js'\nimport { shouldAutoCollapse } from './menu-mode.js'\n\n@customElement('ox-menu-part')\nexport class OxMenuPart extends LitElement {\n static styles = [\n ScrollbarStyles,\n css`\n :host {\n display: flex;\n overflow-y: auto;\n flex-direction: column;\n height: 100%;\n min-width: 200px;\n background-color: var(--md-sys-color-surface);\n }\n\n :host([landscape]) {\n overflow-x: auto;\n flex-direction: row;\n width: 100%;\n min-height: 20px;\n }\n\n /*\n * 레일 모드 — 폭의 주인이 여기다.\n *\n * min-width: 200px 이 접힘을 막는다. 안쪽(ox-menu-portrait)만 줄여도 겉이 200px 로 남아\n * 아이콘 옆이 빈 칸이 된다. 그래서 접힘 상태를 이 요소가 갖는다 — 셸이 크기를 재는 요소도\n * 이것이고, 리사이즈 손잡이가 인라인 폭을 쓰는 요소도 이것이다.\n *\n * 가로만 감춘다. overflow: hidden 으로 두면 **긴 메뉴의 아래쪽에 갈 수 없다** — 이 앱의\n * 메뉴는 최상위가 일곱이고 그 아래가 더 있다.\n */\n :host([rail]) {\n min-width: 0;\n width: var(--menu-rail-width, 208px);\n overflow-x: hidden;\n overflow-y: auto;\n }\n\n :host([rail][collapsed]) {\n width: var(--menu-rail-width-collapsed, 56px);\n }\n\n ox-menu-portrait,\n ox-menu-landscape {\n flex: 1;\n }\n `\n ]\n\n private _route = new RouteController(this)\n private _menu = new MenuController(this)\n\n @property({ type: String }) orientation?: 'landscape' | 'portrait'\n\n /**\n * 레일 모드 — 접을 수 있고, 접히면 하위가 팝업으로 뜬다.\n *\n * **옵트인이다.** 켜지 않은 앱(dataset · ecs · gangsters · pms)은 지금과 한 글자도 다르지 않다.\n *\n * 모양이 함께 바뀌는 것은 취향이 아니라 요건이다. 기존 모양은 줄마다 폭 전체를 쓰는 밑줄이 있고\n * 왼쪽 여백이 16px 이라 56px 에서 무너진다(§`menu-rail-styles.ts`).\n *\n * 가로 메뉴(`landscape`)에는 뜻이 없다 — 접을 축이 다르다. 그때는 무시한다.\n */\n @property({ type: Boolean, reflect: true }) rail = false\n\n /** 접힘 — 아이콘만. 사용자가 고른 것은 브라우저에 남긴다. */\n @property({ type: Boolean, reflect: true }) collapsed = false\n\n /**\n * 이 메뉴가 **서랍**인가 — 화면 위에 겹쳐 떴다가 닫히는 상태.\n *\n * 메뉴를 감추는 설정을 켰거나 휴대폰이면 그렇다(`lite-menu` 가 판단해 준다).\n *\n * ── 왜 알아야 하나 ──────────────────────────────────────────────────────\n * 최상위 항목은 자기 대표 화면의 주소를 갖는다. 늘 보이는 메뉴에서는 그것이 도움이 된다 —\n * 누르면 어디든 도착한다.\n *\n * 그런데 서랍에서는 **누르는 순간 서랍이 닫힌다.** 그러면 그 묶음의 나머지 하위를 볼 기회가\n * 없다. 휴대폰에서 「작업」을 누르면 작업지시로 튀고 나머지 다섯은 못 본다.\n *\n * 그래서 서랍에서는 최상위가 **펼치기만** 한다. 하위를 눌러야 이동한다.\n */\n @property({ type: Boolean, reflect: true }) drawer = false\n\n private static readonly LS_KEY = 'ox-menu.collapsed'\n\n /**\n * 손잡이가 박아 둔 인라인 폭.\n *\n * `ox-nav-bar` 의 리사이즈 손잡이는 `div[navbar]` 의 **자식에 직접** `style.width` 를 쓴다\n * (`ox-nav-bar.ts` 의 `resizeDrag`). 인라인 값은 이 컴포넌트의 `:host([collapsed])` 규칙을\n * 이기므로, **한 번이라도 폭을 끌어 놓은 사용자는 접어도 폭이 그대로다.** 접을 때 치우고 펼칠 때\n * 되돌린다.\n */\n private _draggedWidth: string | null = null\n\n private _watchWidth?: ResizeObserver\n\n connectedCallback() {\n super.connectedCallback()\n\n try {\n this.collapsed = localStorage.getItem(OxMenuPart.LS_KEY) === '1'\n } catch {\n /* 막힐 수 있다(사생활 보호 모드). 펼쳐진 상태가 안전한 기본이다. */\n }\n\n /*\n * 손잡이로 좁게 끌면 스스로 접는다.\n *\n * 폭을 재는 자리는 여기다 — 손잡이는 이 요소에 인라인 폭을 쓴다. 판정은\n * `shouldAutoCollapse` 가 하고(시험으로 못박혀 있다) 여기서는 폭을 넘기고 결과를 따른다.\n */\n if (typeof ResizeObserver !== 'undefined') {\n this._watchWidth = new ResizeObserver(() => this.collapseIfTooNarrow())\n this._watchWidth.observe(this)\n }\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this._watchWidth?.disconnect()\n }\n\n /**\n * 너무 좁아졌으면 접는다.\n *\n * ── 끌어 놓은 폭을 기억하지 않는다 ──────────────────────────────────────\n * 손으로 접을 때는 그 폭을 적어 두고 펼칠 때 되돌린다. 그런데 **스스로 접은 경우에는 그\n * 폭이 「너무 좁은 폭」이다.** 되돌리면 펼치는 순간 다시 접혀 아무리 눌러도 펼쳐지지 않는다.\n */\n private collapseIfTooNarrow() {\n if (!this.rail) {\n return\n }\n\n /*\n * ── 접힌 뒤에는 손잡이가 더 써도 듣지 않는다 ──────────────────────────\n * 손잡이는 **끄는 동안 매 이동마다** 인라인 폭을 쓴다(`ox-nav-bar` 의 `resizeDrag`). 그래서\n * 접으면서 그 폭을 한 번 치워도 다음 이동이 곧바로 다시 쓴다 — 접힌 채로 폭이 계속 따라와\n * 「접혔는데 안 접힌」 상태가 된다.\n *\n * 들어오는 대로 치운다. 사용자가 손을 떼지 않아도 그 자리에서 멈춘다.\n */\n if (this.collapsed) {\n if (this.style.width) {\n this.style.width = ''\n }\n\n return\n }\n\n if (!shouldAutoCollapse(this.offsetWidth, { rail: true, collapsed: false })) {\n return\n }\n\n /*\n * 스스로 접을 때는 끌어 놓은 폭을 **기억하지 않는다.** 그 폭이 「너무 좁은 폭」이므로,\n * 되돌리면 펼치는 순간 다시 접혀 아무리 눌러도 펼쳐지지 않는다.\n */\n this._draggedWidth = null\n this.style.width = ''\n this.setCollapsed(true)\n }\n\n /**\n * 접혔으면 폭 손잡이를 감춘다.\n *\n * ── 왜 감추나 ──────────────────────────────────────────────────────────\n * 아이콘만 남은 레일의 폭을 바꿀 이유가 없다. 그리고 **끌면 실제로 망가진다** — 손잡이는\n * 인라인 `style.width` 를 쓰고(`ox-nav-bar` 의 `resizeDrag`), 인라인은 이 컴포넌트의\n * `:host([collapsed])` 규칙을 이긴다. 그래서 접힌 레일을 300px 로 늘려 빈 칸을 만들 수 있다.\n *\n * ── 왜 남의 요소를 건드리나 ────────────────────────────────────────────\n * 손잡이는 `ox-nav-bar` 가 `div[navbar]` 의 **형제**로 그린다. 그 층은 안에 무엇이 들었는지\n * 모르므로 접힘을 알 수 없다. 접힘을 아는 쪽은 여기다. 이미 손잡이가 쓴 인라인 폭을 여기서\n * 치우고 있으므로, 그 짝을 같은 자리에 둔다.\n */\n private syncSplitter() {\n if (!this.rail) {\n return\n }\n\n const splitter = this.parentElement?.nextElementSibling as HTMLElement | undefined\n\n if (splitter?.tagName?.toLowerCase() === 'ox-resize-splitter') {\n splitter.hidden = this.collapsed\n }\n }\n\n protected updated() {\n this.syncSplitter()\n }\n\n /**\n * 접기/펼치기.\n *\n * 폭이 바뀌면 캔버스(씬)와 그리스트가 자기 크기를 다시 재야 하므로 `resize` 를 낸다.\n * `twin-nav-rail` 이 같은 자리에서 같은 일을 한다.\n */\n private toggleCollapsed() {\n const next = !this.collapsed\n\n if (next) {\n if (this.style.width) {\n this._draggedWidth = this.style.width\n this.style.width = ''\n }\n } else if (this._draggedWidth) {\n this.style.width = this._draggedWidth\n this._draggedWidth = null\n }\n\n this.setCollapsed(next)\n }\n\n /** 접힘을 정하고 남긴다. 스스로 접을 때와 손으로 접을 때가 이 뒷부분을 함께 쓴다. */\n private setCollapsed(next: boolean) {\n this.collapsed = next\n\n try {\n localStorage.setItem(OxMenuPart.LS_KEY, next ? '1' : '0')\n } catch {\n /* 못 남기면 다음에 펼쳐진 채로 뜬다. 기능은 그대로다. */\n }\n\n window.dispatchEvent(new Event('resize'))\n }\n\n @state() _activeTopLevel?: Menu\n @state() _activeMenu?: Menu\n @state() _path?: string\n\n /**\n * 사용자가 손으로 정한 펼침.\n *\n * ```\n * undefined 정한 적 없다 — 주소가 가리키는 것을 쓴다\n * null 닫았다\n * Menu 그 묶음을 열었다\n * ```\n *\n * ── 왜 이 값이 필요한가 ─────────────────────────────────────────────────\n * 펼침 상태는 **주소에서 계산된다** — `willUpdate` 가 매 렌더마다 `findActivePage()` 를 불러\n * `_activeTopLevel` 을 주소가 가리키는 메뉴로 다시 넣는다. 그래서 손으로 무엇을 열거나 닫아도\n * **그리기 전에 되돌아왔다.** 여닫는 코드는 처음부터 있었는데 한 번도 살아 있지 못했다.\n *\n * 다른 최상위를 누를 때 열리는 것처럼 보였던 것은 여닫힘이 아니라 **이동**이었다. 링크가 주소를\n * 바꾸고, 바뀐 주소로 다른 메뉴가 계산된 것이다. 그래서 화살표만 눌러 이동 없이 열려고 하면\n * 아무 일도 안 일어났다.\n *\n * 한때 「닫았다」만 기억했다. 그러면 접기는 되지만 **다른 묶음을 열 수는 없었다** — 그 방향도\n * 같은 이유로 되돌려진다. 세 값으로 두면 두 방향이 다 산다.\n *\n * **주소가 바뀌면 잊는다** — 새 자리에서는 그 자리의 메뉴가 열려 있어야 한다.\n */\n private _userOpened: UserOpened\n\n render() {\n const menus = this._menu.menus\n\n return html`\n <slot name=\"head\"></slot>\n ${this.orientation !== 'landscape'\n ? html`<ox-menu-portrait\n .menus=${menus}\n .activeTopLevel=${this._activeTopLevel}\n .activeMenu=${this._activeMenu}\n .path=${this._path}\n ?rail=${this.rail}\n ?collapsed=${this.rail && this.collapsed}\n ?drawer=${this.drawer}\n ></ox-menu-portrait>`\n : html`<ox-menu-landscape\n .menus=${menus}\n .activeTopLevel=${this._activeTopLevel}\n .activeMenu=${this._activeMenu}\n .path=${this._path}\n ></ox-menu-landscape>`}\n <slot name=\"tail\"></slot>\n `\n }\n\n firstUpdated() {\n this.renderRoot.addEventListener('active-toplevel', (e: Event) => {\n e.stopPropagation()\n e.preventDefault()\n\n /*\n * **`detail: undefined` 는 `null` 로 도착한다.**\n *\n * `CustomEvent` 의 `detail` 은 기본값이 `null` 이라, 보내는 쪽이 `undefined` 를 넣어도 받는\n * 쪽에서는 `null` 이다. 그래서 `next === undefined` 로 「접겠다는 뜻」을 가리면 한 번도 맞지\n * 않는다 — 값이 있나 없나로 가른다.\n */\n const next = (e as CustomEvent).detail || undefined\n\n /* 열었든 닫았든 손으로 정한 것을 적어 둔다 — 안 적으면 주소가 곧 되돌린다. */\n this._userOpened = next ?? null\n this._activeTopLevel = next\n })\n\n this.renderRoot.addEventListener('collapse-toggle', (e: Event) => {\n e.stopPropagation()\n this.toggleCollapsed()\n })\n }\n\n protected willUpdate() {\n this.findActivePage()\n\n if (this.orientation == 'portrait') {\n this.removeAttribute('landscape')\n } else if (this.orientation == 'landscape') {\n this.setAttribute('landscape', '')\n }\n\n const { slotTemplate } = this._menu\n if (slotTemplate) {\n this.replaceChild(slotTemplate, this.renderRoot)\n }\n }\n\n /**\n * 지금 자리의 메뉴를 다시 정한다.\n *\n * 규칙은 `menu-active.ts` 에 있다 — 순수 함수라 시험으로 못박혀 있다. 여기서는 라우터가 준\n * 것을 넘기고 결과를 상태에 넣는 일만 한다.\n */\n private findActivePage() {\n const { page, resourceId, context } = this._route\n const where = { page, resourceId, context }\n const path = joinedPath(where)\n\n /* 자리를 옮겼으면 손으로 정한 것을 잊는다 — 새 자리에서는 그 자리의 메뉴가 열려 있어야 한다. */\n if (this._path !== undefined && path !== this._path) {\n this._userOpened = undefined\n }\n\n const { top, menu } = resolveActive(this._menu.menus || [], where, this._userOpened)\n\n this._path = path\n this._activeTopLevel = top\n this._activeMenu = menu\n }\n}\n"]}