@ds-mo/ui 13.18.1 → 14.0.1

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 (206) hide show
  1. package/dist/.package-ready.json +1 -1
  2. package/dist/agent-patterns.json +1 -1
  3. package/dist/agent.json +127 -4
  4. package/dist/angular/ds-bar-action.d.ts +18 -0
  5. package/dist/angular/ds-bar-action.js +42 -0
  6. package/dist/angular/index.d.ts +1 -1
  7. package/dist/angular/index.js +1 -0
  8. package/dist/angular/proxies.d.ts +1 -0
  9. package/dist/angular/proxies.js +1 -0
  10. package/dist/components/ds-agent-activity.js +1 -1
  11. package/dist/components/ds-agent-questionnaire.js +1 -1
  12. package/dist/components/ds-agent-response.js +1 -1
  13. package/dist/components/ds-agent-source-list.js +1 -1
  14. package/dist/components/ds-agent-tool-call.js +1 -1
  15. package/dist/components/ds-attachment-list.js +1 -1
  16. package/dist/components/ds-avatar.js +1 -1
  17. package/dist/components/ds-banner.js +1 -1
  18. package/dist/components/ds-bar-action.d.ts +11 -0
  19. package/dist/components/ds-bar-action.js +2 -0
  20. package/dist/components/ds-bar-action.js.map +1 -0
  21. package/dist/components/ds-bar-nav.js +1 -1
  22. package/dist/components/ds-bar-title.js +1 -1
  23. package/dist/components/ds-bar-workflow.js +1 -1
  24. package/dist/components/ds-button-filled.js +1 -1
  25. package/dist/components/ds-button-unfilled.js +1 -1
  26. package/dist/components/ds-card-chart.js +1 -1
  27. package/dist/components/ds-card-overview.js +1 -1
  28. package/dist/components/ds-card-setting.js +1 -1
  29. package/dist/components/ds-chip.js +1 -1
  30. package/dist/components/ds-code-block.js +1 -1
  31. package/dist/components/ds-conversation-list-item.js +1 -1
  32. package/dist/components/ds-conversation-list-section.js +1 -1
  33. package/dist/components/ds-empty-state.js +1 -1
  34. package/dist/components/ds-field.js +1 -1
  35. package/dist/components/ds-filter-menu.js +1 -1
  36. package/dist/components/ds-icon.js +1 -1
  37. package/dist/components/ds-input.js +1 -1
  38. package/dist/components/ds-input.js.map +1 -1
  39. package/dist/components/ds-markdown.js +1 -1
  40. package/dist/components/ds-menu.js +1 -1
  41. package/dist/components/ds-message-actions.js +1 -1
  42. package/dist/components/ds-message-composer.js +1 -1
  43. package/dist/components/ds-message-scroller.js +1 -1
  44. package/dist/components/ds-message-scroller.js.map +1 -1
  45. package/dist/components/ds-message.js +1 -1
  46. package/dist/components/ds-mobile-bar-nav.js +1 -1
  47. package/dist/components/ds-mobile-header.js +1 -1
  48. package/dist/components/ds-mobile-section-switcher.js +1 -1
  49. package/dist/components/ds-mobile-sheet-nav.js +1 -1
  50. package/dist/components/ds-modal.js +1 -1
  51. package/dist/components/ds-panel-nav.js +1 -1
  52. package/dist/components/ds-panel-tool-header.js +1 -1
  53. package/dist/components/ds-panel-tool-search.js +1 -1
  54. package/dist/components/ds-panel-tools.js +1 -1
  55. package/dist/components/ds-select.js +1 -1
  56. package/dist/components/ds-select.js.map +1 -1
  57. package/dist/components/ds-shell-app.js +1 -1
  58. package/dist/components/ds-shell-page.js +1 -1
  59. package/dist/components/ds-shell-tools.js +1 -1
  60. package/dist/components/ds-swatch-picker.js +1 -1
  61. package/dist/components/ds-tab-group.js +1 -1
  62. package/dist/components/ds-table.js +1 -1
  63. package/dist/components/ds-table.js.map +1 -1
  64. package/dist/components/ds-tag.js +1 -1
  65. package/dist/components/ds-toast.js +1 -1
  66. package/dist/components/ds-typing-indicator.js +1 -1
  67. package/dist/components/{p-DGvAZk-D.js → p--z-DWRkg.js} +2 -2
  68. package/dist/components/{p-BUAJ5TD7.js → p-1KtrhAC7.js} +2 -2
  69. package/dist/components/{p-BqFKxWVl.js → p-B7Hf86NF.js} +2 -2
  70. package/dist/components/{p-BCfgU8Fh.js → p-BDCrM8-1.js} +2 -2
  71. package/dist/components/p-BFfLvMZd.js +2 -0
  72. package/dist/components/p-BFfLvMZd.js.map +1 -0
  73. package/dist/components/p-BKsWDUnX.js +2 -0
  74. package/dist/components/{p-ByNAXs5X.js.map → p-BKsWDUnX.js.map} +1 -1
  75. package/dist/components/{p-BOjGzd5N.js → p-BagXfn1G.js} +2 -2
  76. package/dist/components/{p-DeBto13S.js → p-Bf_D3crn.js} +2 -2
  77. package/dist/components/{p-CBTGKnIy.js → p-CElfUmMp.js} +2 -2
  78. package/dist/components/{p-B-CVaYE6.js → p-CIhETsHv.js} +2 -2
  79. package/dist/components/{p-ChGkNsWu.js → p-CQS5aWiC.js} +2 -2
  80. package/dist/components/{p-oyZjjoYV.js → p-CUeuemsF.js} +2 -2
  81. package/dist/components/{p-BQkZ8FIl.js → p-Cae9PFPE.js} +2 -2
  82. package/dist/components/{p-C_tqXl5P.js → p-Cih1XCy3.js} +2 -2
  83. package/dist/components/{p-C7ywYAzN.js → p-D-kWb8cF.js} +2 -2
  84. package/dist/components/{p-C25Nmifd.js → p-D0i_J2rj.js} +2 -2
  85. package/dist/components/{p-Qzh-wyTg.js → p-DLkNcCY0.js} +2 -2
  86. package/dist/components/p-DXHq_AfZ.js +2 -0
  87. package/dist/components/{p-DQK2ZH60.js.map → p-DXHq_AfZ.js.map} +1 -1
  88. package/dist/components/{p-Ca0npbVt.js → p-DXgSSxyB.js} +2 -2
  89. package/dist/components/{p-CswBI711.js → p-DY_sA9Jz.js} +2 -2
  90. package/dist/components/{p-BDsAON4I.js → p-DatlGTJp.js} +2 -2
  91. package/dist/components/{p-NzOcZg8r.js → p-DcPVSNDr.js} +2 -2
  92. package/dist/components/{p-BhOjnMLG.js → p-DofS_hVZ.js} +2 -2
  93. package/dist/components/{p-CXYjqJwC.js → p-S9IjLCGj.js} +2 -2
  94. package/dist/components/{p-D0-VdNjl.js → p-ZtJM0cZm.js} +2 -2
  95. package/dist/components/{p-DGhSz9Fo.js → p-nMkoXLC0.js} +2 -2
  96. package/dist/components/{p-eu5AZEuT.js → p-vT4pgp8o.js} +2 -2
  97. package/dist/components/p-vT4pgp8o.js.map +1 -0
  98. package/dist/docs/components.json +168 -2
  99. package/dist/mcp-data/registry/agent-activity.json +2 -2
  100. package/dist/mcp-data/registry/agent-questionnaire.json +2 -2
  101. package/dist/mcp-data/registry/agent-response.json +2 -2
  102. package/dist/mcp-data/registry/agent-source-list.json +2 -2
  103. package/dist/mcp-data/registry/agent-tool-call.json +2 -2
  104. package/dist/mcp-data/registry/attachment-list.json +2 -2
  105. package/dist/mcp-data/registry/avatar.json +2 -2
  106. package/dist/mcp-data/registry/badge.json +2 -2
  107. package/dist/mcp-data/registry/banner.json +2 -2
  108. package/dist/mcp-data/registry/bar-action.json +237 -0
  109. package/dist/mcp-data/registry/bar-nav.json +2 -2
  110. package/dist/mcp-data/registry/bar-title.json +2 -2
  111. package/dist/mcp-data/registry/bar-workflow.json +2 -2
  112. package/dist/mcp-data/registry/breadcrumb.json +2 -2
  113. package/dist/mcp-data/registry/button-filled.json +2 -2
  114. package/dist/mcp-data/registry/button-unfilled.json +2 -2
  115. package/dist/mcp-data/registry/card-chart.json +2 -2
  116. package/dist/mcp-data/registry/card-overview.json +3 -3
  117. package/dist/mcp-data/registry/card-setting.json +2 -2
  118. package/dist/mcp-data/registry/chart-legend.json +2 -2
  119. package/dist/mcp-data/registry/chart.json +2 -2
  120. package/dist/mcp-data/registry/checkbox.json +2 -2
  121. package/dist/mcp-data/registry/chip.json +2 -2
  122. package/dist/mcp-data/registry/code-block.json +2 -2
  123. package/dist/mcp-data/registry/conversation-list-item.json +2 -2
  124. package/dist/mcp-data/registry/conversation-list-section.json +2 -2
  125. package/dist/mcp-data/registry/conversation-list.json +2 -2
  126. package/dist/mcp-data/registry/divider.json +2 -2
  127. package/dist/mcp-data/registry/empty-state.json +2 -2
  128. package/dist/mcp-data/registry/field.json +2 -2
  129. package/dist/mcp-data/registry/filter-menu.json +2 -2
  130. package/dist/mcp-data/registry/icon.json +3 -3
  131. package/dist/mcp-data/registry/input.json +3 -3
  132. package/dist/mcp-data/registry/loader.json +2 -2
  133. package/dist/mcp-data/registry/markdown.json +2 -2
  134. package/dist/mcp-data/registry/menu.json +2 -2
  135. package/dist/mcp-data/registry/message-actions.json +2 -2
  136. package/dist/mcp-data/registry/message-bubble.json +2 -2
  137. package/dist/mcp-data/registry/message-composer.json +2 -2
  138. package/dist/mcp-data/registry/message-scroller.json +3 -3
  139. package/dist/mcp-data/registry/message.json +2 -2
  140. package/dist/mcp-data/registry/mobile-bar-nav.json +2 -2
  141. package/dist/mcp-data/registry/mobile-header.json +2 -2
  142. package/dist/mcp-data/registry/mobile-section-switcher.json +2 -2
  143. package/dist/mcp-data/registry/mobile-sheet-nav.json +2 -2
  144. package/dist/mcp-data/registry/modal.json +2 -2
  145. package/dist/mcp-data/registry/panel-nav.json +7 -6
  146. package/dist/mcp-data/registry/panel-sub-nav.json +2 -2
  147. package/dist/mcp-data/registry/panel-tool-header.json +2 -2
  148. package/dist/mcp-data/registry/panel-tool-search.json +2 -2
  149. package/dist/mcp-data/registry/panel-tools.json +2 -2
  150. package/dist/mcp-data/registry/radio.json +2 -2
  151. package/dist/mcp-data/registry/registry.json +131 -5
  152. package/dist/mcp-data/registry/scroll-overlay.json +3 -3
  153. package/dist/mcp-data/registry/select.json +2 -2
  154. package/dist/mcp-data/registry/shell-app.json +2 -2
  155. package/dist/mcp-data/registry/shell-page.json +2 -2
  156. package/dist/mcp-data/registry/shell-tools.json +2 -2
  157. package/dist/mcp-data/registry/skeleton.json +2 -2
  158. package/dist/mcp-data/registry/slider.json +2 -2
  159. package/dist/mcp-data/registry/swatch-picker.json +2 -2
  160. package/dist/mcp-data/registry/switch.json +2 -2
  161. package/dist/mcp-data/registry/tab-group.json +2 -2
  162. package/dist/mcp-data/registry/table.json +6 -5
  163. package/dist/mcp-data/registry/tag.json +2 -2
  164. package/dist/mcp-data/registry/text.json +2 -2
  165. package/dist/mcp-data/registry/toast.json +2 -2
  166. package/dist/mcp-data/registry/tooltip-chart.json +2 -2
  167. package/dist/mcp-data/registry/tooltip.json +2 -2
  168. package/dist/mcp-data/registry/typing-indicator.json +2 -2
  169. package/dist/react/components.d.ts +1 -0
  170. package/dist/react/components.js +1 -0
  171. package/dist/react/ds-bar-action.d.ts +12 -0
  172. package/dist/react/ds-bar-action.js +12 -0
  173. package/dist/styles/table.css +12 -4
  174. package/dist/types/components/BarAction/BarAction.d.ts +25 -0
  175. package/dist/types/components/MessageScroller/MessageScroller.d.ts +1 -0
  176. package/dist/types/components/PanelNav/PanelNav.d.ts +1 -1
  177. package/dist/types/components.d.ts +80 -2
  178. package/package.json +5 -5
  179. package/dist/components/p-ByNAXs5X.js +0 -2
  180. package/dist/components/p-C8U5BsJf.js +0 -2
  181. package/dist/components/p-C8U5BsJf.js.map +0 -1
  182. package/dist/components/p-DQK2ZH60.js +0 -2
  183. package/dist/components/p-eu5AZEuT.js.map +0 -1
  184. /package/dist/components/{p-DGvAZk-D.js.map → p--z-DWRkg.js.map} +0 -0
  185. /package/dist/components/{p-BUAJ5TD7.js.map → p-1KtrhAC7.js.map} +0 -0
  186. /package/dist/components/{p-BqFKxWVl.js.map → p-B7Hf86NF.js.map} +0 -0
  187. /package/dist/components/{p-BCfgU8Fh.js.map → p-BDCrM8-1.js.map} +0 -0
  188. /package/dist/components/{p-BOjGzd5N.js.map → p-BagXfn1G.js.map} +0 -0
  189. /package/dist/components/{p-DeBto13S.js.map → p-Bf_D3crn.js.map} +0 -0
  190. /package/dist/components/{p-CBTGKnIy.js.map → p-CElfUmMp.js.map} +0 -0
  191. /package/dist/components/{p-B-CVaYE6.js.map → p-CIhETsHv.js.map} +0 -0
  192. /package/dist/components/{p-ChGkNsWu.js.map → p-CQS5aWiC.js.map} +0 -0
  193. /package/dist/components/{p-oyZjjoYV.js.map → p-CUeuemsF.js.map} +0 -0
  194. /package/dist/components/{p-BQkZ8FIl.js.map → p-Cae9PFPE.js.map} +0 -0
  195. /package/dist/components/{p-C_tqXl5P.js.map → p-Cih1XCy3.js.map} +0 -0
  196. /package/dist/components/{p-C7ywYAzN.js.map → p-D-kWb8cF.js.map} +0 -0
  197. /package/dist/components/{p-C25Nmifd.js.map → p-D0i_J2rj.js.map} +0 -0
  198. /package/dist/components/{p-Qzh-wyTg.js.map → p-DLkNcCY0.js.map} +0 -0
  199. /package/dist/components/{p-Ca0npbVt.js.map → p-DXgSSxyB.js.map} +0 -0
  200. /package/dist/components/{p-CswBI711.js.map → p-DY_sA9Jz.js.map} +0 -0
  201. /package/dist/components/{p-BDsAON4I.js.map → p-DatlGTJp.js.map} +0 -0
  202. /package/dist/components/{p-NzOcZg8r.js.map → p-DcPVSNDr.js.map} +0 -0
  203. /package/dist/components/{p-BhOjnMLG.js.map → p-DofS_hVZ.js.map} +0 -0
  204. /package/dist/components/{p-CXYjqJwC.js.map → p-S9IjLCGj.js.map} +0 -0
  205. /package/dist/components/{p-D0-VdNjl.js.map → p-ZtJM0cZm.js.map} +0 -0
  206. /package/dist/components/{p-DGhSz9Fo.js.map → p-nMkoXLC0.js.map} +0 -0
@@ -194,8 +194,8 @@
194
194
  },
195
195
  "peerDependencies": {
196
196
  "required": [
197
- "@ds-mo/tokens ^6.2.1",
198
- "@ds-mo/icons ^6.6.0"
197
+ "@ds-mo/tokens ^6.2.2",
198
+ "@ds-mo/icons ^7.0.0"
199
199
  ],
200
200
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
201
201
  }
@@ -222,7 +222,7 @@
222
222
  },
223
223
  {
224
224
  "path": "src/wc/components/MessageScroller/MessageScroller.tsx",
225
- "content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n State,\n} from '@stencil/core';\nimport type { MessageScrollerPosition } from '../conversation-types';\nimport { ScrollOverlayController } from '../../utils/scroll-overlay-controller';\nimport { resolveCssLengthPx } from '../../utils/resolve-css-length-px';\n\nconst LIVE_EDGE_THRESHOLD = 24;\n\n@Component({\n tag: 'ds-message-scroller',\n styleUrl: 'MessageScroller.css',\n styleUrls: ['../../utils/scroll-edge-fade.css'],\n scoped: true,\n})\nexport class MessageScroller {\n @Element() el!: HTMLElement;\n\n @Prop() busy: boolean = false;\n @Prop() autoFollow: boolean = true;\n @Prop() defaultPosition: MessageScrollerPosition = 'last-anchor';\n @Prop() messagesLabel: string = 'Messages';\n\n @Event() dsReachStart!: EventEmitter<void>;\n\n @State() private showScrollToLatest = false;\n\n private viewport?: HTMLElement;\n private content?: HTMLElement;\n private overlay?: HTMLElement;\n private transcriptSlot?: HTMLSlotElement;\n private scrollOverlayController?: ScrollOverlayController;\n private mutationObserver?: MutationObserver;\n private following = true;\n private followReleased = false;\n private atStart = false;\n private programmatic = false;\n private programmaticTimer?: ReturnType<typeof setTimeout>;\n private transcriptChildren: HTMLElement[] = [];\n private knownTranscriptElements = new Set<HTMLElement>();\n private knownTurnAnchors = new Set<HTMLElement>();\n private firstChildViewportTop?: number;\n private activeTurnAnchor?: HTMLElement;\n private activeTurnAnchorViewportTop?: number;\n private turnClearance = 0;\n private prependReconciliation = 0;\n\n componentDidLoad() {\n this.transcriptChildren = this.getTranscriptChildren();\n this.transcriptChildren.forEach(element => {\n this.knownTranscriptElements.add(element);\n if (element.matches('ds-message[scroll-anchor]')) {\n this.knownTurnAnchors.add(element);\n }\n });\n this.rememberFirstChildTop();\n this.connectObservers();\n requestAnimationFrame(() => this.applyDefaultPosition());\n document.addEventListener('selectionchange', this.handleSelectionChange);\n }\n\n disconnectedCallback() {\n this.prependReconciliation += 1;\n this.scrollOverlayController?.disconnect();\n this.mutationObserver?.disconnect();\n if (this.programmaticTimer) clearTimeout(this.programmaticTimer);\n document.removeEventListener('selectionchange', this.handleSelectionChange);\n }\n\n @Method()\n async scrollToMessage(id: string): Promise<boolean> {\n const message = Array.from(\n this.el.querySelectorAll<HTMLElement & { messageId?: string }>('ds-message')\n ).find(item => item.messageId === id || item.getAttribute('message-id') === id);\n if (!message) return false;\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = true;\n message.scrollIntoView({ block: 'start', behavior: this.motionBehavior() });\n return true;\n }\n\n @Method()\n async scrollToStart() {\n this.scrollTo(0);\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = Boolean(\n this.viewport && this.viewport.scrollHeight > this.viewport.clientHeight\n );\n }\n\n @Method()\n async scrollToEnd() {\n this.following = true;\n this.followReleased = false;\n this.showScrollToLatest = false;\n this.scrollTo(this.viewport?.scrollHeight ?? 0);\n }\n\n private motionBehavior(): ScrollBehavior {\n return matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';\n }\n\n private scrollTo(top: number) {\n if (!this.viewport) return;\n this.programmatic = true;\n const behavior = this.motionBehavior();\n this.viewport.scrollTo({ top, behavior });\n if (this.programmaticTimer) clearTimeout(this.programmaticTimer);\n this.programmaticTimer = setTimeout(() => {\n this.programmatic = false;\n this.programmaticTimer = undefined;\n if (this.following) {\n this.viewport!.scrollTop = top;\n this.showScrollToLatest = false;\n }\n }, behavior === 'smooth' ? 600 : 0);\n }\n\n private connectObservers() {\n if (!this.viewport || !this.content || !this.overlay) return;\n this.scrollOverlayController = new ScrollOverlayController({\n host: this.el,\n viewport: this.viewport,\n content: this.content,\n overlay: this.overlay,\n overlayContentSelector: '.message-scroller__overlay-stack',\n onGeometryChange: () => this.handleContentGrowth(),\n });\n this.scrollOverlayController.connect();\n this.mutationObserver = new MutationObserver(() => {\n this.handleTranscriptChange();\n this.handleContentGrowth();\n });\n this.mutationObserver.observe(this.el, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n\n private handleContentGrowth() {\n if (!this.viewport) return;\n this.syncTurnClearance();\n const nextHeight = this.viewport.scrollHeight;\n if (this.autoFollow && this.following && !this.followReleased) {\n this.viewport.scrollTop = nextHeight;\n }\n this.showScrollToLatest = !this.following && !this.isAtLiveEdge();\n this.scrollOverlayController?.sync();\n }\n\n private rememberFirstChildTop() {\n if (!this.viewport) return;\n const first = this.getTranscriptChildren()[0];\n this.firstChildViewportTop = first\n ? first.getBoundingClientRect().top - this.viewport.getBoundingClientRect().top\n : undefined;\n }\n\n private getTranscriptChildren(): HTMLElement[] {\n const assigned = this.transcriptSlot?.assignedElements() ?? [];\n const assignedTranscript = assigned.filter(\n (element): element is HTMLElement =>\n element instanceof HTMLElement &&\n (element.matches('ds-message') || element.matches('ds-agent-response')),\n );\n const queriedTranscript = Array.from(\n this.el.querySelectorAll<HTMLElement>('ds-message, ds-agent-response'),\n ).filter(element => {\n if (\n element.closest(\n '.message-scroller__interaction, .message-scroller__overlay-content',\n )\n ) {\n return false;\n }\n if (element.matches('ds-agent-response')) return true;\n return !element.closest('ds-agent-response');\n });\n return queriedTranscript.length > assignedTranscript.length\n ? queriedTranscript\n : assignedTranscript;\n }\n\n private handleTranscriptChange = () => {\n const current = this.getTranscriptChildren();\n const previous = this.transcriptChildren;\n const hadKnownTranscript = this.knownTranscriptElements.size > 0;\n const previouslyKnown = new Set(this.knownTranscriptElements);\n const appendedAnchor = [...current].reverse().find((element, reverseIndex) => {\n if (\n !element.matches('ds-message[scroll-anchor]') ||\n this.knownTurnAnchors.has(element)\n ) {\n return false;\n }\n const index = current.length - reverseIndex - 1;\n return current\n .slice(index + 1)\n .every(next => !this.knownTranscriptElements.has(next));\n });\n current.forEach(element => {\n this.knownTranscriptElements.add(element);\n if (element.matches('ds-message[scroll-anchor]')) {\n this.knownTurnAnchors.add(element);\n }\n });\n if (!previous.length) {\n this.transcriptChildren = current;\n if (appendedAnchor && hadKnownTranscript) {\n requestAnimationFrame(() => this.positionNewTurn(appendedAnchor));\n }\n return;\n }\n\n const firstKnownIndex = current.findIndex(element => previouslyKnown.has(element));\n const newTranscript = current.filter(element => !previouslyKnown.has(element));\n const prepended =\n (current.length > previous.length && previous.every(\n (element, index) => current[current.length - previous.length + index] === element,\n )) ||\n (!appendedAnchor &&\n newTranscript.length > 0 &&\n firstKnownIndex > 0 &&\n newTranscript.every(element => current.indexOf(element) < firstKnownIndex));\n\n this.transcriptChildren = current;\n if (prepended) {\n if (this.viewport) {\n const preservedElement = this.activeTurnAnchor?.isConnected\n ? this.activeTurnAnchor\n : previous[0];\n const preservedTop = this.activeTurnAnchor?.isConnected\n ? this.activeTurnAnchorViewportTop\n : this.firstChildViewportTop;\n if (preservedElement && preservedTop !== undefined) {\n this.reconcilePrependPosition(preservedElement, preservedTop);\n this.schedulePrependReconciliation(\n preservedElement,\n preservedTop,\n newTranscript,\n );\n }\n }\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n this.scrollOverlayController?.sync();\n return;\n }\n if (appendedAnchor) {\n requestAnimationFrame(() => this.positionNewTurn(appendedAnchor));\n }\n };\n\n private reconcilePrependPosition(element: HTMLElement, viewportTop: number) {\n if (!this.viewport || !element.isConnected) return;\n const nextTop =\n element.getBoundingClientRect().top - this.viewport.getBoundingClientRect().top;\n this.viewport.scrollTop += nextTop - viewportTop;\n }\n\n private schedulePrependReconciliation(\n element: HTMLElement,\n viewportTop: number,\n prepended: HTMLElement[],\n ) {\n const generation = ++this.prependReconciliation;\n // Prepended custom elements can gain height after their child-list mutation.\n // Reconcile against the original coordinate after their layouts settle.\n const pending = prepended\n .flatMap(item => [item, ...Array.from(item.querySelectorAll<HTMLElement>('*'))])\n .map(item => {\n const componentOnReady = (\n item as HTMLElement & { componentOnReady?: () => Promise<unknown> }\n ).componentOnReady;\n return componentOnReady ? componentOnReady.call(item).catch(() => undefined) : null;\n })\n .filter((promise): promise is Promise<unknown> => Boolean(promise));\n\n void Promise.all(pending).then(() => {\n requestAnimationFrame(() => {\n if (generation !== this.prependReconciliation) return;\n this.reconcilePrependPosition(element, viewportTop);\n requestAnimationFrame(() => {\n if (generation !== this.prependReconciliation) return;\n this.reconcilePrependPosition(element, viewportTop);\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n this.scrollOverlayController?.sync();\n });\n });\n });\n }\n\n private handleTranscriptSlotChange = () => this.handleTranscriptChange();\n\n private positionNewTurn(anchor: HTMLElement) {\n if (!this.viewport) return;\n this.activeTurnAnchor = anchor;\n this.syncTurnClearance();\n requestAnimationFrame(() => this.finishPositionNewTurn(anchor));\n }\n\n private finishPositionNewTurn(anchor: HTMLElement) {\n if (!this.viewport || !anchor.isConnected) return;\n const viewportRect = this.viewport.getBoundingClientRect();\n const anchorTop =\n anchor.getBoundingClientRect().top - viewportRect.top + this.viewport.scrollTop;\n const customOffset = getComputedStyle(this.el)\n .getPropertyValue('--ds-message-scroller-anchor-offset')\n .trim();\n const offset = resolveCssLengthPx(customOffset, 'var(--dimension-space-800)');\n const max = Math.max(this.viewport.scrollHeight - this.viewport.clientHeight, 0);\n const target = Math.min(Math.max(anchorTop - offset, 0), max);\n this.following = max - target <= LIVE_EDGE_THRESHOLD;\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n this.programmatic = true;\n this.viewport.scrollTop = target;\n requestAnimationFrame(() => {\n this.programmatic = false;\n this.following = this.isAtLiveEdge();\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n });\n this.scrollOverlayController?.sync();\n }\n\n private rememberActiveTurnAnchorTop() {\n if (!this.viewport || !this.activeTurnAnchor?.isConnected) return;\n this.activeTurnAnchorViewportTop =\n this.activeTurnAnchor.getBoundingClientRect().top -\n this.viewport.getBoundingClientRect().top;\n }\n\n private syncTurnClearance() {\n if (!this.viewport || !this.activeTurnAnchor?.isConnected) return;\n const children = this.getTranscriptChildren();\n const last = children[children.length - 1];\n if (!last) return;\n const anchorRect = this.activeTurnAnchor.getBoundingClientRect();\n const lastRect = last.getBoundingClientRect();\n const customOffset = getComputedStyle(this.el)\n .getPropertyValue('--ds-message-scroller-anchor-offset')\n .trim();\n const offset = resolveCssLengthPx(customOffset, 'var(--dimension-space-800)');\n const contentAfterAnchor = Math.max(lastRect.bottom - anchorRect.top, 0);\n const clearance = Math.max(\n Math.ceil(this.viewport.clientHeight - offset - contentAfterAnchor),\n 0,\n );\n if (clearance === this.turnClearance) return;\n this.turnClearance = clearance;\n this.el.style.setProperty('--ds-message-scroller-turn-clearance', `${clearance}px`);\n }\n\n private applyDefaultPosition() {\n if (!this.viewport) return;\n if (this.defaultPosition === 'start') {\n this.viewport.scrollTop = 0;\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = this.viewport.scrollHeight > this.viewport.clientHeight;\n this.scrollOverlayController?.sync();\n return;\n }\n if (this.defaultPosition === 'last-anchor') {\n const anchors = this.el.querySelectorAll<HTMLElement>('ds-message[scroll-anchor]');\n const last = anchors.item(anchors.length - 1);\n if (last) {\n last.scrollIntoView({ block: 'start' });\n this.following = this.isAtLiveEdge();\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n this.scrollOverlayController?.sync();\n return;\n }\n }\n this.viewport.scrollTop = this.viewport.scrollHeight;\n this.following = true;\n this.followReleased = false;\n this.showScrollToLatest = false;\n this.scrollOverlayController?.sync();\n }\n\n private isAtLiveEdge(): boolean {\n if (!this.viewport) return true;\n return (\n this.viewport.scrollHeight - this.viewport.clientHeight - this.viewport.scrollTop <=\n LIVE_EDGE_THRESHOLD\n );\n }\n\n private handleScroll = () => {\n if (!this.viewport) return;\n this.scrollOverlayController?.sync();\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n if (this.programmatic) return;\n this.following = this.isAtLiveEdge();\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n const nextAtStart = this.viewport.scrollTop <= LIVE_EDGE_THRESHOLD;\n if (nextAtStart && !this.atStart) this.dsReachStart.emit();\n this.atStart = nextAtStart;\n };\n\n private releaseFollow = () => {\n this.prependReconciliation += 1;\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = true;\n };\n\n private handleSelectionChange = () => {\n const selection = document.getSelection();\n if (\n selection &&\n !selection.isCollapsed &&\n selection.anchorNode &&\n this.el.contains(selection.anchorNode)\n ) {\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = !this.isAtLiveEdge();\n }\n };\n\n render() {\n return (\n <Host>\n <div class=\"message-scroller\">\n <div\n class=\"message-scroller__viewport\"\n ref={element => {\n this.viewport = element;\n }}\n role=\"region\"\n aria-label={this.messagesLabel}\n tabIndex={0}\n onScroll={this.handleScroll}\n onPointerDown={(event: PointerEvent) => {\n if (event.pointerType === 'touch') this.releaseFollow();\n }}\n onWheel={this.releaseFollow}\n onKeyDown={(event: KeyboardEvent) => {\n if (\n ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(\n event.key,\n )\n ) {\n this.releaseFollow();\n }\n }}\n >\n <div\n class=\"message-scroller__content scroll-edge-fade--block-window\"\n ref={element => {\n this.content = element;\n }}\n role=\"log\"\n aria-live=\"polite\"\n aria-relevant=\"additions\"\n aria-busy={this.busy ? 'true' : undefined}\n >\n <slot\n ref={element => {\n this.transcriptSlot = element;\n }}\n onSlotchange={this.handleTranscriptSlotChange}\n />\n </div>\n </div>\n <div\n class=\"message-scroller__overlay\"\n ref={element => {\n this.overlay = element;\n }}\n >\n <div class=\"message-scroller__overlay-stack\">\n <div class=\"message-scroller__interaction\">\n <slot name=\"interaction\" />\n </div>\n <div class=\"message-scroller__overlay-content\">\n <slot name=\"overlay\" />\n </div>\n </div>\n </div>\n {this.showScrollToLatest ? (\n <div class=\"message-scroller__control ds-control-elevation ds-control-elevation--md ds-control-elevation--press-scale\">\n <ds-tooltip label=\"Scroll to latest message\" side=\"top\" size=\"sm\">\n <ds-button-unfilled\n variant=\"icon\"\n icon=\"ChevronDown\"\n size=\"md\"\n rounded\n hasBorder={false}\n aria-label=\"Scroll to latest message\"\n onDsClick={() => void this.scrollToEnd()}\n />\n </ds-tooltip>\n </div>\n ) : null}\n </div>\n </Host>\n );\n }\n}\n",
225
+ "content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n State,\n} from '@stencil/core';\nimport type { MessageScrollerPosition } from '../conversation-types';\nimport { ScrollOverlayController } from '../../utils/scroll-overlay-controller';\nimport { resolveCssLengthPx } from '../../utils/resolve-css-length-px';\n\nconst LIVE_EDGE_THRESHOLD = 24;\n\n@Component({\n tag: 'ds-message-scroller',\n styleUrl: 'MessageScroller.css',\n styleUrls: ['../../utils/scroll-edge-fade.css'],\n scoped: true,\n})\nexport class MessageScroller {\n @Element() el!: HTMLElement;\n\n @Prop() busy: boolean = false;\n @Prop() autoFollow: boolean = true;\n @Prop() defaultPosition: MessageScrollerPosition = 'last-anchor';\n @Prop() messagesLabel: string = 'Messages';\n\n @Event() dsReachStart!: EventEmitter<void>;\n\n @State() private showScrollToLatest = false;\n\n private viewport?: HTMLElement;\n private content?: HTMLElement;\n private overlay?: HTMLElement;\n private transcriptSlot?: HTMLSlotElement;\n private scrollOverlayController?: ScrollOverlayController;\n private mutationObserver?: MutationObserver;\n private following = true;\n private followReleased = false;\n private releasePendingAtLiveEdge = false;\n private atStart = false;\n private programmatic = false;\n private programmaticTimer?: ReturnType<typeof setTimeout>;\n private transcriptChildren: HTMLElement[] = [];\n private knownTranscriptElements = new Set<HTMLElement>();\n private knownTurnAnchors = new Set<HTMLElement>();\n private firstChildViewportTop?: number;\n private activeTurnAnchor?: HTMLElement;\n private activeTurnAnchorViewportTop?: number;\n private turnClearance = 0;\n private prependReconciliation = 0;\n\n componentDidLoad() {\n this.transcriptChildren = this.getTranscriptChildren();\n this.transcriptChildren.forEach(element => {\n this.knownTranscriptElements.add(element);\n if (element.matches('ds-message[scroll-anchor]')) {\n this.knownTurnAnchors.add(element);\n }\n });\n this.rememberFirstChildTop();\n this.connectObservers();\n requestAnimationFrame(() => this.applyDefaultPosition());\n document.addEventListener('selectionchange', this.handleSelectionChange);\n }\n\n disconnectedCallback() {\n this.prependReconciliation += 1;\n this.scrollOverlayController?.disconnect();\n this.mutationObserver?.disconnect();\n if (this.programmaticTimer) clearTimeout(this.programmaticTimer);\n document.removeEventListener('selectionchange', this.handleSelectionChange);\n }\n\n @Method()\n async scrollToMessage(id: string): Promise<boolean> {\n const message = Array.from(\n this.el.querySelectorAll<HTMLElement & { messageId?: string }>('ds-message')\n ).find(item => item.messageId === id || item.getAttribute('message-id') === id);\n if (!message) return false;\n this.releasePendingAtLiveEdge = false;\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = true;\n message.scrollIntoView({ block: 'start', behavior: this.motionBehavior() });\n return true;\n }\n\n @Method()\n async scrollToStart() {\n this.releasePendingAtLiveEdge = false;\n this.scrollTo(0);\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = Boolean(\n this.viewport && this.viewport.scrollHeight > this.viewport.clientHeight\n );\n }\n\n @Method()\n async scrollToEnd() {\n this.releasePendingAtLiveEdge = false;\n this.following = true;\n this.followReleased = false;\n this.showScrollToLatest = false;\n this.scrollTo(this.viewport?.scrollHeight ?? 0);\n }\n\n private motionBehavior(): ScrollBehavior {\n return matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';\n }\n\n private scrollTo(top: number) {\n if (!this.viewport) return;\n this.programmatic = true;\n const behavior = this.motionBehavior();\n this.viewport.scrollTo({ top, behavior });\n if (this.programmaticTimer) clearTimeout(this.programmaticTimer);\n this.programmaticTimer = setTimeout(() => {\n this.programmatic = false;\n this.programmaticTimer = undefined;\n if (this.following) {\n this.viewport!.scrollTop = top;\n this.showScrollToLatest = false;\n }\n }, behavior === 'smooth' ? 600 : 0);\n }\n\n private connectObservers() {\n if (!this.viewport || !this.content || !this.overlay) return;\n this.scrollOverlayController = new ScrollOverlayController({\n host: this.el,\n viewport: this.viewport,\n content: this.content,\n overlay: this.overlay,\n overlayContentSelector: '.message-scroller__overlay-stack',\n onGeometryChange: () => this.handleContentGrowth(),\n });\n this.scrollOverlayController.connect();\n this.mutationObserver = new MutationObserver(() => {\n this.handleTranscriptChange();\n this.handleContentGrowth();\n });\n this.mutationObserver.observe(this.el, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n\n private handleContentGrowth() {\n if (!this.viewport) return;\n this.syncTurnClearance();\n const nextHeight = this.viewport.scrollHeight;\n if (this.autoFollow && this.following && !this.followReleased) {\n this.viewport.scrollTop = nextHeight;\n }\n if (this.releasePendingAtLiveEdge && !this.isAtLiveEdge()) {\n this.releasePendingAtLiveEdge = false;\n }\n this.showScrollToLatest = !this.following && !this.isAtLiveEdge();\n this.scrollOverlayController?.sync();\n }\n\n private rememberFirstChildTop() {\n if (!this.viewport) return;\n const first = this.getTranscriptChildren()[0];\n this.firstChildViewportTop = first\n ? first.getBoundingClientRect().top - this.viewport.getBoundingClientRect().top\n : undefined;\n }\n\n private getTranscriptChildren(): HTMLElement[] {\n const assigned = this.transcriptSlot?.assignedElements() ?? [];\n const assignedTranscript = assigned.filter(\n (element): element is HTMLElement =>\n element instanceof HTMLElement &&\n (element.matches('ds-message') || element.matches('ds-agent-response')),\n );\n const queriedTranscript = Array.from(\n this.el.querySelectorAll<HTMLElement>('ds-message, ds-agent-response'),\n ).filter(element => {\n if (\n element.closest(\n '.message-scroller__interaction, .message-scroller__overlay-content',\n )\n ) {\n return false;\n }\n if (element.matches('ds-agent-response')) return true;\n return !element.closest('ds-agent-response');\n });\n return queriedTranscript.length > assignedTranscript.length\n ? queriedTranscript\n : assignedTranscript;\n }\n\n private handleTranscriptChange = () => {\n const current = this.getTranscriptChildren();\n const previous = this.transcriptChildren;\n const hadKnownTranscript = this.knownTranscriptElements.size > 0;\n const previouslyKnown = new Set(this.knownTranscriptElements);\n const appendedAnchor = [...current].reverse().find((element, reverseIndex) => {\n if (\n !element.matches('ds-message[scroll-anchor]') ||\n this.knownTurnAnchors.has(element)\n ) {\n return false;\n }\n const index = current.length - reverseIndex - 1;\n return current\n .slice(index + 1)\n .every(next => !this.knownTranscriptElements.has(next));\n });\n current.forEach(element => {\n this.knownTranscriptElements.add(element);\n if (element.matches('ds-message[scroll-anchor]')) {\n this.knownTurnAnchors.add(element);\n }\n });\n if (!previous.length) {\n this.transcriptChildren = current;\n if (appendedAnchor && hadKnownTranscript) {\n requestAnimationFrame(() => this.positionNewTurn(appendedAnchor));\n }\n return;\n }\n\n const firstKnownIndex = current.findIndex(element => previouslyKnown.has(element));\n const newTranscript = current.filter(element => !previouslyKnown.has(element));\n const prepended =\n (current.length > previous.length && previous.every(\n (element, index) => current[current.length - previous.length + index] === element,\n )) ||\n (!appendedAnchor &&\n newTranscript.length > 0 &&\n firstKnownIndex > 0 &&\n newTranscript.every(element => current.indexOf(element) < firstKnownIndex));\n\n this.transcriptChildren = current;\n if (prepended) {\n if (this.viewport) {\n const preservedElement = this.activeTurnAnchor?.isConnected\n ? this.activeTurnAnchor\n : previous[0];\n const preservedTop = this.activeTurnAnchor?.isConnected\n ? this.activeTurnAnchorViewportTop\n : this.firstChildViewportTop;\n if (preservedElement && preservedTop !== undefined) {\n this.reconcilePrependPosition(preservedElement, preservedTop);\n this.schedulePrependReconciliation(\n preservedElement,\n preservedTop,\n newTranscript,\n );\n }\n }\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n this.scrollOverlayController?.sync();\n return;\n }\n if (appendedAnchor) {\n requestAnimationFrame(() => this.positionNewTurn(appendedAnchor));\n }\n };\n\n private reconcilePrependPosition(element: HTMLElement, viewportTop: number) {\n if (!this.viewport || !element.isConnected) return;\n const nextTop =\n element.getBoundingClientRect().top - this.viewport.getBoundingClientRect().top;\n this.viewport.scrollTop += nextTop - viewportTop;\n }\n\n private schedulePrependReconciliation(\n element: HTMLElement,\n viewportTop: number,\n prepended: HTMLElement[],\n ) {\n const generation = ++this.prependReconciliation;\n // Prepended custom elements can gain height after their child-list mutation.\n // Reconcile against the original coordinate after their layouts settle.\n const pending = prepended\n .flatMap(item => [item, ...Array.from(item.querySelectorAll<HTMLElement>('*'))])\n .map(item => {\n const componentOnReady = (\n item as HTMLElement & { componentOnReady?: () => Promise<unknown> }\n ).componentOnReady;\n return componentOnReady ? componentOnReady.call(item).catch(() => undefined) : null;\n })\n .filter((promise): promise is Promise<unknown> => Boolean(promise));\n\n void Promise.all(pending).then(() => {\n requestAnimationFrame(() => {\n if (generation !== this.prependReconciliation) return;\n this.reconcilePrependPosition(element, viewportTop);\n requestAnimationFrame(() => {\n if (generation !== this.prependReconciliation) return;\n this.reconcilePrependPosition(element, viewportTop);\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n this.scrollOverlayController?.sync();\n });\n });\n });\n }\n\n private handleTranscriptSlotChange = () => this.handleTranscriptChange();\n\n private positionNewTurn(anchor: HTMLElement) {\n if (!this.viewport) return;\n this.activeTurnAnchor = anchor;\n this.syncTurnClearance();\n requestAnimationFrame(() => this.finishPositionNewTurn(anchor));\n }\n\n private finishPositionNewTurn(anchor: HTMLElement) {\n if (!this.viewport || !anchor.isConnected) return;\n const viewportRect = this.viewport.getBoundingClientRect();\n const anchorTop =\n anchor.getBoundingClientRect().top - viewportRect.top + this.viewport.scrollTop;\n const customOffset = getComputedStyle(this.el)\n .getPropertyValue('--ds-message-scroller-anchor-offset')\n .trim();\n const offset = resolveCssLengthPx(customOffset, 'var(--dimension-space-800)');\n const max = Math.max(this.viewport.scrollHeight - this.viewport.clientHeight, 0);\n const target = Math.min(Math.max(anchorTop - offset, 0), max);\n this.following = max - target <= LIVE_EDGE_THRESHOLD;\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n this.programmatic = true;\n this.viewport.scrollTop = target;\n requestAnimationFrame(() => {\n this.programmatic = false;\n this.following = this.isAtLiveEdge();\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n });\n this.scrollOverlayController?.sync();\n }\n\n private rememberActiveTurnAnchorTop() {\n if (!this.viewport || !this.activeTurnAnchor?.isConnected) return;\n this.activeTurnAnchorViewportTop =\n this.activeTurnAnchor.getBoundingClientRect().top -\n this.viewport.getBoundingClientRect().top;\n }\n\n private syncTurnClearance() {\n if (!this.viewport || !this.activeTurnAnchor?.isConnected) return;\n const children = this.getTranscriptChildren();\n const last = children[children.length - 1];\n if (!last) return;\n const anchorRect = this.activeTurnAnchor.getBoundingClientRect();\n const lastRect = last.getBoundingClientRect();\n const customOffset = getComputedStyle(this.el)\n .getPropertyValue('--ds-message-scroller-anchor-offset')\n .trim();\n const offset = resolveCssLengthPx(customOffset, 'var(--dimension-space-800)');\n const contentAfterAnchor = Math.max(lastRect.bottom - anchorRect.top, 0);\n const clearance = Math.max(\n Math.ceil(this.viewport.clientHeight - offset - contentAfterAnchor),\n 0,\n );\n if (clearance === this.turnClearance) return;\n this.turnClearance = clearance;\n this.el.style.setProperty('--ds-message-scroller-turn-clearance', `${clearance}px`);\n }\n\n private applyDefaultPosition() {\n if (!this.viewport) return;\n this.releasePendingAtLiveEdge = false;\n if (this.defaultPosition === 'start') {\n this.viewport.scrollTop = 0;\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = this.viewport.scrollHeight > this.viewport.clientHeight;\n this.scrollOverlayController?.sync();\n return;\n }\n if (this.defaultPosition === 'last-anchor') {\n const anchors = this.el.querySelectorAll<HTMLElement>('ds-message[scroll-anchor]');\n const last = anchors.item(anchors.length - 1);\n if (last) {\n last.scrollIntoView({ block: 'start' });\n this.following = this.isAtLiveEdge();\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n this.scrollOverlayController?.sync();\n return;\n }\n }\n this.viewport.scrollTop = this.viewport.scrollHeight;\n this.following = true;\n this.followReleased = false;\n this.showScrollToLatest = false;\n this.scrollOverlayController?.sync();\n }\n\n private isAtLiveEdge(): boolean {\n if (!this.viewport) return true;\n return (\n this.viewport.scrollHeight - this.viewport.clientHeight - this.viewport.scrollTop <=\n LIVE_EDGE_THRESHOLD\n );\n }\n\n private handleScroll = () => {\n if (!this.viewport) return;\n this.scrollOverlayController?.sync();\n this.rememberFirstChildTop();\n this.rememberActiveTurnAnchorTop();\n if (this.programmatic) return;\n const atLiveEdge = this.isAtLiveEdge();\n if (this.releasePendingAtLiveEdge && atLiveEdge) {\n // A scroll event queued by the previous auto-follow can arrive after\n // reader input. Keep that stale event from restoring follow mode.\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = false;\n return;\n }\n this.releasePendingAtLiveEdge = false;\n this.following = atLiveEdge;\n this.followReleased = !this.following;\n this.showScrollToLatest = !this.following;\n const nextAtStart = this.viewport.scrollTop <= LIVE_EDGE_THRESHOLD;\n if (nextAtStart && !this.atStart) this.dsReachStart.emit();\n this.atStart = nextAtStart;\n };\n\n private releaseFollow = () => {\n this.prependReconciliation += 1;\n this.releasePendingAtLiveEdge = this.isAtLiveEdge();\n this.following = false;\n this.followReleased = true;\n this.showScrollToLatest = !this.releasePendingAtLiveEdge;\n };\n\n private handleSelectionChange = () => {\n const selection = document.getSelection();\n if (\n selection &&\n !selection.isCollapsed &&\n selection.anchorNode &&\n this.el.contains(selection.anchorNode)\n ) {\n this.releaseFollow();\n }\n };\n\n render() {\n return (\n <Host>\n <div class=\"message-scroller\">\n <div\n class=\"message-scroller__viewport\"\n ref={element => {\n this.viewport = element;\n }}\n role=\"region\"\n aria-label={this.messagesLabel}\n tabIndex={0}\n onScroll={this.handleScroll}\n onPointerDown={(event: PointerEvent) => {\n if (event.pointerType === 'touch') this.releaseFollow();\n }}\n onWheel={this.releaseFollow}\n onKeyDown={(event: KeyboardEvent) => {\n if (\n ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(\n event.key,\n )\n ) {\n this.releaseFollow();\n }\n }}\n >\n <div\n class=\"message-scroller__content scroll-edge-fade--block-window\"\n ref={element => {\n this.content = element;\n }}\n role=\"log\"\n aria-live=\"polite\"\n aria-relevant=\"additions\"\n aria-busy={this.busy ? 'true' : undefined}\n >\n <slot\n ref={element => {\n this.transcriptSlot = element;\n }}\n onSlotchange={this.handleTranscriptSlotChange}\n />\n </div>\n </div>\n <div\n class=\"message-scroller__overlay\"\n ref={element => {\n this.overlay = element;\n }}\n >\n <div class=\"message-scroller__overlay-stack\">\n <div class=\"message-scroller__interaction\">\n <slot name=\"interaction\" />\n </div>\n <div class=\"message-scroller__overlay-content\">\n <slot name=\"overlay\" />\n </div>\n </div>\n </div>\n {this.showScrollToLatest ? (\n <div class=\"message-scroller__control ds-control-elevation ds-control-elevation--md ds-control-elevation--press-scale\">\n <ds-tooltip label=\"Scroll to latest message\" side=\"top\" size=\"sm\">\n <ds-button-unfilled\n variant=\"icon\"\n icon=\"ChevronDown\"\n size=\"md\"\n rounded\n hasBorder={false}\n aria-label=\"Scroll to latest message\"\n onDsClick={() => void this.scrollToEnd()}\n />\n </ds-tooltip>\n </div>\n ) : null}\n </div>\n </Host>\n );\n }\n}\n",
226
226
  "type": "registry:ui"
227
227
  }
228
228
  ]
@@ -249,8 +249,8 @@
249
249
  },
250
250
  "peerDependencies": {
251
251
  "required": [
252
- "@ds-mo/tokens ^6.2.1",
253
- "@ds-mo/icons ^6.6.0"
252
+ "@ds-mo/tokens ^6.2.2",
253
+ "@ds-mo/icons ^7.0.0"
254
254
  ],
255
255
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
256
256
  }
@@ -331,8 +331,8 @@
331
331
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: currentArea.",
332
332
  "peerDependencies": {
333
333
  "required": [
334
- "@ds-mo/tokens ^6.2.1",
335
- "@ds-mo/icons ^6.6.0"
334
+ "@ds-mo/tokens ^6.2.2",
335
+ "@ds-mo/icons ^7.0.0"
336
336
  ],
337
337
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
338
338
  }
@@ -317,8 +317,8 @@
317
317
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: sections, subsections.",
318
318
  "peerDependencies": {
319
319
  "required": [
320
- "@ds-mo/tokens ^6.2.1",
321
- "@ds-mo/icons ^6.6.0"
320
+ "@ds-mo/tokens ^6.2.2",
321
+ "@ds-mo/icons ^7.0.0"
322
322
  ],
323
323
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
324
324
  }
@@ -153,8 +153,8 @@
153
153
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: sections.",
154
154
  "peerDependencies": {
155
155
  "required": [
156
- "@ds-mo/tokens ^6.2.1",
157
- "@ds-mo/icons ^6.6.0"
156
+ "@ds-mo/tokens ^6.2.2",
157
+ "@ds-mo/icons ^7.0.0"
158
158
  ],
159
159
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
160
160
  }
@@ -284,8 +284,8 @@
284
284
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: dashboardGroups, settingsGroups.",
285
285
  "peerDependencies": {
286
286
  "required": [
287
- "@ds-mo/tokens ^6.2.1",
288
- "@ds-mo/icons ^6.6.0"
287
+ "@ds-mo/tokens ^6.2.2",
288
+ "@ds-mo/icons ^7.0.0"
289
289
  ],
290
290
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
291
291
  }
@@ -270,8 +270,8 @@
270
270
  },
271
271
  "peerDependencies": {
272
272
  "required": [
273
- "@ds-mo/tokens ^6.2.1",
274
- "@ds-mo/icons ^6.6.0"
273
+ "@ds-mo/tokens ^6.2.2",
274
+ "@ds-mo/icons ^7.0.0"
275
275
  ],
276
276
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
277
277
  }
@@ -68,6 +68,7 @@
68
68
  "Dashboard presents day-to-day operational destinations; Settings presents administrative and configuration destinations.",
69
69
  "The standard footer switches the entire destination collection and navStyle between Dashboard and Settings and exposes the user/account entry.",
70
70
  "The host owns the external user menu opened from dsNavUserAction.",
71
+ "While the external user menu is open or closing, accountMenuExpanded keeps its trigger visibly pressed without applying selected-destination foreground treatment in either expanded or collapsed presentation.",
71
72
  "Item and header controls inherit the shared md control radius while local shell geometry continues to own collapsed layout and footer animation positioning.",
72
73
  "On desktop, allow user-controlled expand and collapse and persist the preference with storageKey when cross-session persistence is appropriate."
73
74
  ],
@@ -126,7 +127,7 @@
126
127
  "default": "false",
127
128
  "required": false,
128
129
  "mutable": false,
129
- "description": "Keep the account-menu trigger visually active while its popup is open or closing."
130
+ "description": "Keep the account-menu trigger visually pressed while its popup is open or closing."
130
131
  },
131
132
  "activeId": {
132
133
  "type": "string",
@@ -359,7 +360,7 @@
359
360
  "default": "false",
360
361
  "required": false,
361
362
  "mutable": false,
362
- "description": "Keep the account-menu trigger visually active while its popup is open or closing."
363
+ "description": "Keep the account-menu trigger visually pressed while its popup is open or closing."
363
364
  },
364
365
  "activeId": {
365
366
  "type": "string",
@@ -597,8 +598,8 @@
597
598
  },
598
599
  "peerDependencies": {
599
600
  "required": [
600
- "@ds-mo/tokens ^6.2.1",
601
- "@ds-mo/icons ^6.6.0"
601
+ "@ds-mo/tokens ^6.2.2",
602
+ "@ds-mo/icons ^7.0.0"
602
603
  ],
603
604
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
604
605
  }
@@ -619,12 +620,12 @@
619
620
  "files": [
620
621
  {
621
622
  "path": "src/wc/components/PanelNav/PanelNav.css",
622
- "content": "@import '../../utils/control-density.css';\n@import '../../utils/chrome-layout.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n@import '../../utils/scrollbar-hidden.css';\n\n/* ── Theme variables ──────────────────────────────────────────────────────── */\n\n.panel-nav {\n /* ─ Sizes ─ */\n --_nav-width: var(--dimension-panel-width-2xs);\n --_nav-width-collapsed: var(--dimension-size-600);\n --_nav-item-height: var(--dimension-size-400, 32px);\n --_nav-padding-x: var(--dimension-space-100, 8px);\n --_nav-padding-y: var(--dimension-space-100, 8px);\n /* gear button outer width = padding*2 + icon */\n --_nav-footer-btn-w: calc(var(--dimension-space-075, 6px) * 2 + var(--dimension-iconography-md, 20px));\n\n /* ─ Motion ─ */\n /* --ds-panel-nav-speed: unitless multiplier; override in Storybook to slow-mo (e.g. 10 = 10×).\n *-dur vars are duration-only (needed for calc); *-motion vars add easing for use in transitions. */\n --_nav-speed: var(--ds-panel-nav-speed, 1);\n --_nav-expand-dur: calc(var(--_nav-speed) * var(--effect-animation-duration-medium-1, 300ms));\n --_nav-expand-motion: var(--_nav-expand-dur) var(--effect-animation-easing-ease-in-out, ease);\n --_nav-label-fade-dur: calc(var(--_nav-speed) * var(--effect-animation-duration-medium-1, 300ms));\n --_nav-label-fade-motion: var(--_nav-label-fade-dur) var(--effect-animation-easing-ease-in-out, ease);\n --_nav-label-fade-out-dur: calc(var(--_nav-speed) * var(--effect-animation-duration-medium-1, 300ms));\n --_nav-label-fade-out-motion: var(--_nav-label-fade-out-dur) var(--effect-animation-easing-ease-in-out, ease);\n\n /* ─ Colours – default chrome (app surfaces) ─ */\n --_nav-bg: var(--color-background-secondary);\n --_nav-border: var(--color-border-tertiary);\n --_nav-divider: var(--color-border-tertiary);\n --_nav-fg-primary: var(--color-foreground-primary);\n --_nav-fg-secondary: var(--color-foreground-secondary);\n --_nav-fg-tertiary: var(--color-foreground-tertiary);\n --_nav-hover: var(--color-interaction-hover);\n --_nav-pressed: var(--color-interaction-pressed);\n --_nav-focus: var(--color-interaction-focus);\n --_nav-dot: var(--color-foreground-bold-brand);\n --ds-interaction-hover: var(--_nav-hover);\n --ds-interaction-pressed: var(--_nav-pressed);\n --ds-focus-ring-color: var(--_nav-focus);\n}\n\n/* ── Default chrome overrides ────────────────────────────────────────────── */\n\n/* Shell-owned chrome (bg + wash) — panel is a transparent frame. */\n:host-context(ds-shell-app:not([gradient-preset='none'])) .panel-nav {\n background-color: transparent;\n}\n\n/* Style slots — same app tokens for now; hook texture/glyph layers on these classes later. */\n.panel-nav--dashboard,\n.panel-nav--settings {\n --_nav-bg: var(--color-background-secondary);\n --_nav-border: var(--color-border-tertiary);\n --_nav-divider: var(--color-border-tertiary);\n --_nav-fg-primary: var(--color-foreground-primary);\n --_nav-fg-secondary: var(--color-foreground-secondary);\n --_nav-fg-tertiary: var(--color-foreground-tertiary);\n --_nav-hover: var(--color-interaction-hover);\n --_nav-pressed: var(--color-interaction-pressed);\n --_nav-focus: var(--color-interaction-focus);\n --_nav-dot: var(--color-foreground-bold-brand);\n --ds-interaction-hover: var(--_nav-hover);\n --ds-interaction-pressed: var(--_nav-pressed);\n --ds-focus-ring-color: var(--_nav-focus);\n}\n\n/* ── Shell ───────────────────────────────────────────────────────────────── */\n\n.panel-nav {\n display: flex;\n flex-direction: column;\n position: relative;\n width: var(--_nav-width);\n min-width: var(--_nav-width);\n height: 100%;\n background-color: var(--_nav-bg);\n border-right: var(--dimension-stroke-width-012, 1px) solid var(--_nav-border);\n box-sizing: border-box;\n overflow: visible;\n transition:\n width var(--_nav-expand-motion),\n min-width var(--_nav-expand-motion),\n background-color var(--effect-motion-short-3, 200ms ease),\n border-color var(--effect-motion-short-3, 200ms ease);\n font-family: var(--typography-font-family-ui, system-ui);\n isolation: isolate;\n}\n\n.panel-nav__header,\n.panel-nav__body,\n.panel-nav__footer {\n position: relative;\n z-index: 3;\n}\n\n.panel-nav--collapsed {\n width: var(--_nav-width-collapsed);\n min-width: var(--_nav-width-collapsed);\n}\n\n/* ── Header ──────────────────────────────────────────────────────────────── */\n\n.panel-nav__header {\n height: var(--dimension-size-600, 48px);\n flex-shrink: 0;\n border-bottom: var(--dimension-stroke-width-012, 1px) solid var(--_nav-divider);\n transition: border-color var(--effect-motion-short-3, 200ms ease);\n}\n\n.panel-nav__header-btn {\n all: unset;\n /* Re-assert after unset — required for `.ds-interaction-fill` stacking. */\n position: relative;\n z-index: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: var(--dimension-space-075, 6px);\n border-radius: var(--ds-control-radius, var(--dimension-radius-025, 2px));\n cursor: default;\n color: var(--_nav-fg-primary);\n flex-shrink: 0;\n box-sizing: border-box;\n transition: color var(--effect-motion-short-3, 200ms ease);\n /* Hover/press: `.ds-interaction-fill` (tokens from --_nav-* via --ds-interaction-*). */\n}\n\n.panel-nav--breakpoint-locked .panel-nav__header-btn {\n cursor: default;\n}\n\n/* Logo — visible by default; instant swap to toggle on hover (no crossfade overlap) */\n.panel-nav__header-logo {\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n z-index: 2;\n opacity: 1;\n}\n\n.panel-nav__m-mark {\n width: var(--dimension-iconography-md, 20px);\n height: var(--dimension-iconography-md, 20px);\n display: block;\n}\n\n/* Toggle icon — stacked on logo; under hover wash (::after z-index 3) */\n.panel-nav__header-toggle {\n display: flex;\n align-items: center;\n justify-content: center;\n position: absolute;\n inset: 0;\n z-index: 2;\n opacity: 0;\n pointer-events: none;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .panel-nav:not(.panel-nav--breakpoint-locked)\n .panel-nav__header-btn:hover\n .panel-nav__header-logo {\n opacity: 0;\n }\n\n .panel-nav:not(.panel-nav--breakpoint-locked)\n .panel-nav__header-btn:hover\n .panel-nav__header-toggle {\n opacity: 1;\n }\n}\n\n/* ── Scrollable body ─────────────────────────────────────────────────────── */\n\n.panel-nav__body {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n}\n\n.panel-nav__sections {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-200, 16px);\n width: 100%;\n}\n\n/* ── Group ───────────────────────────────────────────────────────────────── */\n\n.panel-nav__group {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-050, 4px);\n}\n\n/*\n * Section headers — typography via ds-text (text-caption + emphasis).\n * Element selector so display:flex beats Text's inline-block, and padding\n * isn't clobbered by sharing `.panel-nav__item-label-text` on the same host.\n */\nds-text.panel-nav__group-label {\n display: flex;\n align-items: center;\n box-sizing: border-box;\n text-transform: uppercase;\n color: var(--_nav-fg-secondary);\n user-select: none;\n height: var(--dimension-size-300, 24px);\n /* Former nested pad: group-label (075) + item-label-text (025). */\n padding: 0 calc(var(--dimension-space-075, 6px) + var(--ds-control-label-inset));\n white-space: nowrap;\n overflow: hidden;\n max-height: var(--dimension-size-300, 24px);\n opacity: 1;\n transition:\n opacity var(--_nav-label-fade-motion) 0ms,\n max-height var(--_nav-label-fade-motion) 0ms,\n padding var(--_nav-label-fade-motion) 0ms,\n color var(--effect-motion-short-3, 200ms ease);\n}\n\n/* ── Item ────────────────────────────────────────────────────────────────── */\n\n.panel-nav__item {\n all: unset;\n /* Re-assert after unset — required for `.ds-interaction-fill` stacking. */\n position: relative;\n z-index: 0;\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050, 4px);\n padding: 0 var(--dimension-space-075, 6px);\n height: var(--dimension-size-400, 32px);\n border-radius: var(--ds-control-radius, var(--dimension-radius-025, 2px));\n cursor: default;\n color: var(--_nav-fg-secondary);\n box-sizing: border-box;\n width: 100%;\n user-select: none;\n transition: color var(--effect-motion-short-1, 50ms ease-in-out);\n /* Hover/press: `.ds-interaction-fill` — no --selected (icon/type weight only). */\n}\n\n/* ── Active item ─────────────────────────────────────────────────────────── */\n\n.panel-nav__item--active {\n color: var(--_nav-fg-primary);\n /* Bold typography via ds-text (text-body-medium + emphasis) on the label */\n}\n\n/* ── Item icon ───────────────────────────────────────────────────────────── */\n\n.panel-nav__item-icon {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--dimension-iconography-md, 20px);\n height: var(--dimension-iconography-md, 20px);\n position: relative;\n z-index: 2;\n}\n\n/* ── Item label ──────────────────────────────────────────────────────────── */\n\n.panel-nav__item-label {\n position: relative;\n z-index: 2;\n flex: 1;\n min-width: 0;\n height: var(--typography-lineheight-md, 20px);\n opacity: 1;\n /* Gradient wipe: mask sweeps left-to-right on expand, right-to-left on collapse */\n /* stylelint-disable-next-line color-named -- mask luminance stops, not theme colors */\n mask-image: linear-gradient(to right, black 50%, transparent 100%);\n /* stylelint-disable-next-line color-named -- mask luminance stops, not theme colors */\n -webkit-mask-image: linear-gradient(to right, black 50%, transparent 100%);\n mask-size: 200% 100%;\n -webkit-mask-size: 200% 100%;\n mask-position: 0% 0;\n -webkit-mask-position: 0% 0;\n transition:\n opacity var(--_nav-label-fade-motion) 0ms,\n mask-position var(--_nav-label-fade-motion) 0ms,\n -webkit-mask-position var(--_nav-label-fade-motion) 0ms;\n}\n\n.panel-nav__item-label-text {\n padding: 0 var(--ds-control-label-inset);\n}\n\n/* ── Item notification dot — 20px suffix zone matches the icon geometry ──── */\n\n.panel-nav__item-dot-box {\n position: absolute;\n z-index: 2;\n right: var(--dimension-space-075, 6px);\n top: var(--dimension-space-075, 6px);\n display: flex;\n align-items: center;\n justify-content: center;\n width: var(--dimension-iconography-md, 20px);\n height: var(--dimension-iconography-md, 20px);\n pointer-events: none;\n}\n\n.panel-nav__item-dot {\n transition:\n transform var(--_nav-expand-motion);\n}\n\n.panel-nav--collapsed .panel-nav__item-dot {\n transform: translate(\n calc(var(--dimension-space-075, 6px) + var(--dimension-stroke-width-012, 1px)),\n calc(0px - var(--dimension-space-075, 6px) - var(--dimension-stroke-width-012, 1px))\n );\n}\n\n.panel-nav--animating .panel-nav__item-label {\n text-overflow: clip;\n}\n\n.panel-nav--collapsed .panel-nav__item-label {\n opacity: 0;\n pointer-events: none;\n text-overflow: clip;\n mask-position: 100% 0;\n -webkit-mask-position: 100% 0;\n transition:\n opacity var(--_nav-label-fade-out-motion) 0ms,\n mask-position var(--_nav-label-fade-out-motion) 0ms,\n -webkit-mask-position var(--_nav-label-fade-out-motion) 0ms;\n}\n\n.panel-nav--collapsed ds-text.panel-nav__group-label {\n opacity: 0;\n max-height: 0;\n padding: 0 calc(var(--dimension-space-075, 6px) + var(--ds-control-label-inset));\n pointer-events: none;\n transition:\n opacity var(--_nav-label-fade-out-motion) 0ms,\n max-height var(--_nav-label-fade-out-motion) 0ms,\n padding var(--_nav-label-fade-out-motion) 0ms,\n color var(--effect-motion-short-3, 200ms ease);\n}\n\n/* ── Footer ──────────────────────────────────────────────────────────────── */\n\n.panel-nav__footer {\n position: relative;\n flex-shrink: 0;\n /* single row: padding*2 + item */\n height: calc(var(--_nav-padding-y) * 2 + var(--_nav-item-height));\n transition: height var(--_nav-expand-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer {\n /* two rows: padding*2 + item*2 + gap */\n height: calc(var(--_nav-padding-y) * 2 + var(--_nav-item-height) * 2 + var(--dimension-space-050, 4px));\n}\n\n/* ── Footer icon button (gear / dashboard) ───────────────────────────────── */\n\n.panel-nav__footer-btn {\n position: absolute;\n left: var(--_nav-padding-x);\n top: var(--_nav-padding-y);\n --ds-button-unfilled-fg: var(--_nav-fg-secondary);\n --ds-button-unfilled-fg-active: var(--_nav-fg-primary);\n --ds-interaction-hover: var(--_nav-hover);\n --ds-interaction-pressed: var(--_nav-pressed);\n --ds-focus-ring-color: var(--_nav-focus);\n}\n\n/* ── Footer user — absolutely positioned, slides from row to below gear ───── */\n\n.panel-nav__footer-user {\n position: absolute;\n /* Expanded row: balanced shell inset with an 8px gear-to-user gap. */\n left: calc(var(--_nav-padding-x) + var(--_nav-footer-btn-w) + var(--dimension-space-100, 8px));\n right: var(--_nav-padding-x);\n top: var(--_nav-padding-y);\n width: auto;\n min-width: 0;\n transition:\n left var(--_nav-expand-motion),\n right var(--_nav-expand-motion),\n top var(--_nav-expand-motion);\n}\n\n/* Popup-open state keeps the native footer trigger visibly pressed. */\n.panel-nav__footer-user--menu-expanded::after {\n background: var(--_nav-pressed);\n}\n\n.panel-nav__footer-user--menu-expanded {\n color: var(--_nav-fg-primary);\n --ds-interaction-hover: var(--_nav-pressed);\n}\n\n.panel-nav--collapsed .panel-nav__footer-user {\n /* Compact column keeps the intentionally tighter 4px vertical gap. */\n left: var(--_nav-padding-x);\n top: calc(var(--_nav-padding-y) + var(--_nav-item-height) + var(--dimension-space-050, 4px));\n}\n\n/* Label: absolute so it stays in place and only fades — no mask slide */\n.panel-nav__footer-user-label {\n text-align: right;\n position: absolute;\n left: var(--dimension-space-075, 6px);\n right: calc(var(--dimension-space-075, 6px) + var(--dimension-iconography-md, 20px) + var(--dimension-space-050, 4px));\n top: 50%;\n transform: translateY(-50%);\n flex: none;\n height: var(--typography-lineheight-md, 20px);\n mask-image: none;\n -webkit-mask-image: none;\n transition: opacity var(--_nav-label-fade-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-user-label {\n opacity: 0;\n pointer-events: none;\n mask-position: 0% 0;\n -webkit-mask-position: 0% 0;\n}\n\n/* ── Footer user icon — cross-fades chevron ↔ circle+initial ────────────── */\n\n/* Absolute — right-anchored in expanded, left-anchored in collapsed to match regular item icon position */\n.panel-nav__footer-user-icon {\n position: absolute;\n left: calc(100% - var(--dimension-iconography-md, 20px) - var(--dimension-space-075, 6px));\n top: 0;\n bottom: 0;\n height: auto;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: left var(--_nav-expand-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-user-icon {\n left: var(--dimension-space-075, 6px);\n}\n\n.panel-nav__footer-icon-expanded,\n.panel-nav__footer-icon-collapsed {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.panel-nav__footer-icon-expanded {\n opacity: 1;\n transition: opacity var(--_nav-label-fade-motion);\n}\n\n.panel-nav__footer-icon-collapsed {\n opacity: 0;\n transition: opacity var(--_nav-label-fade-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-icon-expanded {\n opacity: 0;\n transition: opacity var(--_nav-label-fade-out-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-icon-collapsed {\n opacity: 1;\n transition: opacity var(--_nav-label-fade-out-motion);\n}\n\n/* ── User initial — overlaid on the circle icon ─────────────────────────── */\n\n/*\n * Center the caption glyph on the Circle icon. Don't use inset:0 + flex — the\n * inner `.ds-text__element { width: 100% }` stretches and left-aligns the letter.\n * Translate-centering keeps the complete caption metric box on the icon center;\n * the caption variant remains the sole owner of its line-height.\n * (Do not set `inset: auto` after top/left — it resets them.)\n */\nds-text.panel-nav__user-initial,\nds-text.panel-nav__user-initial.ds-text--caption {\n position: absolute;\n top: 50%;\n left: 50%;\n right: auto;\n bottom: auto;\n transform: translate(-50%, -50%);\n display: block;\n width: max-content;\n max-width: none;\n min-width: 0;\n text-align: center;\n color: inherit;\n pointer-events: none;\n}\n\n/* ── Drag-to-resize handle ───────────────────────────────────────────────── */\n\n/* The resize overlay line — extends past the right edge; keep above workspace content */\n.panel-nav__resize-handle {\n position: absolute;\n top: 0;\n /* Centered on the right edge: --dimension-space-050 outside + inside = --dimension-space-100 hit zone */\n right: var(--dimension-space-n050, -4px);\n bottom: 0;\n width: var(--dimension-space-100, 8px);\n z-index: 20;\n cursor: ew-resize;\n touch-action: none;\n /* Needs a real background for Safari hit-testing */\n background: transparent;\n}\n\n/* Safari requires non-transparent fill for hit testing */\n.panel-nav__resize-handle::before {\n content: '';\n position: absolute;\n inset: 0;\n /* stylelint-disable-next-line color-named -- near-invisible hit-test fill; not a theme color */\n background: rgba(0, 0, 0, 0.001);\n}\n\n/* Resize overlay line */\n.panel-nav__resize-handle::after {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n transform: translateX(-50%);\n width: var(--dimension-stroke-width-025, 2px);\n background: var(--color-foreground-primary);\n opacity: 0;\n pointer-events: none;\n transition: opacity var(--effect-motion-short-2, 100ms ease-in-out);\n}\n\n.panel-nav__resize-handle--overlay::after {\n opacity: 1;\n}\n\n/* ── Reduced motion ──────────────────────────────────────────────────────── */\n\n@media (prefers-reduced-motion: reduce) {\n .panel-nav,\n .panel-nav *,\n .panel-nav *::before,\n .panel-nav *::after,\n .panel-nav__resize-handle,\n .panel-nav__resize-handle::before,\n .panel-nav__resize-handle::after {\n /* Near-instant — browsers treat 0 as \"no transition\" and can skip the end state. */\n transition-duration: var(--effect-animation-duration-instant) !important;\n animation-duration: var(--effect-animation-duration-instant) !important;\n transition-delay: var(--effect-animation-duration-instant) !important;\n }\n}\n",
623
+ "content": "@import '../../utils/control-density.css';\n@import '../../utils/chrome-layout.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n@import '../../utils/scrollbar-hidden.css';\n\n/* ── Theme variables ──────────────────────────────────────────────────────── */\n\n.panel-nav {\n /* ─ Sizes ─ */\n --_nav-width: var(--dimension-panel-width-2xs);\n --_nav-width-collapsed: var(--dimension-size-600);\n --_nav-item-height: var(--dimension-size-400, 32px);\n --_nav-padding-x: var(--dimension-space-100, 8px);\n --_nav-padding-y: var(--dimension-space-100, 8px);\n /* gear button outer width = padding*2 + icon */\n --_nav-footer-btn-w: calc(var(--dimension-space-075, 6px) * 2 + var(--dimension-iconography-md, 20px));\n\n /* ─ Motion ─ */\n /* --ds-panel-nav-speed: unitless multiplier; override in Storybook to slow-mo (e.g. 10 = 10×).\n *-dur vars are duration-only (needed for calc); *-motion vars add easing for use in transitions. */\n --_nav-speed: var(--ds-panel-nav-speed, 1);\n --_nav-expand-dur: calc(var(--_nav-speed) * var(--effect-animation-duration-medium-1, 300ms));\n --_nav-expand-motion: var(--_nav-expand-dur) var(--effect-animation-easing-ease-in-out, ease);\n --_nav-label-fade-dur: calc(var(--_nav-speed) * var(--effect-animation-duration-medium-1, 300ms));\n --_nav-label-fade-motion: var(--_nav-label-fade-dur) var(--effect-animation-easing-ease-in-out, ease);\n --_nav-label-fade-out-dur: calc(var(--_nav-speed) * var(--effect-animation-duration-medium-1, 300ms));\n --_nav-label-fade-out-motion: var(--_nav-label-fade-out-dur) var(--effect-animation-easing-ease-in-out, ease);\n\n /* ─ Colours – default chrome (app surfaces) ─ */\n --_nav-bg: var(--color-background-secondary);\n --_nav-border: var(--color-border-tertiary);\n --_nav-divider: var(--color-border-tertiary);\n --_nav-fg-primary: var(--color-foreground-primary);\n --_nav-fg-secondary: var(--color-foreground-secondary);\n --_nav-fg-tertiary: var(--color-foreground-tertiary);\n --_nav-hover: var(--color-interaction-hover);\n --_nav-pressed: var(--color-interaction-pressed);\n --_nav-focus: var(--color-interaction-focus);\n --_nav-dot: var(--color-foreground-bold-brand);\n --ds-interaction-hover: var(--_nav-hover);\n --ds-interaction-pressed: var(--_nav-pressed);\n --ds-focus-ring-color: var(--_nav-focus);\n}\n\n/* ── Default chrome overrides ────────────────────────────────────────────── */\n\n/* Shell-owned chrome (bg + wash) — panel is a transparent frame. */\n:host-context(ds-shell-app:not([gradient-preset='none'])) .panel-nav {\n background-color: transparent;\n}\n\n/* Style slots — same app tokens for now; hook texture/glyph layers on these classes later. */\n.panel-nav--dashboard,\n.panel-nav--settings {\n --_nav-bg: var(--color-background-secondary);\n --_nav-border: var(--color-border-tertiary);\n --_nav-divider: var(--color-border-tertiary);\n --_nav-fg-primary: var(--color-foreground-primary);\n --_nav-fg-secondary: var(--color-foreground-secondary);\n --_nav-fg-tertiary: var(--color-foreground-tertiary);\n --_nav-hover: var(--color-interaction-hover);\n --_nav-pressed: var(--color-interaction-pressed);\n --_nav-focus: var(--color-interaction-focus);\n --_nav-dot: var(--color-foreground-bold-brand);\n --ds-interaction-hover: var(--_nav-hover);\n --ds-interaction-pressed: var(--_nav-pressed);\n --ds-focus-ring-color: var(--_nav-focus);\n}\n\n/* ── Shell ───────────────────────────────────────────────────────────────── */\n\n.panel-nav {\n display: flex;\n flex-direction: column;\n position: relative;\n width: var(--_nav-width);\n min-width: var(--_nav-width);\n height: 100%;\n background-color: var(--_nav-bg);\n border-right: var(--dimension-stroke-width-012, 1px) solid var(--_nav-border);\n box-sizing: border-box;\n overflow: visible;\n transition:\n width var(--_nav-expand-motion),\n min-width var(--_nav-expand-motion),\n background-color var(--effect-motion-short-3, 200ms ease),\n border-color var(--effect-motion-short-3, 200ms ease);\n font-family: var(--typography-font-family-ui, system-ui);\n isolation: isolate;\n}\n\n.panel-nav__header,\n.panel-nav__body,\n.panel-nav__footer {\n position: relative;\n z-index: 3;\n}\n\n.panel-nav--collapsed {\n width: var(--_nav-width-collapsed);\n min-width: var(--_nav-width-collapsed);\n}\n\n/* ── Header ──────────────────────────────────────────────────────────────── */\n\n.panel-nav__header {\n height: var(--dimension-size-600, 48px);\n flex-shrink: 0;\n border-bottom: var(--dimension-stroke-width-012, 1px) solid var(--_nav-divider);\n transition: border-color var(--effect-motion-short-3, 200ms ease);\n}\n\n.panel-nav__header-btn {\n all: unset;\n /* Re-assert after unset — required for `.ds-interaction-fill` stacking. */\n position: relative;\n z-index: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: var(--dimension-space-075, 6px);\n border-radius: var(--ds-control-radius, var(--dimension-radius-025, 2px));\n cursor: default;\n color: var(--_nav-fg-primary);\n flex-shrink: 0;\n box-sizing: border-box;\n transition: color var(--effect-motion-short-3, 200ms ease);\n /* Hover/press: `.ds-interaction-fill` (tokens from --_nav-* via --ds-interaction-*). */\n}\n\n.panel-nav--breakpoint-locked .panel-nav__header-btn {\n cursor: default;\n}\n\n/* Logo — visible by default; instant swap to toggle on hover (no crossfade overlap) */\n.panel-nav__header-logo {\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n z-index: 2;\n opacity: 1;\n}\n\n.panel-nav__m-mark {\n width: var(--dimension-iconography-md, 20px);\n height: var(--dimension-iconography-md, 20px);\n display: block;\n}\n\n/* Toggle icon — stacked on logo; under hover wash (::after z-index 3) */\n.panel-nav__header-toggle {\n display: flex;\n align-items: center;\n justify-content: center;\n position: absolute;\n inset: 0;\n z-index: 2;\n opacity: 0;\n pointer-events: none;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .panel-nav:not(.panel-nav--breakpoint-locked)\n .panel-nav__header-btn:hover\n .panel-nav__header-logo {\n opacity: 0;\n }\n\n .panel-nav:not(.panel-nav--breakpoint-locked)\n .panel-nav__header-btn:hover\n .panel-nav__header-toggle {\n opacity: 1;\n }\n}\n\n/* ── Scrollable body ─────────────────────────────────────────────────────── */\n\n.panel-nav__body {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n}\n\n.panel-nav__sections {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-200, 16px);\n width: 100%;\n}\n\n/* ── Group ───────────────────────────────────────────────────────────────── */\n\n.panel-nav__group {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-050, 4px);\n}\n\n/*\n * Section headers — typography via ds-text (text-caption + emphasis).\n * Element selector so display:flex beats Text's inline-block, and padding\n * isn't clobbered by sharing `.panel-nav__item-label-text` on the same host.\n */\nds-text.panel-nav__group-label {\n display: flex;\n align-items: center;\n box-sizing: border-box;\n text-transform: uppercase;\n color: var(--_nav-fg-secondary);\n user-select: none;\n height: var(--dimension-size-300, 24px);\n /* Former nested pad: group-label (075) + item-label-text (025). */\n padding: 0 calc(var(--dimension-space-075, 6px) + var(--ds-control-label-inset));\n white-space: nowrap;\n overflow: hidden;\n max-height: var(--dimension-size-300, 24px);\n opacity: 1;\n transition:\n opacity var(--_nav-label-fade-motion) 0ms,\n max-height var(--_nav-label-fade-motion) 0ms,\n padding var(--_nav-label-fade-motion) 0ms,\n color var(--effect-motion-short-3, 200ms ease);\n}\n\n/* ── Item ────────────────────────────────────────────────────────────────── */\n\n.panel-nav__item {\n all: unset;\n /* Re-assert after unset — required for `.ds-interaction-fill` stacking. */\n position: relative;\n z-index: 0;\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050, 4px);\n padding: 0 var(--dimension-space-075, 6px);\n height: var(--dimension-size-400, 32px);\n border-radius: var(--ds-control-radius, var(--dimension-radius-025, 2px));\n cursor: default;\n color: var(--_nav-fg-secondary);\n box-sizing: border-box;\n width: 100%;\n user-select: none;\n transition: color var(--effect-motion-short-1, 50ms ease-in-out);\n /* Hover/press: `.ds-interaction-fill` — no --selected (icon/type weight only). */\n}\n\n/* ── Active item ─────────────────────────────────────────────────────────── */\n\n.panel-nav__item--active {\n color: var(--_nav-fg-primary);\n /* Bold typography via ds-text (text-body-medium + emphasis) on the label */\n}\n\n/* ── Item icon ───────────────────────────────────────────────────────────── */\n\n.panel-nav__item-icon {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--dimension-iconography-md, 20px);\n height: var(--dimension-iconography-md, 20px);\n position: relative;\n z-index: 2;\n}\n\n/* ── Item label ──────────────────────────────────────────────────────────── */\n\n.panel-nav__item-label {\n position: relative;\n z-index: 2;\n flex: 1;\n min-width: 0;\n height: var(--typography-lineheight-md, 20px);\n opacity: 1;\n /* Gradient wipe: mask sweeps left-to-right on expand, right-to-left on collapse */\n /* stylelint-disable-next-line color-named -- mask luminance stops, not theme colors */\n mask-image: linear-gradient(to right, black 50%, transparent 100%);\n /* stylelint-disable-next-line color-named -- mask luminance stops, not theme colors */\n -webkit-mask-image: linear-gradient(to right, black 50%, transparent 100%);\n mask-size: 200% 100%;\n -webkit-mask-size: 200% 100%;\n mask-position: 0% 0;\n -webkit-mask-position: 0% 0;\n transition:\n opacity var(--_nav-label-fade-motion) 0ms,\n mask-position var(--_nav-label-fade-motion) 0ms,\n -webkit-mask-position var(--_nav-label-fade-motion) 0ms;\n}\n\n.panel-nav__item-label-text {\n padding: 0 var(--ds-control-label-inset);\n}\n\n/* ── Item notification dot — 20px suffix zone matches the icon geometry ──── */\n\n.panel-nav__item-dot-box {\n position: absolute;\n z-index: 2;\n right: var(--dimension-space-075, 6px);\n top: var(--dimension-space-075, 6px);\n display: flex;\n align-items: center;\n justify-content: center;\n width: var(--dimension-iconography-md, 20px);\n height: var(--dimension-iconography-md, 20px);\n pointer-events: none;\n}\n\n.panel-nav__item-dot {\n transition:\n transform var(--_nav-expand-motion);\n}\n\n.panel-nav--collapsed .panel-nav__item-dot {\n transform: translate(\n calc(var(--dimension-space-075, 6px) + var(--dimension-stroke-width-012, 1px)),\n calc(0px - var(--dimension-space-075, 6px) - var(--dimension-stroke-width-012, 1px))\n );\n}\n\n.panel-nav--animating .panel-nav__item-label {\n text-overflow: clip;\n}\n\n.panel-nav--collapsed .panel-nav__item-label {\n opacity: 0;\n pointer-events: none;\n text-overflow: clip;\n mask-position: 100% 0;\n -webkit-mask-position: 100% 0;\n transition:\n opacity var(--_nav-label-fade-out-motion) 0ms,\n mask-position var(--_nav-label-fade-out-motion) 0ms,\n -webkit-mask-position var(--_nav-label-fade-out-motion) 0ms;\n}\n\n.panel-nav--collapsed ds-text.panel-nav__group-label {\n opacity: 0;\n max-height: 0;\n padding: 0 calc(var(--dimension-space-075, 6px) + var(--ds-control-label-inset));\n pointer-events: none;\n transition:\n opacity var(--_nav-label-fade-out-motion) 0ms,\n max-height var(--_nav-label-fade-out-motion) 0ms,\n padding var(--_nav-label-fade-out-motion) 0ms,\n color var(--effect-motion-short-3, 200ms ease);\n}\n\n/* ── Footer ──────────────────────────────────────────────────────────────── */\n\n.panel-nav__footer {\n position: relative;\n flex-shrink: 0;\n /* single row: padding*2 + item */\n height: calc(var(--_nav-padding-y) * 2 + var(--_nav-item-height));\n transition: height var(--_nav-expand-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer {\n /* two rows: padding*2 + item*2 + gap */\n height: calc(var(--_nav-padding-y) * 2 + var(--_nav-item-height) * 2 + var(--dimension-space-050, 4px));\n}\n\n/* ── Footer icon button (gear / dashboard) ───────────────────────────────── */\n\n.panel-nav__footer-btn {\n position: absolute;\n left: var(--_nav-padding-x);\n top: var(--_nav-padding-y);\n --ds-button-unfilled-fg: var(--_nav-fg-secondary);\n --ds-button-unfilled-fg-active: var(--_nav-fg-primary);\n --ds-interaction-hover: var(--_nav-hover);\n --ds-interaction-pressed: var(--_nav-pressed);\n --ds-focus-ring-color: var(--_nav-focus);\n}\n\n/* ── Footer user — absolutely positioned, slides from row to below gear ───── */\n\n.panel-nav__footer-user {\n position: absolute;\n /* Expanded row: balanced shell inset with an 8px gear-to-user gap. */\n left: calc(var(--_nav-padding-x) + var(--_nav-footer-btn-w) + var(--dimension-space-100, 8px));\n right: var(--_nav-padding-x);\n top: var(--_nav-padding-y);\n width: auto;\n min-width: 0;\n transition:\n left var(--_nav-expand-motion),\n right var(--_nav-expand-motion),\n top var(--_nav-expand-motion);\n}\n\n/* Popup-open state keeps the native footer trigger visibly pressed. */\n.panel-nav__footer-user--menu-expanded::after {\n background: var(--_nav-pressed);\n}\n\n.panel-nav__footer-user--menu-expanded {\n --ds-interaction-hover: var(--_nav-pressed);\n}\n\n.panel-nav--collapsed .panel-nav__footer-user {\n /* Compact column keeps the intentionally tighter 4px vertical gap. */\n left: var(--_nav-padding-x);\n top: calc(var(--_nav-padding-y) + var(--_nav-item-height) + var(--dimension-space-050, 4px));\n}\n\n/* Label: absolute so it stays in place and only fades — no mask slide */\n.panel-nav__footer-user-label {\n text-align: right;\n position: absolute;\n left: var(--dimension-space-075, 6px);\n right: calc(var(--dimension-space-075, 6px) + var(--dimension-iconography-md, 20px) + var(--dimension-space-050, 4px));\n top: 50%;\n transform: translateY(-50%);\n flex: none;\n height: var(--typography-lineheight-md, 20px);\n mask-image: none;\n -webkit-mask-image: none;\n transition: opacity var(--_nav-label-fade-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-user-label {\n opacity: 0;\n pointer-events: none;\n mask-position: 0% 0;\n -webkit-mask-position: 0% 0;\n}\n\n/* ── Footer user icon — cross-fades chevron ↔ circle+initial ────────────── */\n\n/* Absolute — right-anchored in expanded, left-anchored in collapsed to match regular item icon position */\n.panel-nav__footer-user-icon {\n position: absolute;\n left: calc(100% - var(--dimension-iconography-md, 20px) - var(--dimension-space-075, 6px));\n top: 0;\n bottom: 0;\n height: auto;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: left var(--_nav-expand-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-user-icon {\n left: var(--dimension-space-075, 6px);\n}\n\n.panel-nav__footer-icon-expanded,\n.panel-nav__footer-icon-collapsed {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.panel-nav__footer-icon-expanded {\n opacity: 1;\n transition: opacity var(--_nav-label-fade-motion);\n}\n\n.panel-nav__footer-icon-collapsed {\n opacity: 0;\n transition: opacity var(--_nav-label-fade-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-icon-expanded {\n opacity: 0;\n transition: opacity var(--_nav-label-fade-out-motion);\n}\n\n.panel-nav--collapsed .panel-nav__footer-icon-collapsed {\n opacity: 1;\n transition: opacity var(--_nav-label-fade-out-motion);\n}\n\n/* ── User initial — overlaid on the circle icon ─────────────────────────── */\n\n/*\n * Center the caption glyph on the Circle icon. Don't use inset:0 + flex — the\n * inner `.ds-text__element { width: 100% }` stretches and left-aligns the letter.\n * Translate-centering keeps the complete caption metric box on the icon center;\n * the caption variant remains the sole owner of its line-height.\n * (Do not set `inset: auto` after top/left — it resets them.)\n */\nds-text.panel-nav__user-initial,\nds-text.panel-nav__user-initial.ds-text--caption {\n position: absolute;\n top: 50%;\n left: 50%;\n right: auto;\n bottom: auto;\n transform: translate(-50%, -50%);\n display: block;\n width: max-content;\n max-width: none;\n min-width: 0;\n text-align: center;\n color: inherit;\n pointer-events: none;\n}\n\n/* ── Drag-to-resize handle ───────────────────────────────────────────────── */\n\n/* The resize overlay line — extends past the right edge; keep above workspace content */\n.panel-nav__resize-handle {\n position: absolute;\n top: 0;\n /* Centered on the right edge: --dimension-space-050 outside + inside = --dimension-space-100 hit zone */\n right: var(--dimension-space-n050, -4px);\n bottom: 0;\n width: var(--dimension-space-100, 8px);\n z-index: 20;\n cursor: ew-resize;\n touch-action: none;\n /* Needs a real background for Safari hit-testing */\n background: transparent;\n}\n\n/* Safari requires non-transparent fill for hit testing */\n.panel-nav__resize-handle::before {\n content: '';\n position: absolute;\n inset: 0;\n /* stylelint-disable-next-line color-named -- near-invisible hit-test fill; not a theme color */\n background: rgba(0, 0, 0, 0.001);\n}\n\n/* Resize overlay line */\n.panel-nav__resize-handle::after {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n transform: translateX(-50%);\n width: var(--dimension-stroke-width-025, 2px);\n background: var(--color-foreground-primary);\n opacity: 0;\n pointer-events: none;\n transition: opacity var(--effect-motion-short-2, 100ms ease-in-out);\n}\n\n.panel-nav__resize-handle--overlay::after {\n opacity: 1;\n}\n\n/* ── Reduced motion ──────────────────────────────────────────────────────── */\n\n@media (prefers-reduced-motion: reduce) {\n .panel-nav,\n .panel-nav *,\n .panel-nav *::before,\n .panel-nav *::after,\n .panel-nav__resize-handle,\n .panel-nav__resize-handle::before,\n .panel-nav__resize-handle::after {\n /* Near-instant — browsers treat 0 as \"no transition\" and can skip the end state. */\n transition-duration: var(--effect-animation-duration-instant) !important;\n animation-duration: var(--effect-animation-duration-instant) !important;\n transition-delay: var(--effect-animation-duration-instant) !important;\n }\n}\n",
623
624
  "type": "registry:ui"
624
625
  },
625
626
  {
626
627
  "path": "src/wc/components/PanelNav/PanelNav.tsx",
627
- "content": "import {\n Component,\n Prop,\n Event,\n EventEmitter,\n Watch,\n State,\n Element,\n Method,\n h,\n Host,\n} from '@stencil/core';\nimport type { ChromeTransitionDetail } from '../../shell/chrome-transition';\nimport type { NavChromeStyle } from '../../shell/nav-chrome';\nimport {\n deriveActiveIdFromUrl,\n panelNavWidthTransitionMs,\n parsePanelNavGroups,\n resolvePanelNavDisableVt,\n resolvePanelNavStyle,\n resolvePanelNavToggle,\n shouldResyncPanelNavGroups,\n shouldResyncPanelNavStyle,\n} from './panel-nav-utils';\nimport {\n PANEL_NAV_USER_MENU_ANCHOR_ID,\n PANEL_NAV_USER_MENU_PLACEMENT,\n type PanelNavGroup,\n type PanelNavItem,\n type PanelNavRouterMode,\n type PanelNavUserActionDetail,\n} from './panel-nav-types';\nimport { scrollEdgeFadeClassMap } from '../../utils/scroll-edge-fade';\nimport { prefersReducedMotion } from '../../utils/resolve-css-time-ms';\nimport { resolveSafeUrl } from '../../utils/safe-url';\n\n@Component({\n tag: 'ds-panel-nav',\n styleUrl: 'PanelNav.css',\n styleUrls: ['../../utils/scroll-edge-fade.css'],\n scoped: true,\n})\nexport class PanelNav {\n /** Style slot: `dashboard` or `settings`. Colors match for now;\n * class hooks reserved for texture/glyph layers. Property: `navStyle`. Attribute: `nav-style`. */\n @Prop({ attribute: 'nav-style', reflect: true }) navStyle: NavChromeStyle = 'dashboard';\n\n /** When `true`, style changes apply synchronously — host app owns view transitions. */\n @Prop() disableViewTransition: boolean = false;\n\n /** Nav groups — set via JS property (`el.groups = [...]`) or JSON string attribute. */\n @Prop() groups: string | PanelNavGroup[] = '[]';\n\n /** How items with `href` render:\n * - `anchor` (default): native `<a href>` — works with routers that intercept anchors.\n * - `event`: always `<button>`; host handles navigation via `dsNavSelect`. */\n @Prop() routerMode: PanelNavRouterMode = 'anchor';\n\n /** ID of the currently active/selected nav item. Overridden by `currentUrl` matching when set. */\n @Prop() activeId: string = '';\n\n /** Whether the nav is in collapsed (icon-only) state.\n * Set `storageKey` to persist across reloads. `dsNavToggle` still fires on change. */\n @Prop({ mutable: true }) collapsed: boolean = false;\n\n /** Viewport width (px) below which the nav locks in icon-only mode. 0 = disabled. */\n @Prop() breakpoint: number = 0;\n\n /** `localStorage` key used to persist the collapsed state across page loads.\n * When set, collapsed state is restored on mount and written on each toggle. */\n @Prop() storageKey: string = '';\n\n /** Current route URL (e.g. `window.location.pathname` or the router's active URL).\n * When set the component derives the active item by matching item `href` values\n * against this string (longest segment-boundary prefix wins), overriding `activeId`. */\n @Prop() currentUrl: string = '';\n\n /** Display name for the footer user section */\n @Prop() userName: string = '';\n\n /** Single character shown in the collapsed avatar */\n @Prop() userInitial: string = '';\n\n @Prop() dashboardLabel: string = 'Dashboard';\n @Prop() settingsLabel: string = 'Settings';\n @Prop() accountLabel: string = 'Account';\n /** Keep the account-menu trigger visually active while its popup is open or closing. */\n @Prop() accountMenuExpanded: boolean = false;\n @Prop() dashboardNavigationLabel: string = 'Dashboard navigation';\n @Prop() settingsNavigationLabel: string = 'Settings navigation';\n @Prop() expandNavigationLabel: string = 'Expand navigation';\n @Prop() collapseNavigationLabel: string = 'Collapse navigation';\n /** Localized name for the body when overflow makes it a keyboard-scrollable region. */\n @Prop() navigationItemsLabel: string = 'Navigation items';\n\n /** Emitted when a nav item is clicked. Detail = the item's `id`. */\n @Event() dsNavSelect!: EventEmitter<string>;\n\n /** Emitted when the collapse toggle is clicked. Detail = new collapsed state. */\n @Event() dsNavToggle!: EventEmitter<boolean>;\n\n /** Bubbling lifecycle — `ds-shell-app` pauses chrome metrics during width motion. */\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionStart!: EventEmitter<ChromeTransitionDetail>;\n\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionEnd!: EventEmitter<ChromeTransitionDetail>;\n\n /** Emitted when the footer left button (gear / dashboard) is clicked. */\n @Event() dsNavFooterAction!: EventEmitter<void>;\n\n /** Emitted when the footer user button is clicked. Detail includes the anchor for `ds-menu`. */\n @Event() dsNavUserAction!: EventEmitter<PanelNavUserActionDetail>;\n\n @Element() el!: HTMLElement;\n\n @State() private renderedStyle: NavChromeStyle = 'dashboard';\n @State() private parsedGroups: PanelNavGroup[] = [];\n @State() private isAnimating = false;\n @State() private rovingIndex: number = 0;\n @State() private viewportNarrow: boolean = false;\n @State() private urlDerivedActiveId: string = '';\n @State() private bodyScrollable = false;\n\n private transitionCompletionHandler?: (e: TransitionEvent) => void;\n private transitionFallbackTimer: number | null = null;\n private resizeObserver?: ResizeObserver;\n private scrollRegionObserver?: ResizeObserver;\n private bodyEl?: HTMLElement;\n private initialRenderComplete = false;\n\n // Drag-to-resize state (not @State — no re-render needed)\n private isDragging = false;\n private dragStartX = 0;\n private didSnap = false;\n private lastDeltaX = 0;\n private edgeOverlayTimer: number | null = null;\n private globalMouseMoveHandler?: (e: MouseEvent) => void;\n private globalMouseUpHandler?: () => void;\n\n @State() private showEdgeOverlay = false;\n\n private static readonly HOST_PROP_SYNC_BUDGET = 8;\n\n /** The ID that should be treated as active. `currentUrl` matching takes precedence\n * over `activeId` when both are present. */\n private get effectiveActiveId(): string {\n return this.urlDerivedActiveId || this.activeId;\n }\n\n private get effectiveDisableViewTransition(): boolean {\n return resolvePanelNavDisableVt(\n this.disableViewTransition,\n this.el.getAttribute('disable-view-transition')\n );\n }\n\n @Watch('collapsed')\n onCollapsedChange(_next: boolean, prev: boolean | undefined) {\n if (prev === undefined || !this.initialRenderComplete) return;\n this.startCollapseAnimation();\n }\n\n @Watch('viewportNarrow')\n onViewportNarrowChange(next: boolean, prev: boolean | undefined) {\n if (prev === undefined || !this.initialRenderComplete) return;\n if (next && this.rovingIndex === 0) {\n this.rovingIndex = this.getFirstRovingIndex();\n }\n this.startCollapseAnimation();\n }\n\n @Watch('breakpoint')\n onBreakpointChange() {\n this.disconnectResizeObserver();\n if (this.effectiveBreakpoint() > 0) this.connectResizeObserver();\n }\n\n @Watch('groups')\n onGroupsChange(val: string | PanelNavGroup[]) {\n this.parsedGroups = parsePanelNavGroups(val);\n this.rovingIndex = 0;\n this.syncActiveFromUrl();\n }\n\n @Watch('activeId')\n @Watch('urlDerivedActiveId')\n onActiveIdChange() {\n /* Active route updates aria-current only — roving tab stop stays user-controlled. */\n }\n\n @Watch('currentUrl')\n onCurrentUrlChange() {\n this.syncActiveFromUrl();\n }\n\n @Watch('navStyle')\n onNavStyleChange(newVal: NavChromeStyle) {\n this.renderedStyle = newVal;\n }\n\n componentWillLoad() {\n this.renderedStyle = resolvePanelNavStyle(this.navStyle, this.el.getAttribute('nav-style'));\n this.viewportNarrow = this.isViewportNarrow();\n if (this.storageKey) {\n try {\n const stored = localStorage.getItem(this.storageKey);\n if (stored !== null) this.collapsed = stored === 'true';\n } catch {\n /* localStorage unavailable */\n }\n }\n this.onGroupsChange(this.groups);\n this.syncActiveFromUrl();\n }\n\n componentDidLoad() {\n this.syncHostPropsIfNeeded();\n this.scheduleDeferredHostPropSync();\n if (this.effectiveBreakpoint() > 0) this.connectResizeObserver();\n this.connectScrollRegionObserver();\n this.updateBodyScrollable();\n this.initialRenderComplete = true;\n }\n\n componentDidRender() {\n this.updateBodyScrollable();\n }\n\n disconnectedCallback() {\n this.disconnectResizeObserver();\n this.disconnectScrollRegionObserver();\n this.clearCollapseAnimationCompletion(\n this.el.querySelector('.panel-nav') as HTMLElement | null\n );\n this.clearEdgeOverlayTimer();\n if (this.globalMouseMoveHandler) {\n window.removeEventListener('mousemove', this.globalMouseMoveHandler);\n }\n if (this.globalMouseUpHandler) {\n window.removeEventListener('mouseup', this.globalMouseUpHandler);\n }\n }\n\n private startCollapseAnimation() {\n if (this.isAnimating) return;\n this.isAnimating = true;\n this.dsChromeTransitionStart.emit({ source: 'panel-nav' });\n const panel = this.el.querySelector('.panel-nav') as HTMLElement | null;\n const widthBefore = panel?.getBoundingClientRect().width ?? 0;\n this.clearCollapseAnimationCompletion(panel);\n this.transitionCompletionHandler = (e: TransitionEvent) => {\n if (e.propertyName === 'width' || e.propertyName === 'min-width') {\n this.finishCollapseAnimation(panel);\n }\n };\n panel?.addEventListener('transitionend', this.transitionCompletionHandler);\n panel?.addEventListener('transitioncancel', this.transitionCompletionHandler);\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n if (!this.isAnimating) return;\n const widthAfter = panel?.getBoundingClientRect().width ?? 0;\n if (Math.abs(widthAfter - widthBefore) < 1) {\n this.finishCollapseAnimation(panel);\n return;\n }\n if (!panel) {\n this.finishCollapseAnimation(panel);\n return;\n }\n\n const fallbackMs = panelNavWidthTransitionMs(getComputedStyle(panel));\n if (fallbackMs <= 0) {\n this.finishCollapseAnimation(panel);\n return;\n }\n this.transitionFallbackTimer = window.setTimeout(() => {\n this.finishCollapseAnimation(panel);\n }, fallbackMs);\n });\n });\n }\n\n private finishCollapseAnimation(panel: HTMLElement | null) {\n const wasAnimating = this.isAnimating;\n this.clearCollapseAnimationCompletion(panel);\n if (!wasAnimating) return;\n this.isAnimating = false;\n this.dsChromeTransitionEnd.emit({ source: 'panel-nav' });\n }\n\n private clearCollapseAnimationCompletion(panel: HTMLElement | null) {\n if (this.transitionCompletionHandler) {\n panel?.removeEventListener('transitionend', this.transitionCompletionHandler);\n panel?.removeEventListener('transitioncancel', this.transitionCompletionHandler);\n this.transitionCompletionHandler = undefined;\n }\n if (this.transitionFallbackTimer !== null) {\n window.clearTimeout(this.transitionFallbackTimer);\n this.transitionFallbackTimer = null;\n }\n }\n\n private effectiveBreakpoint(): number {\n if (this.breakpoint > 0) return this.breakpoint;\n const attributeValue = Number(this.el.getAttribute('breakpoint') ?? 0);\n return Number.isFinite(attributeValue) && attributeValue > 0 ? attributeValue : 0;\n }\n\n private isViewportNarrow(): boolean {\n const breakpoint = this.effectiveBreakpoint();\n return typeof window !== 'undefined' && breakpoint > 0 && window.innerWidth < breakpoint;\n }\n\n private connectResizeObserver() {\n this.viewportNarrow = this.isViewportNarrow();\n this.resizeObserver = new ResizeObserver(() => {\n this.viewportNarrow = this.isViewportNarrow();\n });\n this.resizeObserver.observe(document.documentElement);\n }\n\n private disconnectResizeObserver() {\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n }\n\n private connectScrollRegionObserver() {\n this.disconnectScrollRegionObserver();\n if (!this.bodyEl || typeof ResizeObserver === 'undefined') return;\n this.scrollRegionObserver = new ResizeObserver(() => this.updateBodyScrollable());\n this.scrollRegionObserver.observe(this.bodyEl);\n }\n\n private disconnectScrollRegionObserver() {\n this.scrollRegionObserver?.disconnect();\n this.scrollRegionObserver = undefined;\n }\n\n private updateBodyScrollable() {\n const next = Boolean(this.bodyEl && this.bodyEl.scrollHeight > this.bodyEl.clientHeight + 1);\n if (next !== this.bodyScrollable) this.bodyScrollable = next;\n }\n\n /** Derive active ID from the current URL by matching item `href` values.\n * Uses longest segment-boundary prefix match. */\n private syncActiveFromUrl() {\n if (!this.currentUrl) {\n this.urlDerivedActiveId = '';\n return;\n }\n this.urlDerivedActiveId = deriveActiveIdFromUrl(this.currentUrl, this.getAllItems());\n }\n\n /** Re-parse props assigned by the host after componentWillLoad (Angular ngAfterViewInit). */\n private syncHostPropsIfNeeded() {\n if (shouldResyncPanelNavStyle(this.renderedStyle, this.navStyle)) {\n this.renderedStyle = this.navStyle;\n }\n\n if (shouldResyncPanelNavGroups(this.parsedGroups, this.groups)) {\n this.onGroupsChange(this.groups);\n } else if (this.currentUrl) {\n this.syncActiveFromUrl();\n }\n }\n\n /** Poll across animation frames — host props may land without triggering @Watch. */\n private scheduleDeferredHostPropSync() {\n let remaining = PanelNav.HOST_PROP_SYNC_BUDGET;\n const tick = () => {\n this.syncHostPropsIfNeeded();\n if (--remaining > 0) {\n requestAnimationFrame(tick);\n }\n };\n queueMicrotask(tick);\n }\n\n /** Centralised toggle: updates the desktop preference unless breakpoint-locked. */\n private applyToggle(next: boolean) {\n if (this.viewportNarrow) return;\n this.collapsed = next;\n if (this.storageKey) {\n try {\n localStorage.setItem(this.storageKey, String(next));\n } catch {\n /* unavailable */\n }\n }\n this.dsNavToggle.emit(next);\n }\n\n private getAllItems(): PanelNavItem[] {\n return this.parsedGroups.flatMap(g => g.items);\n }\n\n private getFirstRovingIndex(): number {\n return this.viewportNarrow ? 1 : 0;\n }\n\n private getFooterRovingIndex(): number {\n return 1 + this.getAllItems().length;\n }\n\n private getUserRovingIndex(): number {\n return this.getFooterRovingIndex() + 1;\n }\n\n private getRovingElement(index: number): HTMLElement | null {\n const itemCount = this.getAllItems().length;\n if (index === 0) {\n return this.viewportNarrow ? null : this.el.querySelector('.panel-nav__header-btn');\n }\n if (index >= 1 && index <= itemCount) {\n const items = this.el.querySelectorAll<HTMLElement>('.panel-nav__body .panel-nav__item');\n return items[index - 1] ?? null;\n }\n if (index === this.getFooterRovingIndex()) {\n return this.el.querySelector('.panel-nav__footer-btn .button-unfilled');\n }\n if (index === this.getUserRovingIndex()) {\n return this.el.querySelector('.panel-nav__footer-user');\n }\n return null;\n }\n\n private focusRovingAt(index: number) {\n this.rovingIndex = index;\n this.getRovingElement(index)?.focus({ preventScroll: true });\n }\n\n private activateRovingIndex(index: number) {\n const itemCount = this.getAllItems().length;\n const items = this.getAllItems();\n if (index === 0) {\n this.handleToggle();\n return;\n }\n if (index >= 1 && index <= itemCount) {\n this.handleItemClick(items[index - 1].id);\n return;\n }\n if (index === this.getFooterRovingIndex()) {\n this.handleFooterAction();\n return;\n }\n if (index === this.getUserRovingIndex()) {\n const anchor = this.el.querySelector(\n `#${PANEL_NAV_USER_MENU_ANCHOR_ID}`\n ) as HTMLElement | null;\n if (anchor)\n this.dsNavUserAction.emit({ anchor, menuPlacement: PANEL_NAV_USER_MENU_PLACEMENT });\n }\n }\n\n private handleRovingKeyDown(e: KeyboardEvent, index: number) {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.activateRovingIndex(index);\n return;\n }\n\n const footerIdx = this.getFooterRovingIndex();\n const userIdx = this.getUserRovingIndex();\n const firstIdx = this.getFirstRovingIndex();\n let next: number | undefined;\n\n switch (e.key) {\n case 'ArrowDown':\n if (index === userIdx) return;\n if (index === footerIdx) next = userIdx;\n else next = index + 1;\n break;\n case 'ArrowUp':\n if (index === firstIdx) return;\n if (index === userIdx) next = footerIdx;\n else if (index === footerIdx) next = footerIdx - 1;\n else next = index - 1;\n break;\n case 'ArrowRight':\n if (index === footerIdx) next = userIdx;\n else return;\n break;\n case 'ArrowLeft':\n if (index === userIdx) next = footerIdx;\n else return;\n break;\n case 'Home':\n e.preventDefault();\n next = firstIdx;\n break;\n case 'End':\n e.preventDefault();\n next = userIdx;\n break;\n default:\n return;\n }\n\n if (next === undefined || next === index) return;\n e.preventDefault();\n this.focusRovingAt(next);\n }\n\n private handleItemClick(id: string) {\n this.dsNavSelect.emit(id);\n }\n\n private handleToggle() {\n const next = resolvePanelNavToggle(this.collapsed, this.viewportNarrow);\n if (next !== null) this.applyToggle(next);\n }\n\n /** Toggle the desktop preference. No-op while the breakpoint locks the panel collapsed. */\n @Method()\n async toggleCollapsed() {\n this.handleToggle();\n }\n\n private handleFooterAction() {\n this.dsNavFooterAction.emit();\n }\n\n private handleUserAction(e: MouseEvent) {\n const anchor = e.currentTarget as HTMLElement;\n this.dsNavUserAction.emit({ anchor, menuPlacement: PANEL_NAV_USER_MENU_PLACEMENT });\n }\n\n private clearEdgeOverlayTimer() {\n if (this.edgeOverlayTimer !== null) {\n window.clearTimeout(this.edgeOverlayTimer);\n this.edgeOverlayTimer = null;\n }\n }\n\n private handleResizeHandleMouseEnter() {\n this.clearEdgeOverlayTimer();\n if (prefersReducedMotion()) {\n this.showEdgeOverlay = true;\n return;\n }\n this.edgeOverlayTimer = window.setTimeout(() => {\n this.showEdgeOverlay = true;\n this.edgeOverlayTimer = null;\n }, 500);\n }\n\n private handleResizeHandleMouseLeave() {\n if (this.isDragging) return;\n this.clearEdgeOverlayTimer();\n this.showEdgeOverlay = false;\n }\n\n private handleResizeHandleMouseDown(e: MouseEvent) {\n e.preventDefault();\n e.stopPropagation();\n\n const wasCollapsed = this.collapsed || this.viewportNarrow;\n this.isDragging = true;\n this.dragStartX = e.clientX;\n this.didSnap = false;\n this.lastDeltaX = 0;\n this.clearEdgeOverlayTimer();\n this.showEdgeOverlay = false;\n\n document.body.style.cursor = 'ew-resize';\n document.body.style.userSelect = 'none';\n\n const onMove = (ev: MouseEvent) => {\n const deltaX = ev.clientX - this.dragStartX;\n this.lastDeltaX = Math.abs(deltaX);\n if (this.didSnap) return;\n if (!wasCollapsed && deltaX < -8) {\n this.applyToggle(true);\n this.didSnap = true;\n } else if (wasCollapsed && deltaX > 8) {\n this.applyToggle(false);\n this.didSnap = true;\n }\n };\n\n const onUp = () => {\n if (!this.didSnap && this.lastDeltaX < 3) {\n this.applyToggle(!wasCollapsed);\n }\n this.isDragging = false;\n this.showEdgeOverlay = false;\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n window.removeEventListener('mousemove', onMove);\n window.removeEventListener('mouseup', onUp);\n this.globalMouseMoveHandler = undefined;\n this.globalMouseUpHandler = undefined;\n };\n\n this.globalMouseMoveHandler = onMove;\n this.globalMouseUpHandler = onUp;\n window.addEventListener('mousemove', onMove);\n window.addEventListener('mouseup', onUp);\n }\n\n private renderFooterAction(isDashboardChrome: boolean) {\n // Short chrome labels — same string for tip + aria (not \"Open/Go to …\").\n const footerLabel = isDashboardChrome ? this.settingsLabel : this.dashboardLabel;\n return (\n <ds-tooltip label={footerLabel} side=\"right\" size=\"sm\">\n <ds-button-unfilled\n variant=\"icon\"\n class=\"panel-nav__footer-btn\"\n icon={isDashboardChrome ? 'Gear' : 'Dashboard'}\n activeFill={false}\n hasBorder={false}\n focusTabIndex={this.rovingIndex === this.getFooterRovingIndex() ? 0 : -1}\n aria-label={footerLabel}\n onDsClick={() => this.handleFooterAction()}\n onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, this.getFooterRovingIndex())}\n onFocusin={() => {\n this.rovingIndex = this.getFooterRovingIndex();\n }}\n />\n </ds-tooltip>\n );\n }\n\n private renderFooterUser(collapsed: boolean, userName: string, userInitial: string) {\n // Always wrap so collapse morph CSS can transition on a stable button node.\n // Tip only when collapsed (empty label → ds-tooltip skips show).\n const tipLabel = collapsed ? this.accountLabel : '';\n const userButton = (\n <button\n type=\"button\"\n id={PANEL_NAV_USER_MENU_ANCHOR_ID}\n class={{\n 'panel-nav__item': true,\n 'panel-nav__item--active': this.accountMenuExpanded,\n 'panel-nav__footer-user': true,\n 'panel-nav__footer-user--menu-expanded': this.accountMenuExpanded,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n }}\n tabIndex={this.rovingIndex === this.getUserRovingIndex() ? 0 : -1}\n aria-label={this.accountLabel}\n aria-haspopup=\"menu\"\n aria-expanded={String(this.accountMenuExpanded)}\n onClick={e => this.handleUserAction(e)}\n onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, this.getUserRovingIndex())}\n onFocus={() => {\n this.rovingIndex = this.getUserRovingIndex();\n }}\n >\n <ds-text\n class=\"panel-nav__item-label panel-nav__footer-user-label panel-nav__item-label-text ds-control--md\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis\n color=\"inherit\"\n lineTruncation={1}\n >\n {userName}\n </ds-text>\n <span class=\"panel-nav__item-icon panel-nav__footer-user-icon\" aria-hidden=\"true\">\n <span class=\"panel-nav__footer-icon-expanded\">\n <ds-icon name=\"ChevronUpDown\" size=\"md\" color=\"inherit\" />\n </span>\n <span class=\"panel-nav__footer-icon-collapsed\">\n <ds-icon name=\"Circle\" size=\"md\" color=\"inherit\" />\n <ds-text\n class=\"panel-nav__user-initial\"\n as=\"span\"\n variant=\"text-caption\"\n emphasis\n align=\"center\"\n color=\"inherit\"\n >\n {userInitial}\n </ds-text>\n </span>\n </span>\n </button>\n );\n\n return (\n <ds-tooltip label={tipLabel} side=\"right\" size=\"sm\">\n {userButton}\n </ds-tooltip>\n );\n }\n\n private renderNavItem(item: PanelNavItem, idx: number, collapsed: boolean) {\n const isActive = item.id === this.effectiveActiveId;\n const rovingPosition = idx + 1;\n\n const itemContent = [\n <span class=\"panel-nav__item-icon\">\n <ds-icon name={item.icon} size=\"md\" color=\"inherit\" />\n </span>,\n <ds-text\n class=\"panel-nav__item-label panel-nav__item-label-text ds-control--md\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis={isActive}\n color=\"inherit\"\n lineTruncation={1}\n >\n {item.label}\n </ds-text>,\n item.dot && (\n <span class=\"panel-nav__item-dot-box\" aria-hidden=\"true\">\n <ds-badge class=\"panel-nav__item-dot\" variant=\"dot\" hasRing={collapsed} label=\"\" />\n </span>\n ),\n ];\n\n const sharedProps = {\n class: {\n 'panel-nav__item': true,\n 'panel-nav__item--active': isActive,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n },\n 'aria-current': isActive ? ('page' as const) : undefined,\n tabIndex: rovingPosition === this.rovingIndex ? 0 : -1,\n onClick: () => this.handleItemClick(item.id),\n onKeyDown: (e: KeyboardEvent) => this.handleRovingKeyDown(e, rovingPosition),\n onFocus: () => {\n this.rovingIndex = rovingPosition;\n },\n };\n\n const href = resolveSafeUrl(item.href);\n const useAnchor = this.routerMode === 'anchor' && href;\n const control = useAnchor ? (\n <a {...sharedProps} href={href}>\n {itemContent}\n </a>\n ) : (\n <button {...sharedProps} type=\"button\">\n {itemContent}\n </button>\n );\n\n // Always wrap so label fade CSS can transition on a stable item node.\n // Tip only when collapsed (empty label → ds-tooltip skips show).\n return (\n <ds-tooltip label={collapsed ? item.label : ''} side=\"right\" size=\"sm\">\n {control}\n </ds-tooltip>\n );\n }\n\n render() {\n const chromeStyle = this.renderedStyle;\n const isDashboardChrome = chromeStyle === 'dashboard';\n const isDashboard = this.navStyle === 'dashboard';\n const collapsed = this.collapsed || this.viewportNarrow;\n const { userName, userInitial } = this;\n\n const navCls: Record<string, boolean> = {\n 'panel-nav': true,\n 'ds-control--md': true,\n 'panel-nav--dashboard': isDashboard,\n 'panel-nav--settings': !isDashboard,\n 'panel-nav--collapsed': collapsed,\n 'panel-nav--breakpoint-locked': this.viewportNarrow,\n 'panel-nav--animating': this.isAnimating,\n };\n\n return (\n <Host style={{ display: 'block', position: 'relative' }}>\n <nav\n class={navCls}\n aria-label={\n isDashboardChrome ? this.dashboardNavigationLabel : this.settingsNavigationLabel\n }\n >\n {/* ── Header: Motive logo, reveals collapse toggle on hover ── */}\n <div class=\"panel-nav__header ds-chrome-row ds-chrome-space--md\">\n <button\n type=\"button\"\n class=\"panel-nav__header-btn ds-focus-ring-inset ds-interaction-fill\"\n disabled={this.viewportNarrow}\n tabIndex={!this.viewportNarrow && this.rovingIndex === 0 ? 0 : -1}\n onClick={() => this.handleToggle()}\n onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, 0)}\n onFocus={() => {\n this.rovingIndex = 0;\n }}\n aria-label={collapsed ? this.expandNavigationLabel : this.collapseNavigationLabel}\n aria-expanded={collapsed ? 'false' : 'true'}\n >\n {/* Motive M mark — fades out on hover to reveal collapse toggle */}\n <span class=\"panel-nav__header-logo\" aria-hidden=\"true\">\n <svg\n class=\"panel-nav__m-mark\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n focusable=\"false\"\n aria-hidden=\"true\"\n >\n <path\n d=\"M11.1159 4.31537H7.67021L2.53401 13.0703H0V15.6875H4.02716L8.24319 8.49978V15.6846H11.6342L15.8289 8.47829V15.6846H18.7122V4.3125H15.2559L11.1159 11.3648V4.31537Z\"\n fill=\"currentColor\"\n />\n </svg>\n </span>\n {/* Collapse / expand icon — revealed on hover via CSS */}\n <span class=\"panel-nav__header-toggle\" aria-hidden=\"true\">\n <ds-icon\n name={collapsed ? 'LeftExpandB' : 'LeftCollapseB'}\n size=\"md\"\n color=\"inherit\"\n />\n </span>\n </button>\n </div>\n\n {/* ── Scrollable body ── */}\n <div\n class={{\n 'panel-nav__body': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--md': true,\n 'ds-scrollbar-hidden': true,\n 'ds-focus-ring': this.bodyScrollable,\n ...scrollEdgeFadeClassMap({ edges: 'bottom' }),\n }}\n ref={element => {\n const next = (element as HTMLElement) ?? undefined;\n if (next === this.bodyEl) return;\n this.bodyEl = next;\n this.connectScrollRegionObserver();\n }}\n role={this.bodyScrollable ? 'region' : undefined}\n aria-label={this.bodyScrollable ? this.navigationItemsLabel : undefined}\n tabIndex={this.bodyScrollable ? 0 : undefined}\n >\n <div class=\"panel-nav__sections\">\n {(() => {\n let flatIdx = 0;\n return this.parsedGroups.map(group => (\n <div class=\"panel-nav__group\">\n {group.label && (\n <ds-text\n class=\"panel-nav__group-label ds-control--md\"\n as=\"span\"\n variant=\"text-caption\"\n emphasis\n color=\"inherit\"\n >\n {group.label}\n </ds-text>\n )}\n {group.items.map(item => this.renderNavItem(item, flatIdx++, collapsed))}\n </div>\n ));\n })()}\n </div>\n </div>\n\n {/* ── Footer ── */}\n <div class=\"panel-nav__footer\">\n {this.renderFooterAction(isDashboardChrome)}\n {this.renderFooterUser(collapsed, userName, userInitial)}\n </div>\n </nav>\n\n {/* Drag-to-resize handle — always rendered, hidden only when auto-collapsed by breakpoint */}\n {!this.viewportNarrow && (\n <div\n class={{\n 'panel-nav__resize-handle': true,\n 'panel-nav__resize-handle--overlay': this.showEdgeOverlay,\n }}\n onMouseEnter={() => this.handleResizeHandleMouseEnter()}\n onMouseLeave={() => this.handleResizeHandleMouseLeave()}\n onMouseDown={(e: MouseEvent) => this.handleResizeHandleMouseDown(e)}\n aria-hidden=\"true\"\n />\n )}\n </Host>\n );\n }\n}\n",
628
+ "content": "import {\n Component,\n Prop,\n Event,\n EventEmitter,\n Watch,\n State,\n Element,\n Method,\n h,\n Host,\n} from '@stencil/core';\nimport type { ChromeTransitionDetail } from '../../shell/chrome-transition';\nimport type { NavChromeStyle } from '../../shell/nav-chrome';\nimport {\n deriveActiveIdFromUrl,\n panelNavWidthTransitionMs,\n parsePanelNavGroups,\n resolvePanelNavDisableVt,\n resolvePanelNavStyle,\n resolvePanelNavToggle,\n shouldResyncPanelNavGroups,\n shouldResyncPanelNavStyle,\n} from './panel-nav-utils';\nimport {\n PANEL_NAV_USER_MENU_ANCHOR_ID,\n PANEL_NAV_USER_MENU_PLACEMENT,\n type PanelNavGroup,\n type PanelNavItem,\n type PanelNavRouterMode,\n type PanelNavUserActionDetail,\n} from './panel-nav-types';\nimport { scrollEdgeFadeClassMap } from '../../utils/scroll-edge-fade';\nimport { prefersReducedMotion } from '../../utils/resolve-css-time-ms';\nimport { resolveSafeUrl } from '../../utils/safe-url';\n\n@Component({\n tag: 'ds-panel-nav',\n styleUrl: 'PanelNav.css',\n styleUrls: ['../../utils/scroll-edge-fade.css'],\n scoped: true,\n})\nexport class PanelNav {\n /** Style slot: `dashboard` or `settings`. Colors match for now;\n * class hooks reserved for texture/glyph layers. Property: `navStyle`. Attribute: `nav-style`. */\n @Prop({ attribute: 'nav-style', reflect: true }) navStyle: NavChromeStyle = 'dashboard';\n\n /** When `true`, style changes apply synchronously — host app owns view transitions. */\n @Prop() disableViewTransition: boolean = false;\n\n /** Nav groups — set via JS property (`el.groups = [...]`) or JSON string attribute. */\n @Prop() groups: string | PanelNavGroup[] = '[]';\n\n /** How items with `href` render:\n * - `anchor` (default): native `<a href>` — works with routers that intercept anchors.\n * - `event`: always `<button>`; host handles navigation via `dsNavSelect`. */\n @Prop() routerMode: PanelNavRouterMode = 'anchor';\n\n /** ID of the currently active/selected nav item. Overridden by `currentUrl` matching when set. */\n @Prop() activeId: string = '';\n\n /** Whether the nav is in collapsed (icon-only) state.\n * Set `storageKey` to persist across reloads. `dsNavToggle` still fires on change. */\n @Prop({ mutable: true }) collapsed: boolean = false;\n\n /** Viewport width (px) below which the nav locks in icon-only mode. 0 = disabled. */\n @Prop() breakpoint: number = 0;\n\n /** `localStorage` key used to persist the collapsed state across page loads.\n * When set, collapsed state is restored on mount and written on each toggle. */\n @Prop() storageKey: string = '';\n\n /** Current route URL (e.g. `window.location.pathname` or the router's active URL).\n * When set the component derives the active item by matching item `href` values\n * against this string (longest segment-boundary prefix wins), overriding `activeId`. */\n @Prop() currentUrl: string = '';\n\n /** Display name for the footer user section */\n @Prop() userName: string = '';\n\n /** Single character shown in the collapsed avatar */\n @Prop() userInitial: string = '';\n\n @Prop() dashboardLabel: string = 'Dashboard';\n @Prop() settingsLabel: string = 'Settings';\n @Prop() accountLabel: string = 'Account';\n /** Keep the account-menu trigger visually pressed while its popup is open or closing. */\n @Prop() accountMenuExpanded: boolean = false;\n @Prop() dashboardNavigationLabel: string = 'Dashboard navigation';\n @Prop() settingsNavigationLabel: string = 'Settings navigation';\n @Prop() expandNavigationLabel: string = 'Expand navigation';\n @Prop() collapseNavigationLabel: string = 'Collapse navigation';\n /** Localized name for the body when overflow makes it a keyboard-scrollable region. */\n @Prop() navigationItemsLabel: string = 'Navigation items';\n\n /** Emitted when a nav item is clicked. Detail = the item's `id`. */\n @Event() dsNavSelect!: EventEmitter<string>;\n\n /** Emitted when the collapse toggle is clicked. Detail = new collapsed state. */\n @Event() dsNavToggle!: EventEmitter<boolean>;\n\n /** Bubbling lifecycle — `ds-shell-app` pauses chrome metrics during width motion. */\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionStart!: EventEmitter<ChromeTransitionDetail>;\n\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionEnd!: EventEmitter<ChromeTransitionDetail>;\n\n /** Emitted when the footer left button (gear / dashboard) is clicked. */\n @Event() dsNavFooterAction!: EventEmitter<void>;\n\n /** Emitted when the footer user button is clicked. Detail includes the anchor for `ds-menu`. */\n @Event() dsNavUserAction!: EventEmitter<PanelNavUserActionDetail>;\n\n @Element() el!: HTMLElement;\n\n @State() private renderedStyle: NavChromeStyle = 'dashboard';\n @State() private parsedGroups: PanelNavGroup[] = [];\n @State() private isAnimating = false;\n @State() private rovingIndex: number = 0;\n @State() private viewportNarrow: boolean = false;\n @State() private urlDerivedActiveId: string = '';\n @State() private bodyScrollable = false;\n\n private transitionCompletionHandler?: (e: TransitionEvent) => void;\n private transitionFallbackTimer: number | null = null;\n private resizeObserver?: ResizeObserver;\n private scrollRegionObserver?: ResizeObserver;\n private bodyEl?: HTMLElement;\n private initialRenderComplete = false;\n\n // Drag-to-resize state (not @State — no re-render needed)\n private isDragging = false;\n private dragStartX = 0;\n private didSnap = false;\n private lastDeltaX = 0;\n private edgeOverlayTimer: number | null = null;\n private globalMouseMoveHandler?: (e: MouseEvent) => void;\n private globalMouseUpHandler?: () => void;\n\n @State() private showEdgeOverlay = false;\n\n private static readonly HOST_PROP_SYNC_BUDGET = 8;\n\n /** The ID that should be treated as active. `currentUrl` matching takes precedence\n * over `activeId` when both are present. */\n private get effectiveActiveId(): string {\n return this.urlDerivedActiveId || this.activeId;\n }\n\n private get effectiveDisableViewTransition(): boolean {\n return resolvePanelNavDisableVt(\n this.disableViewTransition,\n this.el.getAttribute('disable-view-transition')\n );\n }\n\n @Watch('collapsed')\n onCollapsedChange(_next: boolean, prev: boolean | undefined) {\n if (prev === undefined || !this.initialRenderComplete) return;\n this.startCollapseAnimation();\n }\n\n @Watch('viewportNarrow')\n onViewportNarrowChange(next: boolean, prev: boolean | undefined) {\n if (prev === undefined || !this.initialRenderComplete) return;\n if (next && this.rovingIndex === 0) {\n this.rovingIndex = this.getFirstRovingIndex();\n }\n this.startCollapseAnimation();\n }\n\n @Watch('breakpoint')\n onBreakpointChange() {\n this.disconnectResizeObserver();\n if (this.effectiveBreakpoint() > 0) this.connectResizeObserver();\n }\n\n @Watch('groups')\n onGroupsChange(val: string | PanelNavGroup[]) {\n this.parsedGroups = parsePanelNavGroups(val);\n this.rovingIndex = 0;\n this.syncActiveFromUrl();\n }\n\n @Watch('activeId')\n @Watch('urlDerivedActiveId')\n onActiveIdChange() {\n /* Active route updates aria-current only — roving tab stop stays user-controlled. */\n }\n\n @Watch('currentUrl')\n onCurrentUrlChange() {\n this.syncActiveFromUrl();\n }\n\n @Watch('navStyle')\n onNavStyleChange(newVal: NavChromeStyle) {\n this.renderedStyle = newVal;\n }\n\n componentWillLoad() {\n this.renderedStyle = resolvePanelNavStyle(this.navStyle, this.el.getAttribute('nav-style'));\n this.viewportNarrow = this.isViewportNarrow();\n if (this.storageKey) {\n try {\n const stored = localStorage.getItem(this.storageKey);\n if (stored !== null) this.collapsed = stored === 'true';\n } catch {\n /* localStorage unavailable */\n }\n }\n this.onGroupsChange(this.groups);\n this.syncActiveFromUrl();\n }\n\n componentDidLoad() {\n this.syncHostPropsIfNeeded();\n this.scheduleDeferredHostPropSync();\n if (this.effectiveBreakpoint() > 0) this.connectResizeObserver();\n this.connectScrollRegionObserver();\n this.updateBodyScrollable();\n this.initialRenderComplete = true;\n }\n\n componentDidRender() {\n this.updateBodyScrollable();\n }\n\n disconnectedCallback() {\n this.disconnectResizeObserver();\n this.disconnectScrollRegionObserver();\n this.clearCollapseAnimationCompletion(\n this.el.querySelector('.panel-nav') as HTMLElement | null\n );\n this.clearEdgeOverlayTimer();\n if (this.globalMouseMoveHandler) {\n window.removeEventListener('mousemove', this.globalMouseMoveHandler);\n }\n if (this.globalMouseUpHandler) {\n window.removeEventListener('mouseup', this.globalMouseUpHandler);\n }\n }\n\n private startCollapseAnimation() {\n if (this.isAnimating) return;\n this.isAnimating = true;\n this.dsChromeTransitionStart.emit({ source: 'panel-nav' });\n const panel = this.el.querySelector('.panel-nav') as HTMLElement | null;\n const widthBefore = panel?.getBoundingClientRect().width ?? 0;\n this.clearCollapseAnimationCompletion(panel);\n this.transitionCompletionHandler = (e: TransitionEvent) => {\n if (e.propertyName === 'width' || e.propertyName === 'min-width') {\n this.finishCollapseAnimation(panel);\n }\n };\n panel?.addEventListener('transitionend', this.transitionCompletionHandler);\n panel?.addEventListener('transitioncancel', this.transitionCompletionHandler);\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n if (!this.isAnimating) return;\n const widthAfter = panel?.getBoundingClientRect().width ?? 0;\n if (Math.abs(widthAfter - widthBefore) < 1) {\n this.finishCollapseAnimation(panel);\n return;\n }\n if (!panel) {\n this.finishCollapseAnimation(panel);\n return;\n }\n\n const fallbackMs = panelNavWidthTransitionMs(getComputedStyle(panel));\n if (fallbackMs <= 0) {\n this.finishCollapseAnimation(panel);\n return;\n }\n this.transitionFallbackTimer = window.setTimeout(() => {\n this.finishCollapseAnimation(panel);\n }, fallbackMs);\n });\n });\n }\n\n private finishCollapseAnimation(panel: HTMLElement | null) {\n const wasAnimating = this.isAnimating;\n this.clearCollapseAnimationCompletion(panel);\n if (!wasAnimating) return;\n this.isAnimating = false;\n this.dsChromeTransitionEnd.emit({ source: 'panel-nav' });\n }\n\n private clearCollapseAnimationCompletion(panel: HTMLElement | null) {\n if (this.transitionCompletionHandler) {\n panel?.removeEventListener('transitionend', this.transitionCompletionHandler);\n panel?.removeEventListener('transitioncancel', this.transitionCompletionHandler);\n this.transitionCompletionHandler = undefined;\n }\n if (this.transitionFallbackTimer !== null) {\n window.clearTimeout(this.transitionFallbackTimer);\n this.transitionFallbackTimer = null;\n }\n }\n\n private effectiveBreakpoint(): number {\n if (this.breakpoint > 0) return this.breakpoint;\n const attributeValue = Number(this.el.getAttribute('breakpoint') ?? 0);\n return Number.isFinite(attributeValue) && attributeValue > 0 ? attributeValue : 0;\n }\n\n private isViewportNarrow(): boolean {\n const breakpoint = this.effectiveBreakpoint();\n return typeof window !== 'undefined' && breakpoint > 0 && window.innerWidth < breakpoint;\n }\n\n private connectResizeObserver() {\n this.viewportNarrow = this.isViewportNarrow();\n this.resizeObserver = new ResizeObserver(() => {\n this.viewportNarrow = this.isViewportNarrow();\n });\n this.resizeObserver.observe(document.documentElement);\n }\n\n private disconnectResizeObserver() {\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n }\n\n private connectScrollRegionObserver() {\n this.disconnectScrollRegionObserver();\n if (!this.bodyEl || typeof ResizeObserver === 'undefined') return;\n this.scrollRegionObserver = new ResizeObserver(() => this.updateBodyScrollable());\n this.scrollRegionObserver.observe(this.bodyEl);\n }\n\n private disconnectScrollRegionObserver() {\n this.scrollRegionObserver?.disconnect();\n this.scrollRegionObserver = undefined;\n }\n\n private updateBodyScrollable() {\n const next = Boolean(this.bodyEl && this.bodyEl.scrollHeight > this.bodyEl.clientHeight + 1);\n if (next !== this.bodyScrollable) this.bodyScrollable = next;\n }\n\n /** Derive active ID from the current URL by matching item `href` values.\n * Uses longest segment-boundary prefix match. */\n private syncActiveFromUrl() {\n if (!this.currentUrl) {\n this.urlDerivedActiveId = '';\n return;\n }\n this.urlDerivedActiveId = deriveActiveIdFromUrl(this.currentUrl, this.getAllItems());\n }\n\n /** Re-parse props assigned by the host after componentWillLoad (Angular ngAfterViewInit). */\n private syncHostPropsIfNeeded() {\n if (shouldResyncPanelNavStyle(this.renderedStyle, this.navStyle)) {\n this.renderedStyle = this.navStyle;\n }\n\n if (shouldResyncPanelNavGroups(this.parsedGroups, this.groups)) {\n this.onGroupsChange(this.groups);\n } else if (this.currentUrl) {\n this.syncActiveFromUrl();\n }\n }\n\n /** Poll across animation frames — host props may land without triggering @Watch. */\n private scheduleDeferredHostPropSync() {\n let remaining = PanelNav.HOST_PROP_SYNC_BUDGET;\n const tick = () => {\n this.syncHostPropsIfNeeded();\n if (--remaining > 0) {\n requestAnimationFrame(tick);\n }\n };\n queueMicrotask(tick);\n }\n\n /** Centralised toggle: updates the desktop preference unless breakpoint-locked. */\n private applyToggle(next: boolean) {\n if (this.viewportNarrow) return;\n this.collapsed = next;\n if (this.storageKey) {\n try {\n localStorage.setItem(this.storageKey, String(next));\n } catch {\n /* unavailable */\n }\n }\n this.dsNavToggle.emit(next);\n }\n\n private getAllItems(): PanelNavItem[] {\n return this.parsedGroups.flatMap(g => g.items);\n }\n\n private getFirstRovingIndex(): number {\n return this.viewportNarrow ? 1 : 0;\n }\n\n private getFooterRovingIndex(): number {\n return 1 + this.getAllItems().length;\n }\n\n private getUserRovingIndex(): number {\n return this.getFooterRovingIndex() + 1;\n }\n\n private getRovingElement(index: number): HTMLElement | null {\n const itemCount = this.getAllItems().length;\n if (index === 0) {\n return this.viewportNarrow ? null : this.el.querySelector('.panel-nav__header-btn');\n }\n if (index >= 1 && index <= itemCount) {\n const items = this.el.querySelectorAll<HTMLElement>('.panel-nav__body .panel-nav__item');\n return items[index - 1] ?? null;\n }\n if (index === this.getFooterRovingIndex()) {\n return this.el.querySelector('.panel-nav__footer-btn .button-unfilled');\n }\n if (index === this.getUserRovingIndex()) {\n return this.el.querySelector('.panel-nav__footer-user');\n }\n return null;\n }\n\n private focusRovingAt(index: number) {\n this.rovingIndex = index;\n this.getRovingElement(index)?.focus({ preventScroll: true });\n }\n\n private activateRovingIndex(index: number) {\n const itemCount = this.getAllItems().length;\n const items = this.getAllItems();\n if (index === 0) {\n this.handleToggle();\n return;\n }\n if (index >= 1 && index <= itemCount) {\n this.handleItemClick(items[index - 1].id);\n return;\n }\n if (index === this.getFooterRovingIndex()) {\n this.handleFooterAction();\n return;\n }\n if (index === this.getUserRovingIndex()) {\n const anchor = this.el.querySelector(\n `#${PANEL_NAV_USER_MENU_ANCHOR_ID}`\n ) as HTMLElement | null;\n if (anchor)\n this.dsNavUserAction.emit({ anchor, menuPlacement: PANEL_NAV_USER_MENU_PLACEMENT });\n }\n }\n\n private handleRovingKeyDown(e: KeyboardEvent, index: number) {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.activateRovingIndex(index);\n return;\n }\n\n const footerIdx = this.getFooterRovingIndex();\n const userIdx = this.getUserRovingIndex();\n const firstIdx = this.getFirstRovingIndex();\n let next: number | undefined;\n\n switch (e.key) {\n case 'ArrowDown':\n if (index === userIdx) return;\n if (index === footerIdx) next = userIdx;\n else next = index + 1;\n break;\n case 'ArrowUp':\n if (index === firstIdx) return;\n if (index === userIdx) next = footerIdx;\n else if (index === footerIdx) next = footerIdx - 1;\n else next = index - 1;\n break;\n case 'ArrowRight':\n if (index === footerIdx) next = userIdx;\n else return;\n break;\n case 'ArrowLeft':\n if (index === userIdx) next = footerIdx;\n else return;\n break;\n case 'Home':\n e.preventDefault();\n next = firstIdx;\n break;\n case 'End':\n e.preventDefault();\n next = userIdx;\n break;\n default:\n return;\n }\n\n if (next === undefined || next === index) return;\n e.preventDefault();\n this.focusRovingAt(next);\n }\n\n private handleItemClick(id: string) {\n this.dsNavSelect.emit(id);\n }\n\n private handleToggle() {\n const next = resolvePanelNavToggle(this.collapsed, this.viewportNarrow);\n if (next !== null) this.applyToggle(next);\n }\n\n /** Toggle the desktop preference. No-op while the breakpoint locks the panel collapsed. */\n @Method()\n async toggleCollapsed() {\n this.handleToggle();\n }\n\n private handleFooterAction() {\n this.dsNavFooterAction.emit();\n }\n\n private handleUserAction(e: MouseEvent) {\n const anchor = e.currentTarget as HTMLElement;\n this.dsNavUserAction.emit({ anchor, menuPlacement: PANEL_NAV_USER_MENU_PLACEMENT });\n }\n\n private clearEdgeOverlayTimer() {\n if (this.edgeOverlayTimer !== null) {\n window.clearTimeout(this.edgeOverlayTimer);\n this.edgeOverlayTimer = null;\n }\n }\n\n private handleResizeHandleMouseEnter() {\n this.clearEdgeOverlayTimer();\n if (prefersReducedMotion()) {\n this.showEdgeOverlay = true;\n return;\n }\n this.edgeOverlayTimer = window.setTimeout(() => {\n this.showEdgeOverlay = true;\n this.edgeOverlayTimer = null;\n }, 500);\n }\n\n private handleResizeHandleMouseLeave() {\n if (this.isDragging) return;\n this.clearEdgeOverlayTimer();\n this.showEdgeOverlay = false;\n }\n\n private handleResizeHandleMouseDown(e: MouseEvent) {\n e.preventDefault();\n e.stopPropagation();\n\n const wasCollapsed = this.collapsed || this.viewportNarrow;\n this.isDragging = true;\n this.dragStartX = e.clientX;\n this.didSnap = false;\n this.lastDeltaX = 0;\n this.clearEdgeOverlayTimer();\n this.showEdgeOverlay = false;\n\n document.body.style.cursor = 'ew-resize';\n document.body.style.userSelect = 'none';\n\n const onMove = (ev: MouseEvent) => {\n const deltaX = ev.clientX - this.dragStartX;\n this.lastDeltaX = Math.abs(deltaX);\n if (this.didSnap) return;\n if (!wasCollapsed && deltaX < -8) {\n this.applyToggle(true);\n this.didSnap = true;\n } else if (wasCollapsed && deltaX > 8) {\n this.applyToggle(false);\n this.didSnap = true;\n }\n };\n\n const onUp = () => {\n if (!this.didSnap && this.lastDeltaX < 3) {\n this.applyToggle(!wasCollapsed);\n }\n this.isDragging = false;\n this.showEdgeOverlay = false;\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n window.removeEventListener('mousemove', onMove);\n window.removeEventListener('mouseup', onUp);\n this.globalMouseMoveHandler = undefined;\n this.globalMouseUpHandler = undefined;\n };\n\n this.globalMouseMoveHandler = onMove;\n this.globalMouseUpHandler = onUp;\n window.addEventListener('mousemove', onMove);\n window.addEventListener('mouseup', onUp);\n }\n\n private renderFooterAction(isDashboardChrome: boolean) {\n // Short chrome labels — same string for tip + aria (not \"Open/Go to …\").\n const footerLabel = isDashboardChrome ? this.settingsLabel : this.dashboardLabel;\n return (\n <ds-tooltip label={footerLabel} side=\"right\" size=\"sm\">\n <ds-button-unfilled\n variant=\"icon\"\n class=\"panel-nav__footer-btn\"\n icon={isDashboardChrome ? 'Gear' : 'Dashboard'}\n activeFill={false}\n hasBorder={false}\n focusTabIndex={this.rovingIndex === this.getFooterRovingIndex() ? 0 : -1}\n aria-label={footerLabel}\n onDsClick={() => this.handleFooterAction()}\n onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, this.getFooterRovingIndex())}\n onFocusin={() => {\n this.rovingIndex = this.getFooterRovingIndex();\n }}\n />\n </ds-tooltip>\n );\n }\n\n private renderFooterUser(collapsed: boolean, userName: string, userInitial: string) {\n // Always wrap so collapse morph CSS can transition on a stable button node.\n // Tip only when collapsed (empty label → ds-tooltip skips show).\n const tipLabel = collapsed ? this.accountLabel : '';\n const userButton = (\n <button\n type=\"button\"\n id={PANEL_NAV_USER_MENU_ANCHOR_ID}\n class={{\n 'panel-nav__item': true,\n 'panel-nav__footer-user': true,\n 'panel-nav__footer-user--menu-expanded': this.accountMenuExpanded,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n }}\n tabIndex={this.rovingIndex === this.getUserRovingIndex() ? 0 : -1}\n aria-label={this.accountLabel}\n aria-haspopup=\"menu\"\n aria-expanded={String(this.accountMenuExpanded)}\n onClick={e => this.handleUserAction(e)}\n onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, this.getUserRovingIndex())}\n onFocus={() => {\n this.rovingIndex = this.getUserRovingIndex();\n }}\n >\n <ds-text\n class=\"panel-nav__item-label panel-nav__footer-user-label panel-nav__item-label-text ds-control--md\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis\n color=\"inherit\"\n lineTruncation={1}\n >\n {userName}\n </ds-text>\n <span class=\"panel-nav__item-icon panel-nav__footer-user-icon\" aria-hidden=\"true\">\n <span class=\"panel-nav__footer-icon-expanded\">\n <ds-icon name=\"ChevronUpDown\" size=\"md\" color=\"inherit\" />\n </span>\n <span class=\"panel-nav__footer-icon-collapsed\">\n <ds-icon name=\"Circle\" size=\"md\" color=\"inherit\" />\n <ds-text\n class=\"panel-nav__user-initial\"\n as=\"span\"\n variant=\"text-caption\"\n emphasis\n align=\"center\"\n color=\"inherit\"\n >\n {userInitial}\n </ds-text>\n </span>\n </span>\n </button>\n );\n\n return (\n <ds-tooltip label={tipLabel} side=\"right\" size=\"sm\">\n {userButton}\n </ds-tooltip>\n );\n }\n\n private renderNavItem(item: PanelNavItem, idx: number, collapsed: boolean) {\n const isActive = item.id === this.effectiveActiveId;\n const rovingPosition = idx + 1;\n\n const itemContent = [\n <span class=\"panel-nav__item-icon\">\n <ds-icon name={item.icon} size=\"md\" color=\"inherit\" />\n </span>,\n <ds-text\n class=\"panel-nav__item-label panel-nav__item-label-text ds-control--md\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis={isActive}\n color=\"inherit\"\n lineTruncation={1}\n >\n {item.label}\n </ds-text>,\n item.dot && (\n <span class=\"panel-nav__item-dot-box\" aria-hidden=\"true\">\n <ds-badge class=\"panel-nav__item-dot\" variant=\"dot\" hasRing={collapsed} label=\"\" />\n </span>\n ),\n ];\n\n const sharedProps = {\n class: {\n 'panel-nav__item': true,\n 'panel-nav__item--active': isActive,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n },\n 'aria-current': isActive ? ('page' as const) : undefined,\n tabIndex: rovingPosition === this.rovingIndex ? 0 : -1,\n onClick: () => this.handleItemClick(item.id),\n onKeyDown: (e: KeyboardEvent) => this.handleRovingKeyDown(e, rovingPosition),\n onFocus: () => {\n this.rovingIndex = rovingPosition;\n },\n };\n\n const href = resolveSafeUrl(item.href);\n const useAnchor = this.routerMode === 'anchor' && href;\n const control = useAnchor ? (\n <a {...sharedProps} href={href}>\n {itemContent}\n </a>\n ) : (\n <button {...sharedProps} type=\"button\">\n {itemContent}\n </button>\n );\n\n // Always wrap so label fade CSS can transition on a stable item node.\n // Tip only when collapsed (empty label → ds-tooltip skips show).\n return (\n <ds-tooltip label={collapsed ? item.label : ''} side=\"right\" size=\"sm\">\n {control}\n </ds-tooltip>\n );\n }\n\n render() {\n const chromeStyle = this.renderedStyle;\n const isDashboardChrome = chromeStyle === 'dashboard';\n const isDashboard = this.navStyle === 'dashboard';\n const collapsed = this.collapsed || this.viewportNarrow;\n const { userName, userInitial } = this;\n\n const navCls: Record<string, boolean> = {\n 'panel-nav': true,\n 'ds-control--md': true,\n 'panel-nav--dashboard': isDashboard,\n 'panel-nav--settings': !isDashboard,\n 'panel-nav--collapsed': collapsed,\n 'panel-nav--breakpoint-locked': this.viewportNarrow,\n 'panel-nav--animating': this.isAnimating,\n };\n\n return (\n <Host style={{ display: 'block', position: 'relative' }}>\n <nav\n class={navCls}\n aria-label={\n isDashboardChrome ? this.dashboardNavigationLabel : this.settingsNavigationLabel\n }\n >\n {/* ── Header: Motive logo, reveals collapse toggle on hover ── */}\n <div class=\"panel-nav__header ds-chrome-row ds-chrome-space--md\">\n <button\n type=\"button\"\n class=\"panel-nav__header-btn ds-focus-ring-inset ds-interaction-fill\"\n disabled={this.viewportNarrow}\n tabIndex={!this.viewportNarrow && this.rovingIndex === 0 ? 0 : -1}\n onClick={() => this.handleToggle()}\n onKeyDown={(e: KeyboardEvent) => this.handleRovingKeyDown(e, 0)}\n onFocus={() => {\n this.rovingIndex = 0;\n }}\n aria-label={collapsed ? this.expandNavigationLabel : this.collapseNavigationLabel}\n aria-expanded={collapsed ? 'false' : 'true'}\n >\n {/* Motive M mark — fades out on hover to reveal collapse toggle */}\n <span class=\"panel-nav__header-logo\" aria-hidden=\"true\">\n <svg\n class=\"panel-nav__m-mark\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n focusable=\"false\"\n aria-hidden=\"true\"\n >\n <path\n d=\"M11.1159 4.31537H7.67021L2.53401 13.0703H0V15.6875H4.02716L8.24319 8.49978V15.6846H11.6342L15.8289 8.47829V15.6846H18.7122V4.3125H15.2559L11.1159 11.3648V4.31537Z\"\n fill=\"currentColor\"\n />\n </svg>\n </span>\n {/* Collapse / expand icon — revealed on hover via CSS */}\n <span class=\"panel-nav__header-toggle\" aria-hidden=\"true\">\n <ds-icon\n name={collapsed ? 'LeftExpandB' : 'LeftCollapseB'}\n size=\"md\"\n color=\"inherit\"\n />\n </span>\n </button>\n </div>\n\n {/* ── Scrollable body ── */}\n <div\n class={{\n 'panel-nav__body': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--md': true,\n 'ds-scrollbar-hidden': true,\n 'ds-focus-ring': this.bodyScrollable,\n ...scrollEdgeFadeClassMap({ edges: 'bottom' }),\n }}\n ref={element => {\n const next = (element as HTMLElement) ?? undefined;\n if (next === this.bodyEl) return;\n this.bodyEl = next;\n this.connectScrollRegionObserver();\n }}\n role={this.bodyScrollable ? 'region' : undefined}\n aria-label={this.bodyScrollable ? this.navigationItemsLabel : undefined}\n tabIndex={this.bodyScrollable ? 0 : undefined}\n >\n <div class=\"panel-nav__sections\">\n {(() => {\n let flatIdx = 0;\n return this.parsedGroups.map(group => (\n <div class=\"panel-nav__group\">\n {group.label && (\n <ds-text\n class=\"panel-nav__group-label ds-control--md\"\n as=\"span\"\n variant=\"text-caption\"\n emphasis\n color=\"inherit\"\n >\n {group.label}\n </ds-text>\n )}\n {group.items.map(item => this.renderNavItem(item, flatIdx++, collapsed))}\n </div>\n ));\n })()}\n </div>\n </div>\n\n {/* ── Footer ── */}\n <div class=\"panel-nav__footer\">\n {this.renderFooterAction(isDashboardChrome)}\n {this.renderFooterUser(collapsed, userName, userInitial)}\n </div>\n </nav>\n\n {/* Drag-to-resize handle — always rendered, hidden only when auto-collapsed by breakpoint */}\n {!this.viewportNarrow && (\n <div\n class={{\n 'panel-nav__resize-handle': true,\n 'panel-nav__resize-handle--overlay': this.showEdgeOverlay,\n }}\n onMouseEnter={() => this.handleResizeHandleMouseEnter()}\n onMouseLeave={() => this.handleResizeHandleMouseLeave()}\n onMouseDown={(e: MouseEvent) => this.handleResizeHandleMouseDown(e)}\n aria-hidden=\"true\"\n />\n )}\n </Host>\n );\n }\n}\n",
628
629
  "type": "registry:ui"
629
630
  },
630
631
  {
@@ -215,8 +215,8 @@
215
215
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: items.",
216
216
  "peerDependencies": {
217
217
  "required": [
218
- "@ds-mo/tokens ^6.2.1",
219
- "@ds-mo/icons ^6.6.0"
218
+ "@ds-mo/tokens ^6.2.2",
219
+ "@ds-mo/icons ^7.0.0"
220
220
  ],
221
221
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
222
222
  }
@@ -312,8 +312,8 @@
312
312
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: actions.",
313
313
  "peerDependencies": {
314
314
  "required": [
315
- "@ds-mo/tokens ^6.2.1",
316
- "@ds-mo/icons ^6.6.0"
315
+ "@ds-mo/tokens ^6.2.2",
316
+ "@ds-mo/icons ^7.0.0"
317
317
  ],
318
318
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
319
319
  }
@@ -354,8 +354,8 @@
354
354
  },
355
355
  "peerDependencies": {
356
356
  "required": [
357
- "@ds-mo/tokens ^6.2.1",
358
- "@ds-mo/icons ^6.6.0"
357
+ "@ds-mo/tokens ^6.2.2",
358
+ "@ds-mo/icons ^7.0.0"
359
359
  ],
360
360
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
361
361
  }
@@ -439,8 +439,8 @@
439
439
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: headers, items.",
440
440
  "peerDependencies": {
441
441
  "required": [
442
- "@ds-mo/tokens ^6.2.1",
443
- "@ds-mo/icons ^6.6.0"
442
+ "@ds-mo/tokens ^6.2.2",
443
+ "@ds-mo/icons ^7.0.0"
444
444
  ],
445
445
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
446
446
  }
@@ -358,8 +358,8 @@
358
358
  "complexPropertyNote": "Assign these non-primitive values as JavaScript properties: options.",
359
359
  "peerDependencies": {
360
360
  "required": [
361
- "@ds-mo/tokens ^6.2.1",
362
- "@ds-mo/icons ^6.6.0"
361
+ "@ds-mo/tokens ^6.2.2",
362
+ "@ds-mo/icons ^7.0.0"
363
363
  ],
364
364
  "frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
365
365
  }