@ds-mo/ui 14.11.1 → 14.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/NOTICE +10 -0
  2. package/dist/.package-ready.json +1 -1
  3. package/dist/agent-patterns.json +1 -1
  4. package/dist/agent.json +104 -9
  5. package/dist/angular/ds-pagination.d.ts +1 -1
  6. package/dist/angular/ds-pagination.js +3 -3
  7. package/dist/angular/ds-paper-texture.d.ts +12 -0
  8. package/dist/angular/ds-paper-texture.js +38 -0
  9. package/dist/angular/ds-shell-app.d.ts +1 -1
  10. package/dist/angular/ds-shell-app.js +3 -3
  11. package/dist/angular/ds-table.d.ts +1 -1
  12. package/dist/angular/ds-table.js +3 -3
  13. package/dist/angular/index.d.ts +1 -1
  14. package/dist/angular/index.js +1 -0
  15. package/dist/angular/proxies.d.ts +1 -0
  16. package/dist/angular/proxies.js +1 -0
  17. package/dist/components/ds-agent-questionnaire.js +1 -1
  18. package/dist/components/ds-agent-response.js +1 -1
  19. package/dist/components/ds-badge.js +1 -1
  20. package/dist/components/ds-banner.js +1 -1
  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-bar-workflow.js.map +1 -1
  25. package/dist/components/ds-button-filled.js +1 -1
  26. package/dist/components/ds-button-unfilled.js +1 -1
  27. package/dist/components/ds-card-chart.js +1 -1
  28. package/dist/components/ds-card-setting.js +1 -1
  29. package/dist/components/ds-code-block.js +1 -1
  30. package/dist/components/ds-conversation-list-item.js +1 -1
  31. package/dist/components/ds-conversation-list-item.js.map +1 -1
  32. package/dist/components/ds-filter-menu.js +1 -1
  33. package/dist/components/ds-filter-menu.js.map +1 -1
  34. package/dist/components/ds-input.js +1 -1
  35. package/dist/components/ds-markdown.js +1 -1
  36. package/dist/components/ds-menu.js +1 -1
  37. package/dist/components/ds-message-actions.js +1 -1
  38. package/dist/components/ds-message-composer.js +1 -1
  39. package/dist/components/ds-message-scroller.js +1 -1
  40. package/dist/components/ds-mobile-bar-nav.js +1 -1
  41. package/dist/components/ds-mobile-header.js +1 -1
  42. package/dist/components/ds-mobile-section-switcher.js +1 -1
  43. package/dist/components/ds-mobile-sheet-nav.js +1 -1
  44. package/dist/components/ds-modal.js +1 -1
  45. package/dist/components/ds-pagination.js +1 -1
  46. package/dist/components/ds-panel-nav.js +1 -1
  47. package/dist/components/ds-panel-tool-header.js +1 -1
  48. package/dist/components/ds-panel-tool-search.js +1 -1
  49. package/dist/components/ds-panel-tools.js +1 -1
  50. package/dist/components/ds-paper-texture.d.ts +11 -0
  51. package/dist/components/ds-paper-texture.js +2 -0
  52. package/dist/components/ds-paper-texture.js.map +1 -0
  53. package/dist/components/ds-select.js +1 -1
  54. package/dist/components/ds-shell-app.js +1 -1
  55. package/dist/components/ds-shell-app.js.map +1 -1
  56. package/dist/components/ds-shell-page.js +1 -1
  57. package/dist/components/ds-shell-tools.js +1 -1
  58. package/dist/components/ds-swatch-picker.js +1 -1
  59. package/dist/components/ds-tab-group.js +1 -1
  60. package/dist/components/ds-table-saved-views.js +1 -1
  61. package/dist/components/ds-table-saved-views.js.map +1 -1
  62. package/dist/components/ds-table-toolbar.js +1 -1
  63. package/dist/components/ds-table.js +1 -1
  64. package/dist/components/ds-table.js.map +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-BEvBJ65v.js +2 -0
  68. package/dist/components/p-BEvBJ65v.js.map +1 -0
  69. package/dist/components/{p-DvJkkdIO.js → p-BxqBiVbH.js} +2 -2
  70. package/dist/components/p-BzLnSwG9.js +2 -0
  71. package/dist/components/p-BzLnSwG9.js.map +1 -0
  72. package/dist/components/{p-CbfmzgdW.js → p-BzObtSlo.js} +2 -2
  73. package/dist/components/{p-Bv1CFHgD.js → p-C2n8A8NA.js} +2 -2
  74. package/dist/components/p-C5eosu67.js +2 -0
  75. package/dist/components/p-C5eosu67.js.map +1 -0
  76. package/dist/components/{p-c_5SXjwx.js → p-C7rzVotG.js} +2 -2
  77. package/dist/components/{p-bQm3gfr1.js → p-CKJzHclc.js} +2 -2
  78. package/dist/components/p-CWDmHPJC.js +2 -0
  79. package/dist/components/p-CWDmHPJC.js.map +1 -0
  80. package/dist/components/{p-BPnmiiIS.js → p-CjgnFGd6.js} +2 -2
  81. package/dist/components/{p-BuGGwWmt.js → p-Ck_VRQYi.js} +2 -2
  82. package/dist/components/p-CnWC0QvG.js +2 -0
  83. package/dist/components/p-CnWC0QvG.js.map +1 -0
  84. package/dist/components/p-CooNucm0.js +2 -0
  85. package/dist/components/p-CooNucm0.js.map +1 -0
  86. package/dist/components/{p-CoQSB3tl.js → p-Ct-m0I6U.js} +2 -2
  87. package/dist/components/{p-CqbMoVDQ.js → p-Cyw9odJX.js} +2 -2
  88. package/dist/components/{p-D5VglrfF.js → p-D1Nqlrrl.js} +2 -2
  89. package/dist/components/p-DIEx1e0M.js +2 -0
  90. package/dist/components/p-DIEx1e0M.js.map +1 -0
  91. package/dist/components/{p-DjqZWBnM.js → p-DL_5VENG.js} +2 -2
  92. package/dist/components/p-DfyfrfHL.js +2 -0
  93. package/dist/components/p-DfyfrfHL.js.map +1 -0
  94. package/dist/components/{p-CvwtYbmh.js → p-Dsn1RCS5.js} +2 -2
  95. package/dist/components/{p-D5bPuNiF.js → p-M9DPfXbP.js} +2 -2
  96. package/dist/components/{p-DgWGfscR.js → p-Rfpd5mWO.js} +2 -2
  97. package/dist/components/p-URdgCwEs.js +2 -0
  98. package/dist/components/{p-BCg_6ErW.js.map → p-URdgCwEs.js.map} +1 -1
  99. package/dist/components/{p-1tOy4H51.js → p-V2ywPfsR.js} +2 -2
  100. package/dist/components/{p-BVmKcpyP.js → p-iBuPFJL9.js} +2 -2
  101. package/dist/components/p-vWK5faWZ.js +2 -0
  102. package/dist/components/p-vWK5faWZ.js.map +1 -0
  103. package/dist/docs/components.json +163 -5
  104. package/dist/lib/shell/index.js.map +2 -2
  105. package/dist/lib/shell/shell-responsive.d.ts +1 -1
  106. package/dist/mcp-data/registry/bar-nav.json +1 -1
  107. package/dist/mcp-data/registry/filter-menu.json +1 -1
  108. package/dist/mcp-data/registry/menu.json +1 -1
  109. package/dist/mcp-data/registry/pagination.json +21 -3
  110. package/dist/mcp-data/registry/panel-nav.json +1 -1
  111. package/dist/mcp-data/registry/panel-tools.json +1 -1
  112. package/dist/mcp-data/registry/paper-texture.json +141 -0
  113. package/dist/mcp-data/registry/registry.json +106 -8
  114. package/dist/mcp-data/registry/select.json +2 -2
  115. package/dist/mcp-data/registry/shell-app.json +26 -5
  116. package/dist/mcp-data/registry/shell-tools.json +2 -2
  117. package/dist/mcp-data/registry/table-saved-views.json +6 -6
  118. package/dist/mcp-data/registry/table.json +31 -13
  119. package/dist/react/components.d.ts +1 -0
  120. package/dist/react/components.js +1 -0
  121. package/dist/react/ds-paper-texture.d.ts +9 -0
  122. package/dist/react/ds-paper-texture.js +12 -0
  123. package/dist/styles/table.css +5 -0
  124. package/dist/types/components/Pagination/Pagination.d.ts +2 -0
  125. package/dist/types/components/Pagination/pagination-types.d.ts +2 -0
  126. package/dist/types/components/PanelTools/PanelTools.d.ts +1 -0
  127. package/dist/types/components/PaperTexture/PaperTexture.d.ts +18 -0
  128. package/dist/types/components/PaperTexture/paper-texture-types.d.ts +39 -0
  129. package/dist/types/components/ShellApp/ShellApp.d.ts +7 -2
  130. package/dist/types/components/Table/Table.d.ts +19 -1
  131. package/dist/types/components/Table/table-layout-controller.d.ts +7 -1
  132. package/dist/types/components/Table/table-model.d.ts +3 -2
  133. package/dist/types/components/Table/table-viewport-fit-controller.d.ts +5 -1
  134. package/dist/types/components/Table/table-virtual-controller.d.ts +14 -1
  135. package/dist/types/components/Table/table-virtual-model.d.ts +26 -1
  136. package/dist/types/components/TableSavedViews/TableSavedViews.d.ts +1 -1
  137. package/dist/types/components.d.ts +59 -4
  138. package/dist/types/shell/shell-responsive.d.ts +1 -1
  139. package/package.json +4 -2
  140. package/dist/components/p-BCg_6ErW.js +0 -2
  141. package/dist/components/p-BFSnIEsC.js +0 -2
  142. package/dist/components/p-BFSnIEsC.js.map +0 -1
  143. package/dist/components/p-BiTwXttN.js +0 -2
  144. package/dist/components/p-BiTwXttN.js.map +0 -1
  145. package/dist/components/p-BmWWU20X.js +0 -2
  146. package/dist/components/p-BmWWU20X.js.map +0 -1
  147. package/dist/components/p-C-L4InQ0.js +0 -2
  148. package/dist/components/p-C-L4InQ0.js.map +0 -1
  149. package/dist/components/p-C-Zgq_Fx.js +0 -2
  150. package/dist/components/p-C-Zgq_Fx.js.map +0 -1
  151. package/dist/components/p-C1jsgfeh.js +0 -2
  152. package/dist/components/p-C1jsgfeh.js.map +0 -1
  153. package/dist/components/p-D8nnhQNJ.js +0 -2
  154. package/dist/components/p-D8nnhQNJ.js.map +0 -1
  155. package/dist/components/p-DNpE0RBz.js +0 -2
  156. package/dist/components/p-DNpE0RBz.js.map +0 -1
  157. /package/dist/components/{p-DvJkkdIO.js.map → p-BxqBiVbH.js.map} +0 -0
  158. /package/dist/components/{p-CbfmzgdW.js.map → p-BzObtSlo.js.map} +0 -0
  159. /package/dist/components/{p-Bv1CFHgD.js.map → p-C2n8A8NA.js.map} +0 -0
  160. /package/dist/components/{p-c_5SXjwx.js.map → p-C7rzVotG.js.map} +0 -0
  161. /package/dist/components/{p-bQm3gfr1.js.map → p-CKJzHclc.js.map} +0 -0
  162. /package/dist/components/{p-BPnmiiIS.js.map → p-CjgnFGd6.js.map} +0 -0
  163. /package/dist/components/{p-BuGGwWmt.js.map → p-Ck_VRQYi.js.map} +0 -0
  164. /package/dist/components/{p-CoQSB3tl.js.map → p-Ct-m0I6U.js.map} +0 -0
  165. /package/dist/components/{p-CqbMoVDQ.js.map → p-Cyw9odJX.js.map} +0 -0
  166. /package/dist/components/{p-D5VglrfF.js.map → p-D1Nqlrrl.js.map} +0 -0
  167. /package/dist/components/{p-DjqZWBnM.js.map → p-DL_5VENG.js.map} +0 -0
  168. /package/dist/components/{p-CvwtYbmh.js.map → p-Dsn1RCS5.js.map} +0 -0
  169. /package/dist/components/{p-D5bPuNiF.js.map → p-M9DPfXbP.js.map} +0 -0
  170. /package/dist/components/{p-DgWGfscR.js.map → p-Rfpd5mWO.js.map} +0 -0
  171. /package/dist/components/{p-1tOy4H51.js.map → p-V2ywPfsR.js.map} +0 -0
  172. /package/dist/components/{p-BVmKcpyP.js.map → p-iBuPFJL9.js.map} +0 -0
@@ -504,7 +504,7 @@
504
504
  },
505
505
  {
506
506
  "path": "src/wc/components/Menu/Menu.tsx",
507
- "content": "import {\n Component,\n Prop,\n State,\n Event,\n EventEmitter,\n Element,\n Watch,\n Listen,\n h,\n Host,\n} from '@stencil/core';\nimport {\n choicePopupMinWidth,\n CONTROL_SUPPORTING_TEXT_VARIANT,\n CONTROL_TEXT_VARIANT,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ChoicePopupAnchorAlignment,\n type ControlSize,\n} from '../../utils';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport type { MenuAlign, MenuSide } from './menu-position';\nimport {\n isMenuPickerSection,\n isMenuSwatchPickerSection,\n type MenuItemData,\n type MenuReorderDetail,\n type MenuSection,\n} from './menu-types';\nimport { snapshotMenuSections } from './menu-sections';\nimport {\n createMenuReorderDetail,\n locateMenuItem,\n menuReorderableRange,\n moveReorderableMenuItemBefore,\n moveReorderableMenuItemBy,\n} from './menu-reorder';\n\nexport type MenuSelectionMode = 'none' | 'single';\nexport type MenuSize = ControlSize;\nconst MENU_ITEM_TAG_SIZE: Record<MenuSize, 'md' | 'sm' | 'xs'> = {\n lg: 'md',\n md: 'sm',\n sm: 'xs',\n xs: 'xs',\n};\n\nconst MENU_FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\n@Component({\n tag: 'ds-menu',\n styleUrl: 'Menu.css',\n scoped: true,\n})\nexport class Menu {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) open: boolean = false;\n @Prop() items: MenuItemData[] = [];\n /** Choice-row density. */\n @Prop() size: MenuSize = 'md';\n /** Give ordinary rows mutually-exclusive menu semantics using isSelected. */\n @Prop() selectionMode: MenuSelectionMode = 'none';\n @Prop() sections: MenuSection[] = [];\n /** Preferred side; placement flips to the opposite side when that offers a better fit. */\n @Prop() side: MenuSide = 'bottom';\n @Prop() align: MenuAlign = 'start';\n /** Align choice-row edges to the anchor by default; use `popup-frame` only for custom frame geometry. */\n @Prop() anchorAlignment: ChoicePopupAnchorAlignment = 'choice-cell';\n /** Gap between anchor and menu — number (px) or TokoMo length (`var(--dimension-space-050)`, etc.). */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number (px) or TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n @Prop() menuWidth: string | undefined;\n @Prop() minWidth: string | undefined;\n /** External trigger element to position against. Set via JS: menuEl.anchor = buttonEl */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element for positioning */\n @Prop() anchorId: string | undefined;\n /** Show a visible ring on the initially focused menu item. Use only when the opener was keyboard-driven. */\n @Prop() initialFocusVisible: boolean = false;\n /** Accessible name for the popup menu. */\n @Prop() menuLabel: string = 'Menu';\n\n @State() private shouldRender: boolean = false;\n @State() private closing: boolean = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusedIndex: number = 0;\n @State() private positionReady: boolean = false;\n @State() private focusRingVisible: boolean = false;\n @State() private reorderFromFlat: number | null = null;\n @State() private reorderInsertBefore: number | null = null;\n @State() private reorderAnnouncement = '';\n\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted after the popup's exit motion is complete and its rendered content is removed. */\n @Event() dsAfterClose!: EventEmitter<void>;\n @Event() dsSelect!: EventEmitter<MenuItemData>;\n /** Emitted when a generic `swatch-picker` section option is chosen. */\n @Event() dsSwatchSelect!: EventEmitter<string>;\n /** Emitted after a pointer drop or keyboard move; Menu never mutates item order. */\n @Event() dsReorder!: EventEmitter<MenuReorderDetail>;\n\n private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private positionReadyCallback: (() => void) | undefined;\n private suppressItemClick = false;\n private reorderPointer:\n | {\n pointerId: number;\n sectionIndex: number;\n fromIndex: number;\n }\n | null = null;\n /**\n * Anchored placement. The choice-cell measurement stays here on purpose: the\n * inner-cell align offset and the `--dimension-menu-width-xs` floor are Menu\n * semantics, not shared geometry.\n */\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor ?? null,\n getPopup: () => this.el.querySelector<HTMLElement>('.menu-popup'),\n measure: (anchorEl, popup) => {\n if (!this.open) return null;\n\n const anchorRect = anchorEl.getBoundingClientRect();\n const sectionInsetPx = this.viewportPadPx;\n if (!this.minWidth) {\n if (\n this.anchorAlignment === 'choice-cell' &&\n (this.side === 'top' || this.side === 'bottom')\n ) {\n popup.style.minWidth = `max(var(--dimension-menu-width-xs), ${choicePopupMinWidth(\n anchorRect.width,\n sectionInsetPx\n )}px)`;\n } else {\n popup.style.removeProperty('min-width');\n }\n }\n\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidthPx,\n popupHeight: popup.offsetHeight || this.popupFallbackHeightPx,\n side: this.side,\n align: this.align,\n sideOffsetPx: this.sideOffsetPx,\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: this.alignOffsetPx,\n sectionInsetPx,\n anchorAlignment: this.anchorAlignment,\n }),\n viewportPadPx: this.viewportPadPx,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n };\n },\n apply: ({ x, y }) => {\n this.pos = { x, y };\n },\n onReady: () => {\n this.positionReady = true;\n },\n liveUpdate: 'double-frame',\n topLayer: true,\n });\n /** Last content actually painted while open; retained unchanged through exit motion. */\n private lastRenderedSections: MenuSection[] = [];\n private closingSections: MenuSection[] | null = null;\n\n componentDidLoad() {\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.cancelPositionRetry();\n this.cancelLivePositionUpdate();\n this.clearReorderPointer();\n this.teardownListeners();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSections = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.initialFocusVisible;\n this.setupListeners();\n this.schedulePositionUpdate(() => {\n this.focusInitialItem();\n });\n } else if (this.shouldRender) {\n this.positionReadyCallback = undefined;\n this.cancelPositionRetry();\n this.captureClosingSections();\n this.closing = true;\n this.teardownListeners();\n const closeAnimationMs = this.closeAnimationMs;\n if (closeAnimationMs <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => {\n this.closeTimer = null;\n this.finishClose();\n }, closeAnimationMs);\n }\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n onAnchorChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n @Watch('side')\n @Watch('align')\n @Watch('anchorAlignment')\n @Watch('sideOffset')\n @Watch('alignOffset')\n onPositionPropsChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n private get viewportPadPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get sideOffsetPx(): number {\n return resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050);\n }\n\n private get alignOffsetPx(): number {\n return resolveCssLengthPx(this.alignOffset, 0);\n }\n\n private get popupFallbackWidthPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthXs, TOKEN_DEFAULTS.menuWidthXs);\n }\n\n private get popupFallbackHeightPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private get closeAnimationMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort2, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.lastRenderedSections = [];\n this.closingSections = null;\n this.clearReorderPointer();\n this.reorderAnnouncement = '';\n this.dsAfterClose.emit();\n }\n\n private captureClosingSections() {\n if (this.closingSections) return;\n const visibleSections = this.lastRenderedSections.length\n ? this.lastRenderedSections\n : this.activeSections;\n this.closingSections = snapshotMenuSections(visibleSections);\n }\n\n private get resolvedAnchor(): HTMLElement | null {\n if (this.anchor) return this.anchor;\n if (this.anchorId) return document.getElementById(this.anchorId);\n return null;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n (HTMLElement & { setFocus?: () => Promise<void> | void }) | null;\n if (anchor?.setFocus) {\n anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private moveFocusAfterTab(backwards: boolean) {\n const anchor = this.anchorFocusTarget;\n if (!anchor) return;\n if (backwards) {\n anchor.focus();\n return;\n }\n\n const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.menu-popup') &&\n !element.closest('[inert]') &&\n element.getClientRects().length > 0\n );\n const index = candidates.indexOf(anchor);\n (candidates[index + 1] ?? anchor).focus();\n }\n\n private compositeTabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const currentIndex = focusables.indexOf(event.target as HTMLElement);\n if (currentIndex < 0) return true;\n return event.shiftKey ? currentIndex === 0 : currentIndex === focusables.length - 1;\n }\n\n private get activeSections(): MenuSection[] {\n if (this.sections.length > 0) return this.sections;\n if (this.items.length > 0) return [{ items: this.items }];\n return [];\n }\n\n private get flatItems(): MenuItemData[] {\n return this.activeSections.flatMap(section =>\n isMenuPickerSection(section) ? [] : section.items\n );\n }\n\n /** Rich preference content is a non-modal dialog, not an ARIA action menu. */\n private get hasCompositeSections(): boolean {\n return this.activeSections.some(isMenuPickerSection);\n }\n\n private cancelPositionRetry() {\n this.position.cancel();\n }\n\n private cancelLivePositionUpdate() {\n this.position.cancel();\n }\n\n /** Reposition after the current scroll/layout frame without hiding the open popup. */\n private scheduleLivePositionUpdate() {\n this.position.scheduleLiveUpdate();\n }\n\n /** Retry until anchor + popup exist — do not reveal at 0,0 on a failed first pass. */\n private schedulePositionUpdate(onReady?: () => void) {\n if (!this.open) return;\n this.positionReady = false;\n // Opening often sets `open` and `anchorId` together. Each Watch reschedules\n // placement; keep the open-path ready callback so initial focus is not dropped.\n if (onReady) this.positionReadyCallback = onReady;\n const callback = this.positionReadyCallback;\n this.position.schedule(() => {\n if (this.positionReadyCallback === callback) {\n this.positionReadyCallback = undefined;\n }\n callback?.();\n });\n }\n\n /** @returns `true` when anchor and popup were found and `pos` was updated. */\n private calculatePosition(): boolean {\n return this.position.update();\n }\n\n /** Focus the selected item when present, otherwise the first enabled item. */\n private focusInitialItem() {\n if (this.hasCompositeSections) {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.menu-popup [tabindex=\"0\"]')?.focus();\n });\n return;\n }\n\n const flat = this.flatItems;\n const selectedIdx = flat.findIndex(it => it.isSelected && !it.isInactive);\n const firstEnabledIdx = flat.findIndex(it => !it.isInactive || !!it.reorderable);\n this.focusedIndex = selectedIdx >= 0 ? selectedIdx : firstEnabledIdx >= 0 ? firstEnabledIdx : 0;\n\n requestAnimationFrame(() => {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[this.focusedIndex]?.focus();\n });\n }\n\n private setupListeners() {\n this.clickOutsideHandler = (e: MouseEvent) => {\n const t = e.target as Node;\n const popup = this.el.querySelector('.menu-popup');\n const anchorEl = this.resolvedAnchor;\n if (popup?.contains(t) || anchorEl?.contains(t)) return;\n this.close();\n };\n\n document.addEventListener('mousedown', this.clickOutsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.clickOutsideHandler) {\n document.removeEventListener('mousedown', this.clickOutsideHandler, true);\n this.clickOutsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSections();\n if (restoreFocus) this.focusAnchor();\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n @Listen('keydown')\n handleKeyDown(e: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n\n if (e.key === 'Escape') {\n e.preventDefault();\n this.close();\n return;\n }\n\n if (e.key === 'Tab') {\n if (this.hasCompositeSections && !this.compositeTabLeavesPopup(e)) return;\n e.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(e.shiftKey);\n return;\n }\n\n if (this.hasCompositeSections) return;\n\n if (\n (e.key === 'ArrowUp' || e.key === 'ArrowDown') &&\n e.altKey &&\n !e.ctrlKey &&\n !e.shiftKey\n ) {\n this.handleReorderKey(e);\n return;\n }\n\n const flat = this.flatItems;\n const enabled = flat\n .map((it, i) => ({ it, i }))\n .filter(({ it }) => !it.isInactive || !!it.reorderable)\n .map(({ i }) => i);\n if (!enabled.length) return;\n\n const cur = enabled.indexOf(this.focusedIndex);\n const safe = cur < 0 ? 0 : cur;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe + 1) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'ArrowUp':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe - 1 + enabled.length) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'Home':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[0];\n this.focusItem(this.focusedIndex);\n break;\n case 'End':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[enabled.length - 1];\n this.focusItem(this.focusedIndex);\n break;\n }\n }\n\n private focusItem(idx: number) {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[idx]?.focus();\n }\n\n private handleItemClick(item: MenuItemData) {\n if (this.suppressItemClick) {\n this.suppressItemClick = false;\n return;\n }\n if (item.isInactive) return;\n // Consumers commonly change the item source while handling selection.\n // Capture first so the exit animation never paints the next menu context.\n this.captureClosingSections();\n this.dsSelect.emit(item);\n }\n\n private handleSwatchSelect(value: string) {\n this.dsSwatchSelect.emit(value);\n }\n\n private handleReorderKey(event: KeyboardEvent): void {\n const located = locateMenuItem(this.activeSections, this.focusedIndex);\n if (!located || !located.items[located.itemIndex]?.reorderable) return;\n\n const nextItems = moveReorderableMenuItemBy(\n located.items,\n located.itemIndex,\n event.key === 'ArrowUp' ? -1 : 1,\n );\n if (!nextItems) return;\n\n event.preventDefault();\n event.stopPropagation();\n this.emitReorder(located.items, located.itemIndex, nextItems, located.sectionIndex);\n }\n\n private emitReorder(\n items: MenuItemData[],\n fromIndex: number,\n nextItems: MenuItemData[],\n sectionIndex: number,\n ): void {\n const detail = createMenuReorderDetail(items, fromIndex, nextItems, sectionIndex);\n if (!detail) return;\n this.focusedIndex = this.flatIndexFor(sectionIndex, detail.toIndex);\n this.announceReorder(detail);\n this.dsReorder.emit(detail);\n }\n\n private flatIndexFor(sectionIndex: number, itemIndex: number): number {\n let flat = 0;\n for (let index = 0; index < this.activeSections.length; index += 1) {\n const section = this.activeSections[index];\n if (isMenuPickerSection(section)) continue;\n if (index === sectionIndex) return flat + itemIndex;\n flat += section.items.length;\n }\n return itemIndex;\n }\n\n private announceReorder(detail: MenuReorderDetail): void {\n const range = menuReorderableRange(detail.items, detail.toIndex);\n const count = range ? range.end - range.start + 1 : detail.items.length;\n const position = range ? detail.toIndex - range.start + 1 : detail.toIndex + 1;\n this.reorderAnnouncement = `${detail.item.label} moved to position ${position} of ${count}`;\n }\n\n private onReorderPointerDown(\n event: PointerEvent,\n sectionIndex: number,\n itemIndex: number,\n flatIndex: number,\n ): void {\n if (event.button !== 0) return;\n const section = this.activeSections[sectionIndex];\n if (!section || isMenuPickerSection(section)) return;\n if (!section.items[itemIndex]?.reorderable) return;\n\n event.preventDefault();\n event.stopPropagation();\n this.suppressItemClick = true;\n this.focusRingVisible = false;\n this.focusedIndex = flatIndex;\n this.focusItem(flatIndex);\n this.reorderPointer = {\n pointerId: event.pointerId,\n sectionIndex,\n fromIndex: itemIndex,\n };\n this.reorderFromFlat = flatIndex;\n this.reorderInsertBefore = itemIndex;\n window.addEventListener('pointermove', this.onReorderPointerMove);\n window.addEventListener('pointerup', this.onReorderPointerUp);\n window.addEventListener('pointercancel', this.onReorderPointerCancel);\n }\n\n private onReorderPointerMove = (event: PointerEvent) => {\n const drag = this.reorderPointer;\n if (!drag || event.pointerId !== drag.pointerId) return;\n const section = this.activeSections[drag.sectionIndex];\n if (!section || isMenuPickerSection(section)) return;\n\n const insertBefore = this.resolveReorderInsertBefore(\n event.clientY,\n drag.sectionIndex,\n drag.fromIndex,\n section.items,\n );\n if (insertBefore !== this.reorderInsertBefore) this.reorderInsertBefore = insertBefore;\n };\n\n private onReorderPointerUp = (event: PointerEvent) => {\n const drag = this.reorderPointer;\n if (!drag || event.pointerId !== drag.pointerId) return;\n\n const section = this.activeSections[drag.sectionIndex];\n const insertBefore = this.reorderInsertBefore;\n this.clearReorderPointer();\n queueMicrotask(() => {\n this.suppressItemClick = false;\n });\n if (!section || isMenuPickerSection(section) || insertBefore === null) return;\n\n const nextItems = moveReorderableMenuItemBefore(\n section.items,\n drag.fromIndex,\n insertBefore,\n );\n if (!nextItems) return;\n this.emitReorder(section.items, drag.fromIndex, nextItems, drag.sectionIndex);\n };\n\n private onReorderPointerCancel = (event: PointerEvent) => {\n if (!this.reorderPointer || event.pointerId !== this.reorderPointer.pointerId) return;\n this.clearReorderPointer();\n queueMicrotask(() => {\n this.suppressItemClick = false;\n });\n };\n\n private resolveReorderInsertBefore(\n clientY: number,\n sectionIndex: number,\n fromIndex: number,\n items: MenuItemData[],\n ): number {\n const range = menuReorderableRange(items, fromIndex);\n if (!range) return fromIndex;\n\n const buttons = [...this.el.querySelectorAll<HTMLElement>('.menu-item')];\n const sectionStart = this.flatIndexFor(sectionIndex, 0);\n let insertBefore = range.end + 1;\n for (let itemIndex = range.start; itemIndex <= range.end; itemIndex += 1) {\n const row = buttons[sectionStart + itemIndex];\n if (!row) continue;\n const rect = row.getBoundingClientRect();\n if (clientY < rect.top + rect.height / 2) {\n insertBefore = itemIndex;\n break;\n }\n }\n return Math.min(Math.max(insertBefore, range.start), range.end + 1);\n }\n\n private clearReorderPointer(): void {\n if (this.reorderPointer) {\n window.removeEventListener('pointermove', this.onReorderPointerMove);\n window.removeEventListener('pointerup', this.onReorderPointerUp);\n window.removeEventListener('pointercancel', this.onReorderPointerCancel);\n }\n this.reorderPointer = null;\n this.reorderFromFlat = null;\n this.reorderInsertBefore = null;\n }\n\n render() {\n if (!this.shouldRender) return <Host style={{ display: 'contents' }} />;\n\n const sections = this.closing\n ? (this.closingSections ?? this.lastRenderedSections)\n : this.activeSections;\n const hasCompositeSections = sections.some(isMenuPickerSection);\n if (!this.closing) {\n this.lastRenderedSections = snapshotMenuSections(sections);\n this.closingSections = null;\n }\n let flatIdx = 0;\n const dragLocated = this.reorderFromFlat === null\n ? null\n : locateMenuItem(sections, this.reorderFromFlat);\n const dragRange = dragLocated\n ? menuReorderableRange(dragLocated.items, dragLocated.itemIndex)\n : null;\n\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(this.pos.x)}px, ${Math.round(this.pos.y)}px)`,\n zIndex: '9998',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n\n if (this.menuWidth) popupStyle['width'] = this.menuWidth;\n if (this.minWidth) popupStyle['min-width'] = this.minWidth;\n\n return (\n <Host style={{ display: 'contents' }}>\n <div\n popover=\"manual\"\n class={{\n 'menu-popup': true,\n 'menu-popup--closing': this.closing,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role={hasCompositeSections ? 'dialog' : 'menu'}\n aria-label={this.menuLabel}\n aria-orientation={hasCompositeSections ? undefined : 'vertical'}\n >\n <div class=\"ds-choice-list\">\n {sections.map((section, si) => (\n <div\n key={si}\n class={{\n 'menu-section': true,\n 'menu-section--divided': si < sections.length - 1,\n 'menu-section--swatch-picker': isMenuPickerSection(section),\n 'ds-choice-section': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--sm': true,\n 'ds-choice-section--divided': si < sections.length - 1,\n 'ds-choice-section--headed-after-first': si > 0 && Boolean(section.header),\n }}\n role={section.header ? 'group' : undefined}\n aria-label={section.header}\n >\n {section.header && (\n <ds-text\n class={`section-header ds-choice-section__header ds-control-section-heading ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n aria-hidden=\"true\"\n >\n <span class=\"ds-choice-section__header-label\">{section.header}</span>\n </ds-text>\n )}\n {isMenuSwatchPickerSection(section) ? (\n <ds-swatch-picker\n value={section.value}\n groupLabel={section.groupLabel ?? section.header ?? 'Swatch options'}\n options={section.options ?? []}\n sections={section.sections ?? []}\n onDsChange={(e: CustomEvent<string>) => {\n e.stopPropagation();\n this.handleSwatchSelect(e.detail);\n }}\n />\n ) : (\n section.items.map((item, itemIndex) => {\n const idx = flatIdx++;\n const isFocused = this.focusedIndex === idx;\n const isSingleSelectionItem =\n !hasCompositeSections && this.selectionMode === 'single' && !item.showSwitch;\n const usesLeading = section.items.some(\n candidate => candidate.reorderable || !!candidate.icon,\n );\n const locked = !!item.isInactive && !item.reorderable;\n const dragging = this.reorderFromFlat === idx;\n const dropBefore =\n dragLocated?.sectionIndex === si &&\n dragRange !== null &&\n itemIndex <= dragRange.end &&\n this.reorderInsertBefore === itemIndex;\n const dropAfter =\n dragLocated?.sectionIndex === si &&\n dragRange !== null &&\n this.reorderInsertBefore === dragRange.end + 1 &&\n itemIndex === dragRange.end;\n return (\n <button\n key={item.value ?? `${si}-${item.label}`}\n type=\"button\"\n class={{\n 'menu-item': true,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n [`ds-control--${this.size}`]: true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': isFocused && this.focusRingVisible,\n 'ds-interaction-fill': !locked,\n 'ds-interaction-fill--selected': !!item.isSelected && !locked,\n 'menu-item--selected': !!item.isSelected,\n 'menu-item--switch': !!item.showSwitch,\n 'ds-control-inactive': locked,\n 'menu-item--destructive': !!item.isDestructive,\n 'menu-item--focused': isFocused,\n 'menu-item--dragging': dragging,\n }}\n role={\n hasCompositeSections\n ? undefined\n : item.showSwitch\n ? 'menuitemcheckbox'\n : isSingleSelectionItem\n ? 'menuitemradio'\n : 'menuitem'\n }\n aria-checked={\n !hasCompositeSections && item.showSwitch\n ? String(!!item.switchValue)\n : isSingleSelectionItem\n ? String(!!item.isSelected)\n : undefined\n }\n aria-pressed={\n hasCompositeSections\n ? String(item.showSwitch ? !!item.switchValue : !!item.isSelected)\n : undefined\n }\n aria-current={\n !hasCompositeSections &&\n !item.showSwitch &&\n !isSingleSelectionItem &&\n item.isSelected\n ? 'true'\n : undefined\n }\n aria-description={\n item.reorderable\n ? item.isInactive\n ? 'Visibility cannot be changed. Alt + Arrow Up or Alt + Arrow Down moves this row.'\n : 'Drag to reorder. Alt + Arrow Up or Alt + Arrow Down moves this row.'\n : undefined\n }\n aria-disabled={item.isInactive ? 'true' : undefined}\n disabled={locked}\n tabIndex={hasCompositeSections ? 0 : isFocused ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.handleItemClick(item)}\n onFocus={() => {\n this.focusedIndex = idx;\n }}\n >\n {(dropBefore || dropAfter) && (\n <span\n class={{\n 'menu-item__drop-rail': true,\n 'menu-item__drop-rail--before': dropBefore,\n 'menu-item__drop-rail--after': dropAfter,\n }}\n data-menu-drop-rail\n aria-hidden=\"true\"\n />\n )}\n {item.reorderable ? (\n <span\n class=\"menu-item__handle ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n data-menu-handle\n aria-hidden=\"true\"\n onPointerDown={event =>\n this.onReorderPointerDown(event, si, itemIndex, idx)\n }\n onClick={event => {\n event.preventDefault();\n event.stopPropagation();\n }}\n >\n <ds-icon name=\"Drag\" size={this.size} color=\"inherit\" />\n </span>\n ) : usesLeading && item.icon ? (\n <span\n class=\"menu-item__icon ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={item.icon} size={this.size} color=\"inherit\" />\n </span>\n ) : usesLeading ? (\n <span\n class=\"menu-item__icon-spacer ds-choice-item__icon ds-control-icon-box\"\n aria-hidden=\"true\"\n />\n ) : null}\n <div class=\"menu-item__content ds-choice-item__content ds-interaction-fill__content\">\n <ds-text\n class=\"menu-item__label ds-choice-item__label ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_TEXT_VARIANT[this.size]}\n color={item.isSelected ? 'primary' : 'secondary'}\n >\n {item.label}\n </ds-text>\n {item.subtext && (\n <ds-text\n class=\"menu-item__subtext ds-choice-item__subtext ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"secondary\"\n >\n {item.subtext}\n </ds-text>\n )}\n </div>\n {item.tag && (\n <ds-tag\n class=\"ds-choice-item__tag ds-interaction-fill__content\"\n label={item.tag.label}\n size={MENU_ITEM_TAG_SIZE[this.size]}\n intent={item.tag.intent ?? 'neutral'}\n contrast={item.tag.contrast ?? 'faint'}\n rounded={item.tag.rounded ?? false}\n isInset\n />\n )}\n {item.dot && (\n <span\n class=\"menu-item__dot-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-badge\n class=\"menu-item__dot\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n />\n </span>\n )}\n {item.showSwitch && (\n <ds-switch\n class=\"menu-item__switch ds-interaction-fill__content\"\n size={this.size}\n checked={!!item.switchValue}\n isInactive={!!item.isInactive}\n presentation\n />\n )}\n </button>\n );\n })\n )}\n </div>\n ))}\n </div>\n </div>\n <div class=\"ds-visually-hidden\" role=\"status\" aria-live=\"polite\">\n {this.reorderAnnouncement}\n </div>\n </Host>\n );\n }\n}\n",
507
+ "content": "import {\n Component,\n Prop,\n State,\n Event,\n EventEmitter,\n Element,\n Watch,\n Listen,\n h,\n Host,\n} from '@stencil/core';\nimport {\n choicePopupMinWidth,\n CONTROL_SUPPORTING_TEXT_VARIANT,\n CONTROL_TEXT_VARIANT,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ChoicePopupAnchorAlignment,\n type ControlSize,\n} from '../../utils';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport type { MenuAlign, MenuSide } from './menu-position';\nimport {\n isMenuPickerSection,\n isMenuSwatchPickerSection,\n type MenuItemData,\n type MenuReorderDetail,\n type MenuSection,\n} from './menu-types';\nimport { snapshotMenuSections } from './menu-sections';\nimport {\n createMenuReorderDetail,\n locateMenuItem,\n menuReorderableRange,\n moveReorderableMenuItemBefore,\n moveReorderableMenuItemBy,\n} from './menu-reorder';\n\nexport type MenuSelectionMode = 'none' | 'single';\nexport type MenuSize = ControlSize;\nconst MENU_ITEM_TAG_SIZE: Record<MenuSize, 'md' | 'sm' | 'xs'> = {\n lg: 'md',\n md: 'sm',\n sm: 'xs',\n xs: 'xs',\n};\n\nconst MENU_FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\n@Component({\n tag: 'ds-menu',\n styleUrl: 'Menu.css',\n scoped: true,\n})\nexport class Menu {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) open: boolean = false;\n @Prop() items: MenuItemData[] = [];\n /** Choice-row density. */\n @Prop() size: MenuSize = 'md';\n /** Give ordinary rows mutually-exclusive menu semantics using isSelected. */\n @Prop() selectionMode: MenuSelectionMode = 'none';\n @Prop() sections: MenuSection[] = [];\n /** Preferred side; placement flips to the opposite side when that offers a better fit. */\n @Prop() side: MenuSide = 'bottom';\n @Prop() align: MenuAlign = 'start';\n /** Align choice-row edges to the anchor by default; use `popup-frame` only for custom frame geometry. */\n @Prop() anchorAlignment: ChoicePopupAnchorAlignment = 'choice-cell';\n /** Gap between anchor and menu — number (px) or TokoMo length (`var(--dimension-space-050)`, etc.). */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number (px) or TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n @Prop() menuWidth: string | undefined;\n @Prop() minWidth: string | undefined;\n /** External trigger element to position against. Set via JS: menuEl.anchor = buttonEl */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element for positioning */\n @Prop() anchorId: string | undefined;\n /** Show a visible ring on the initially focused menu item. Use only when the opener was keyboard-driven. */\n @Prop() initialFocusVisible: boolean = false;\n /** Accessible name for the popup menu. */\n @Prop() menuLabel: string = 'Menu';\n\n @State() private shouldRender: boolean = false;\n @State() private closing: boolean = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusedIndex: number = 0;\n @State() private positionReady: boolean = false;\n @State() private focusRingVisible: boolean = false;\n @State() private reorderFromFlat: number | null = null;\n @State() private reorderInsertBefore: number | null = null;\n @State() private reorderAnnouncement = '';\n\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted after the popup's exit motion is complete and its rendered content is removed. */\n @Event() dsAfterClose!: EventEmitter<void>;\n @Event() dsSelect!: EventEmitter<MenuItemData>;\n /** Emitted when a generic `swatch-picker` section option is chosen. */\n @Event() dsSwatchSelect!: EventEmitter<string>;\n /** Emitted after a pointer drop or keyboard move; Menu never mutates item order. */\n @Event() dsReorder!: EventEmitter<MenuReorderDetail>;\n\n private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private positionReadyCallback: (() => void) | undefined;\n private suppressItemClick = false;\n private reorderPointer: {\n pointerId: number;\n sectionIndex: number;\n fromIndex: number;\n } | null = null;\n /**\n * Anchored placement. The choice-cell measurement stays here on purpose: the\n * inner-cell align offset and the `--dimension-menu-width-xs` floor are Menu\n * semantics, not shared geometry.\n */\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor ?? null,\n getPopup: () => this.el.querySelector<HTMLElement>('.menu-popup'),\n measure: (anchorEl, popup) => {\n if (!this.open) return null;\n\n const anchorRect = anchorEl.getBoundingClientRect();\n const sectionInsetPx = this.viewportPadPx;\n if (!this.minWidth) {\n if (\n this.anchorAlignment === 'choice-cell' &&\n (this.side === 'top' || this.side === 'bottom')\n ) {\n popup.style.minWidth = `max(var(--dimension-menu-width-xs), ${choicePopupMinWidth(\n anchorRect.width,\n sectionInsetPx\n )}px)`;\n } else {\n popup.style.removeProperty('min-width');\n }\n }\n\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidthPx,\n popupHeight: popup.offsetHeight || this.popupFallbackHeightPx,\n side: this.side,\n align: this.align,\n sideOffsetPx: this.sideOffsetPx,\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: this.alignOffsetPx,\n sectionInsetPx,\n anchorAlignment: this.anchorAlignment,\n }),\n viewportPadPx: this.viewportPadPx,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n };\n },\n apply: ({ x, y }) => {\n this.pos = { x, y };\n },\n onReady: () => {\n this.positionReady = true;\n },\n liveUpdate: 'double-frame',\n topLayer: true,\n });\n /** Last content actually painted while open; retained unchanged through exit motion. */\n private lastRenderedSections: MenuSection[] = [];\n private closingSections: MenuSection[] | null = null;\n\n componentDidLoad() {\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.cancelPositionRetry();\n this.cancelLivePositionUpdate();\n this.clearReorderPointer();\n this.teardownListeners();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSections = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.initialFocusVisible;\n this.setupListeners();\n this.schedulePositionUpdate(() => {\n this.focusInitialItem();\n });\n } else if (this.shouldRender) {\n this.positionReadyCallback = undefined;\n this.cancelPositionRetry();\n this.captureClosingSections();\n this.closing = true;\n this.teardownListeners();\n const closeAnimationMs = this.closeAnimationMs;\n if (closeAnimationMs <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => {\n this.closeTimer = null;\n this.finishClose();\n }, closeAnimationMs);\n }\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n onAnchorChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n @Watch('side')\n @Watch('align')\n @Watch('anchorAlignment')\n @Watch('sideOffset')\n @Watch('alignOffset')\n onPositionPropsChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n private get viewportPadPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get sideOffsetPx(): number {\n return resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050);\n }\n\n private get alignOffsetPx(): number {\n return resolveCssLengthPx(this.alignOffset, 0);\n }\n\n private get popupFallbackWidthPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthXs, TOKEN_DEFAULTS.menuWidthXs);\n }\n\n private get popupFallbackHeightPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private get closeAnimationMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort2, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.lastRenderedSections = [];\n this.closingSections = null;\n this.clearReorderPointer();\n this.reorderAnnouncement = '';\n this.dsAfterClose.emit();\n }\n\n private captureClosingSections() {\n if (this.closingSections) return;\n const visibleSections = this.lastRenderedSections.length\n ? this.lastRenderedSections\n : this.activeSections;\n this.closingSections = snapshotMenuSections(visibleSections);\n }\n\n private get resolvedAnchor(): HTMLElement | null {\n if (this.anchor) return this.anchor;\n if (this.anchorId) return document.getElementById(this.anchorId);\n return null;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n (HTMLElement & { setFocus?: () => Promise<void> | void }) | null;\n if (anchor?.setFocus) {\n anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private moveFocusAfterTab(backwards: boolean) {\n const anchor = this.anchorFocusTarget;\n if (!anchor) return;\n if (backwards) {\n anchor.focus();\n return;\n }\n\n const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.menu-popup') &&\n !element.closest('[inert]') &&\n element.getClientRects().length > 0\n );\n const index = candidates.indexOf(anchor);\n (candidates[index + 1] ?? anchor).focus();\n }\n\n private compositeTabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const currentIndex = focusables.indexOf(event.target as HTMLElement);\n if (currentIndex < 0) return true;\n return event.shiftKey ? currentIndex === 0 : currentIndex === focusables.length - 1;\n }\n\n private get activeSections(): MenuSection[] {\n if (this.sections.length > 0) return this.sections;\n if (this.items.length > 0) return [{ items: this.items }];\n return [];\n }\n\n private get flatItems(): MenuItemData[] {\n return this.activeSections.flatMap(section =>\n isMenuPickerSection(section) ? [] : section.items\n );\n }\n\n /** Rich preference content is a non-modal dialog, not an ARIA action menu. */\n private get hasCompositeSections(): boolean {\n return this.activeSections.some(isMenuPickerSection);\n }\n\n private cancelPositionRetry() {\n this.position.cancel();\n }\n\n private cancelLivePositionUpdate() {\n this.position.cancel();\n }\n\n /** Reposition after the current scroll/layout frame without hiding the open popup. */\n private scheduleLivePositionUpdate() {\n this.position.scheduleLiveUpdate();\n }\n\n /** Retry until anchor + popup exist — do not reveal at 0,0 on a failed first pass. */\n private schedulePositionUpdate(onReady?: () => void) {\n if (!this.open) return;\n this.positionReady = false;\n // Opening often sets `open` and `anchorId` together. Each Watch reschedules\n // placement; keep the open-path ready callback so initial focus is not dropped.\n if (onReady) this.positionReadyCallback = onReady;\n const callback = this.positionReadyCallback;\n this.position.schedule(() => {\n if (this.positionReadyCallback === callback) {\n this.positionReadyCallback = undefined;\n }\n callback?.();\n });\n }\n\n /** @returns `true` when anchor and popup were found and `pos` was updated. */\n private calculatePosition(): boolean {\n return this.position.update();\n }\n\n /** Focus the selected item when present, otherwise the first enabled item. */\n private focusInitialItem() {\n if (this.hasCompositeSections) {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.menu-popup [tabindex=\"0\"]')?.focus();\n });\n return;\n }\n\n const flat = this.flatItems;\n const selectedIdx = flat.findIndex(it => it.isSelected && !it.isInactive);\n const firstEnabledIdx = flat.findIndex(it => !it.isInactive || !!it.reorderable);\n this.focusedIndex = selectedIdx >= 0 ? selectedIdx : firstEnabledIdx >= 0 ? firstEnabledIdx : 0;\n\n requestAnimationFrame(() => {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[this.focusedIndex]?.focus();\n });\n }\n\n private setupListeners() {\n this.clickOutsideHandler = (e: MouseEvent) => {\n const t = e.target as Node;\n const popup = this.el.querySelector('.menu-popup');\n const anchorEl = this.resolvedAnchor;\n if (popup?.contains(t) || anchorEl?.contains(t)) return;\n this.close();\n };\n\n document.addEventListener('mousedown', this.clickOutsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.clickOutsideHandler) {\n document.removeEventListener('mousedown', this.clickOutsideHandler, true);\n this.clickOutsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSections();\n if (restoreFocus) this.focusAnchor();\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n @Listen('keydown')\n handleKeyDown(e: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n\n if (e.key === 'Escape') {\n e.preventDefault();\n this.close();\n return;\n }\n\n if (e.key === 'Tab') {\n if (this.hasCompositeSections && !this.compositeTabLeavesPopup(e)) return;\n e.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(e.shiftKey);\n return;\n }\n\n if (this.hasCompositeSections) return;\n\n if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && e.altKey && !e.ctrlKey && !e.shiftKey) {\n this.handleReorderKey(e);\n return;\n }\n\n const flat = this.flatItems;\n const enabled = flat\n .map((it, i) => ({ it, i }))\n .filter(({ it }) => !it.isInactive || !!it.reorderable)\n .map(({ i }) => i);\n if (!enabled.length) return;\n\n const cur = enabled.indexOf(this.focusedIndex);\n const safe = cur < 0 ? 0 : cur;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe + 1) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'ArrowUp':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe - 1 + enabled.length) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'Home':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[0];\n this.focusItem(this.focusedIndex);\n break;\n case 'End':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[enabled.length - 1];\n this.focusItem(this.focusedIndex);\n break;\n }\n }\n\n private focusItem(idx: number) {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[idx]?.focus();\n }\n\n private handleItemClick(item: MenuItemData) {\n if (this.suppressItemClick) {\n this.suppressItemClick = false;\n return;\n }\n if (item.isInactive) return;\n // Consumers commonly change the item source while handling selection.\n // Capture first so the exit animation never paints the next menu context.\n this.captureClosingSections();\n this.dsSelect.emit(item);\n }\n\n private handleSwatchSelect(value: string) {\n this.dsSwatchSelect.emit(value);\n }\n\n private handleReorderKey(event: KeyboardEvent): void {\n const located = locateMenuItem(this.activeSections, this.focusedIndex);\n if (!located || !located.items[located.itemIndex]?.reorderable) return;\n\n const nextItems = moveReorderableMenuItemBy(\n located.items,\n located.itemIndex,\n event.key === 'ArrowUp' ? -1 : 1\n );\n if (!nextItems) return;\n\n event.preventDefault();\n event.stopPropagation();\n this.emitReorder(located.items, located.itemIndex, nextItems, located.sectionIndex);\n }\n\n private emitReorder(\n items: MenuItemData[],\n fromIndex: number,\n nextItems: MenuItemData[],\n sectionIndex: number\n ): void {\n const detail = createMenuReorderDetail(items, fromIndex, nextItems, sectionIndex);\n if (!detail) return;\n this.focusedIndex = this.flatIndexFor(sectionIndex, detail.toIndex);\n this.announceReorder(detail);\n this.dsReorder.emit(detail);\n }\n\n private flatIndexFor(sectionIndex: number, itemIndex: number): number {\n let flat = 0;\n for (let index = 0; index < this.activeSections.length; index += 1) {\n const section = this.activeSections[index];\n if (isMenuPickerSection(section)) continue;\n if (index === sectionIndex) return flat + itemIndex;\n flat += section.items.length;\n }\n return itemIndex;\n }\n\n private announceReorder(detail: MenuReorderDetail): void {\n const range = menuReorderableRange(detail.items, detail.toIndex);\n const count = range ? range.end - range.start + 1 : detail.items.length;\n const position = range ? detail.toIndex - range.start + 1 : detail.toIndex + 1;\n this.reorderAnnouncement = `${detail.item.label} moved to position ${position} of ${count}`;\n }\n\n private onReorderPointerDown(\n event: PointerEvent,\n sectionIndex: number,\n itemIndex: number,\n flatIndex: number\n ): void {\n if (event.button !== 0) return;\n const section = this.activeSections[sectionIndex];\n if (!section || isMenuPickerSection(section)) return;\n if (!section.items[itemIndex]?.reorderable) return;\n\n event.preventDefault();\n event.stopPropagation();\n this.suppressItemClick = true;\n this.focusRingVisible = false;\n this.focusedIndex = flatIndex;\n this.focusItem(flatIndex);\n this.reorderPointer = {\n pointerId: event.pointerId,\n sectionIndex,\n fromIndex: itemIndex,\n };\n this.reorderFromFlat = flatIndex;\n this.reorderInsertBefore = itemIndex;\n window.addEventListener('pointermove', this.onReorderPointerMove);\n window.addEventListener('pointerup', this.onReorderPointerUp);\n window.addEventListener('pointercancel', this.onReorderPointerCancel);\n }\n\n private onReorderPointerMove = (event: PointerEvent) => {\n const drag = this.reorderPointer;\n if (!drag || event.pointerId !== drag.pointerId) return;\n const section = this.activeSections[drag.sectionIndex];\n if (!section || isMenuPickerSection(section)) return;\n\n const insertBefore = this.resolveReorderInsertBefore(\n event.clientY,\n drag.sectionIndex,\n drag.fromIndex,\n section.items\n );\n if (insertBefore !== this.reorderInsertBefore) this.reorderInsertBefore = insertBefore;\n };\n\n private onReorderPointerUp = (event: PointerEvent) => {\n const drag = this.reorderPointer;\n if (!drag || event.pointerId !== drag.pointerId) return;\n\n const section = this.activeSections[drag.sectionIndex];\n const insertBefore = this.reorderInsertBefore;\n this.clearReorderPointer();\n queueMicrotask(() => {\n this.suppressItemClick = false;\n });\n if (!section || isMenuPickerSection(section) || insertBefore === null) return;\n\n const nextItems = moveReorderableMenuItemBefore(section.items, drag.fromIndex, insertBefore);\n if (!nextItems) return;\n this.emitReorder(section.items, drag.fromIndex, nextItems, drag.sectionIndex);\n };\n\n private onReorderPointerCancel = (event: PointerEvent) => {\n if (!this.reorderPointer || event.pointerId !== this.reorderPointer.pointerId) return;\n this.clearReorderPointer();\n queueMicrotask(() => {\n this.suppressItemClick = false;\n });\n };\n\n private resolveReorderInsertBefore(\n clientY: number,\n sectionIndex: number,\n fromIndex: number,\n items: MenuItemData[]\n ): number {\n const range = menuReorderableRange(items, fromIndex);\n if (!range) return fromIndex;\n\n const buttons = [...this.el.querySelectorAll<HTMLElement>('.menu-item')];\n const sectionStart = this.flatIndexFor(sectionIndex, 0);\n let insertBefore = range.end + 1;\n for (let itemIndex = range.start; itemIndex <= range.end; itemIndex += 1) {\n const row = buttons[sectionStart + itemIndex];\n if (!row) continue;\n const rect = row.getBoundingClientRect();\n if (clientY < rect.top + rect.height / 2) {\n insertBefore = itemIndex;\n break;\n }\n }\n return Math.min(Math.max(insertBefore, range.start), range.end + 1);\n }\n\n private clearReorderPointer(): void {\n if (this.reorderPointer) {\n window.removeEventListener('pointermove', this.onReorderPointerMove);\n window.removeEventListener('pointerup', this.onReorderPointerUp);\n window.removeEventListener('pointercancel', this.onReorderPointerCancel);\n }\n this.reorderPointer = null;\n this.reorderFromFlat = null;\n this.reorderInsertBefore = null;\n }\n\n render() {\n if (!this.shouldRender) return <Host style={{ display: 'contents' }} />;\n\n const sections = this.closing\n ? (this.closingSections ?? this.lastRenderedSections)\n : this.activeSections;\n const hasCompositeSections = sections.some(isMenuPickerSection);\n if (!this.closing) {\n this.lastRenderedSections = snapshotMenuSections(sections);\n this.closingSections = null;\n }\n let flatIdx = 0;\n const dragLocated =\n this.reorderFromFlat === null ? null : locateMenuItem(sections, this.reorderFromFlat);\n const dragRange = dragLocated\n ? menuReorderableRange(dragLocated.items, dragLocated.itemIndex)\n : null;\n\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(this.pos.x)}px, ${Math.round(this.pos.y)}px)`,\n zIndex: '9998',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n\n if (this.menuWidth) popupStyle['width'] = this.menuWidth;\n if (this.minWidth) popupStyle['min-width'] = this.minWidth;\n\n return (\n <Host style={{ display: 'contents' }}>\n <div\n popover=\"manual\"\n class={{\n 'menu-popup': true,\n 'menu-popup--closing': this.closing,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role={hasCompositeSections ? 'dialog' : 'menu'}\n aria-label={this.menuLabel}\n aria-orientation={hasCompositeSections ? undefined : 'vertical'}\n >\n <div class=\"ds-choice-list\">\n {sections.map((section, si) => (\n <div\n key={si}\n class={{\n 'menu-section': true,\n 'menu-section--divided': si < sections.length - 1,\n 'menu-section--swatch-picker': isMenuPickerSection(section),\n 'ds-choice-section': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--sm': true,\n 'ds-choice-section--divided': si < sections.length - 1,\n 'ds-choice-section--headed-after-first': si > 0 && Boolean(section.header),\n }}\n role={section.header ? 'group' : undefined}\n aria-label={section.header}\n >\n {section.header && (\n <ds-text\n class={`section-header ds-choice-section__header ds-control-section-heading ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n aria-hidden=\"true\"\n >\n <span class=\"ds-choice-section__header-label\">{section.header}</span>\n </ds-text>\n )}\n {isMenuSwatchPickerSection(section) ? (\n <ds-swatch-picker\n value={section.value}\n groupLabel={section.groupLabel ?? section.header ?? 'Swatch options'}\n options={section.options ?? []}\n sections={section.sections ?? []}\n onDsChange={(e: CustomEvent<string>) => {\n e.stopPropagation();\n this.handleSwatchSelect(e.detail);\n }}\n />\n ) : (\n section.items.map((item, itemIndex) => {\n const idx = flatIdx++;\n const isFocused = this.focusedIndex === idx;\n const isSingleSelectionItem =\n !hasCompositeSections && this.selectionMode === 'single' && !item.showSwitch;\n const usesLeading = section.items.some(\n candidate => candidate.reorderable || !!candidate.icon\n );\n const locked = !!item.isInactive && !item.reorderable;\n const dragging = this.reorderFromFlat === idx;\n const dropBefore =\n dragLocated?.sectionIndex === si &&\n dragRange !== null &&\n itemIndex <= dragRange.end &&\n this.reorderInsertBefore === itemIndex;\n const dropAfter =\n dragLocated?.sectionIndex === si &&\n dragRange !== null &&\n this.reorderInsertBefore === dragRange.end + 1 &&\n itemIndex === dragRange.end;\n return (\n <button\n key={item.value ?? `${si}-${item.label}`}\n type=\"button\"\n class={{\n 'menu-item': true,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n [`ds-control--${this.size}`]: true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': isFocused && this.focusRingVisible,\n 'ds-interaction-fill': !locked,\n 'ds-interaction-fill--selected': !!item.isSelected && !locked,\n 'menu-item--selected': !!item.isSelected,\n 'menu-item--switch': !!item.showSwitch,\n 'ds-control-inactive': locked,\n 'menu-item--destructive': !!item.isDestructive,\n 'menu-item--focused': isFocused,\n 'menu-item--dragging': dragging,\n }}\n role={\n hasCompositeSections\n ? undefined\n : item.showSwitch\n ? 'menuitemcheckbox'\n : isSingleSelectionItem\n ? 'menuitemradio'\n : 'menuitem'\n }\n aria-checked={\n !hasCompositeSections && item.showSwitch\n ? String(!!item.switchValue)\n : isSingleSelectionItem\n ? String(!!item.isSelected)\n : undefined\n }\n aria-pressed={\n hasCompositeSections\n ? String(item.showSwitch ? !!item.switchValue : !!item.isSelected)\n : undefined\n }\n aria-current={\n !hasCompositeSections &&\n !item.showSwitch &&\n !isSingleSelectionItem &&\n item.isSelected\n ? 'true'\n : undefined\n }\n aria-description={\n item.reorderable\n ? item.isInactive\n ? 'Visibility cannot be changed. Alt + Arrow Up or Alt + Arrow Down moves this row.'\n : 'Drag to reorder. Alt + Arrow Up or Alt + Arrow Down moves this row.'\n : undefined\n }\n aria-disabled={item.isInactive ? 'true' : undefined}\n disabled={locked}\n tabIndex={hasCompositeSections ? 0 : isFocused ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.handleItemClick(item)}\n onFocus={() => {\n this.focusedIndex = idx;\n }}\n >\n {(dropBefore || dropAfter) && (\n <span\n class={{\n 'menu-item__drop-rail': true,\n 'menu-item__drop-rail--before': dropBefore,\n 'menu-item__drop-rail--after': dropAfter,\n }}\n data-menu-drop-rail\n aria-hidden=\"true\"\n />\n )}\n {item.reorderable ? (\n <span\n class=\"menu-item__handle ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n data-menu-handle\n aria-hidden=\"true\"\n onPointerDown={event =>\n this.onReorderPointerDown(event, si, itemIndex, idx)\n }\n onClick={event => {\n event.preventDefault();\n event.stopPropagation();\n }}\n >\n <ds-icon name=\"Drag\" size={this.size} color=\"inherit\" />\n </span>\n ) : usesLeading && item.icon ? (\n <span\n class=\"menu-item__icon ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={item.icon} size={this.size} color=\"inherit\" />\n </span>\n ) : usesLeading ? (\n <span\n class=\"menu-item__icon-spacer ds-choice-item__icon ds-control-icon-box\"\n aria-hidden=\"true\"\n />\n ) : null}\n <div class=\"menu-item__content ds-choice-item__content ds-interaction-fill__content\">\n <ds-text\n class=\"menu-item__label ds-choice-item__label ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_TEXT_VARIANT[this.size]}\n color={item.isSelected ? 'primary' : 'secondary'}\n >\n {item.label}\n </ds-text>\n {item.subtext && (\n <ds-text\n class=\"menu-item__subtext ds-choice-item__subtext ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"secondary\"\n >\n {item.subtext}\n </ds-text>\n )}\n </div>\n {item.tag && (\n <ds-tag\n class=\"ds-choice-item__tag ds-interaction-fill__content\"\n label={item.tag.label}\n size={MENU_ITEM_TAG_SIZE[this.size]}\n intent={item.tag.intent ?? 'neutral'}\n contrast={item.tag.contrast ?? 'faint'}\n rounded={item.tag.rounded ?? false}\n isInset\n />\n )}\n {item.dot && (\n <span\n class=\"menu-item__dot-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-badge\n class=\"menu-item__dot\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n />\n </span>\n )}\n {item.showSwitch && (\n <ds-switch\n class=\"menu-item__switch ds-interaction-fill__content\"\n size={this.size}\n checked={!!item.switchValue}\n isInactive={!!item.isInactive}\n presentation\n />\n )}\n </button>\n );\n })\n )}\n </div>\n ))}\n </div>\n </div>\n <div class=\"ds-visually-hidden\" role=\"status\" aria-live=\"polite\">\n {this.reorderAnnouncement}\n </div>\n </Host>\n );\n }\n}\n",
508
508
  "type": "registry:ui"
509
509
  },
510
510
  {
@@ -38,7 +38,7 @@
38
38
  "Page-size changes request the first page. Out-of-range page indexes and invalid numeric inputs normalize only for presentation; the owner remains authoritative.",
39
39
  "Zero results display 0–0 of 0 and Page 1 of 1. A partial final page caps the visible range at totalItems.",
40
40
  "loading preserves the full pagination surface, applies aria-busy to the navigation region, and prevents page or page-size requests.",
41
- "fitToPage appends a Fit to page choice. fitPageSize supplies its effective whole-item capacity; the closed trigger says Fit while the range continues to expose the resulting count. Pagination does not measure layouts itself."
41
+ "fitToPage appends a Fit to page choice. fitPageSize supplies its effective whole-item capacity; the closed trigger says Fit while the range continues to expose the resulting count. fitToPageInactive keeps that choice visible but unavailable when fitting is not meaningful for the current data shape. Pagination does not measure layouts itself."
42
42
  ],
43
43
  "responsiveBehavior": [
44
44
  "The compact controls remain on one line with noun, selected size, total, a vertical divider, and page position. When inline space is still insufficient, the pagination surface scrolls horizontally rather than wrapping labels away from their controls."
@@ -98,6 +98,15 @@
98
98
  "mutable": false,
99
99
  "description": "Include the Fit to page choice."
100
100
  },
101
+ "fitToPageInactive": {
102
+ "type": "boolean",
103
+ "resolvedType": "boolean",
104
+ "attribute": "fit-to-page-inactive",
105
+ "default": "false",
106
+ "required": false,
107
+ "mutable": false,
108
+ "description": "Keep the Fit to page choice visible but unavailable."
109
+ },
101
110
  "itemLabel": {
102
111
  "type": "string",
103
112
  "resolvedType": "string",
@@ -236,6 +245,15 @@
236
245
  "mutable": false,
237
246
  "description": "Include the Fit to page choice."
238
247
  },
248
+ "fitToPageInactive": {
249
+ "type": "boolean",
250
+ "resolvedType": "boolean",
251
+ "attribute": "fit-to-page-inactive",
252
+ "default": "false",
253
+ "required": false,
254
+ "mutable": false,
255
+ "description": "Keep the Fit to page choice visible but unavailable."
256
+ },
239
257
  "itemLabel": {
240
258
  "type": "string",
241
259
  "resolvedType": "string",
@@ -385,7 +403,7 @@
385
403
  },
386
404
  {
387
405
  "path": "src/wc/components/Pagination/Pagination.tsx",
388
- "content": "import { Component, Event, EventEmitter, h, Host, Prop } from '@stencil/core';\nimport { resolvePaginationState } from './pagination-model';\nimport type {\n PaginationChangeDetail,\n PaginationPageSizeMode,\n} from './pagination-types';\n\nlet paginationId = 0;\n\n@Component({\n tag: 'ds-pagination',\n styleUrl: 'Pagination.css',\n scoped: true,\n})\nexport class Pagination {\n /** Controlled zero-based page index. */\n @Prop() pageIndex: number = 0;\n /** Controlled number of top-level items on a full page. */\n @Prop() pageSize: number = 25;\n /** Whether pageSize is a fixed choice or a measured Fit snapshot. */\n @Prop() pageSizeMode: PaginationPageSizeMode = 'fixed';\n /** Controlled total number of top-level items across every page. */\n @Prop() totalItems: number = 0;\n /** Available page sizes. Assign arrays through JavaScript. */\n @Prop() pageSizeOptions: number[] = [25, 50, 100, 200];\n /** Include the Fit to page choice. */\n @Prop() fitToPage: boolean = false;\n /** Effective whole-item capacity to request when Fit is selected. */\n @Prop() fitPageSize: number | undefined;\n /** Full choice-list label for Fit. */\n @Prop() fitPageSizeLabel: string = 'Fit to page';\n /** Compact closed-trigger label while Fit is selected. */\n @Prop() fitPageSizeTriggerLabel: string = 'Fit';\n /** Localized plural noun used by assistive range announcements. */\n @Prop() itemLabel: string = 'items';\n /** Visible page-size control label. */\n @Prop() pageSizeLabel: string = 'Items';\n /** Accessible page-size control label. Defaults to “{pageSizeLabel} per page”. */\n @Prop() pageSizeAriaLabel: string | undefined;\n /** Accessible name for the pagination navigation region. */\n @Prop() label: string = 'Pagination';\n /** Prevent interaction while the owner replaces the current data page. */\n @Prop() loading: boolean = false;\n\n /** Emits the complete next controlled state after a page or page-size request. */\n @Event() dsChange!: EventEmitter<PaginationChangeDetail>;\n\n private readonly pageSizeControlId = `ds-pagination-${++paginationId}-page-size`;\n\n private requestPage(pageIndex: number): void {\n const state = this.resolvedState;\n if (this.loading || pageIndex === state.pageIndex || pageIndex < 0 || pageIndex >= state.totalPages) {\n return;\n }\n this.dsChange.emit({\n pageIndex,\n pageSize: state.pageSize,\n pageSizeMode: state.pageSizeMode,\n totalItems: state.totalItems,\n pageSizeOptions: state.pageSizeOptions,\n fitToPage: this.fitToPage,\n fitPageSize: this.resolvedFitPageSize,\n fitPageSizeLabel: this.fitPageSizeLabel,\n fitPageSizeTriggerLabel: this.fitPageSizeTriggerLabel,\n itemLabel: this.itemLabel,\n pageSizeLabel: this.pageSizeLabel,\n ariaLabel: this.label,\n previousPageIndex: state.pageIndex,\n previousPageSize: state.pageSize,\n previousPageSizeMode: state.pageSizeMode,\n reason: 'page',\n });\n }\n\n private requestPageSize(value: string | string[]): void {\n if (this.loading || typeof value !== 'string') return;\n const state = this.resolvedState;\n const nextMode: PaginationPageSizeMode = value === 'fit' ? 'fit' : 'fixed';\n const pageSize = nextMode === 'fit' ? this.resolvedFitPageSize : Number(value);\n if (!pageSize || (pageSize === state.pageSize && nextMode === state.pageSizeMode)) return;\n this.dsChange.emit({\n pageIndex: 0,\n pageSize: Math.trunc(pageSize),\n pageSizeMode: nextMode,\n totalItems: state.totalItems,\n pageSizeOptions: state.pageSizeOptions,\n fitToPage: this.fitToPage,\n fitPageSize: this.resolvedFitPageSize,\n fitPageSizeLabel: this.fitPageSizeLabel,\n fitPageSizeTriggerLabel: this.fitPageSizeTriggerLabel,\n itemLabel: this.itemLabel,\n pageSizeLabel: this.pageSizeLabel,\n ariaLabel: this.label,\n previousPageIndex: state.pageIndex,\n previousPageSize: state.pageSize,\n previousPageSizeMode: state.pageSizeMode,\n reason: 'page-size',\n });\n }\n\n private handleKeyDown(event: KeyboardEvent): void {\n if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n return;\n }\n const fromChoiceControl = event.composedPath().some(node =>\n node instanceof HTMLElement &&\n (node.tagName === 'DS-SELECT' || ['INPUT', 'SELECT', 'TEXTAREA'].includes(node.tagName)),\n );\n if (fromChoiceControl || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;\n const state = this.resolvedState;\n const pageIndex = event.key === 'ArrowLeft' ? state.pageIndex - 1 : state.pageIndex + 1;\n if (this.loading || pageIndex < 0 || pageIndex >= state.totalPages) return;\n event.preventDefault();\n this.requestPage(pageIndex);\n }\n\n private get resolvedFitPageSize(): number | undefined {\n return Number.isFinite(this.fitPageSize) && Number(this.fitPageSize) > 0\n ? Math.trunc(Number(this.fitPageSize))\n : undefined;\n }\n\n private get resolvedState() {\n return resolvePaginationState({\n pageIndex: this.pageIndex,\n pageSize: this.pageSize,\n pageSizeMode: this.pageSizeMode,\n totalItems: this.totalItems,\n pageSizeOptions: this.pageSizeOptions,\n });\n }\n\n render() {\n const state = this.resolvedState;\n const atStart = state.pageIndex === 0;\n const atEnd = state.pageIndex === state.totalPages - 1;\n const total = `of ${state.totalItems}`;\n const page = `${state.pageIndex + 1} of ${state.totalPages}`;\n const announcedRange = `${state.firstItem}–${state.lastItem} of ${state.totalItems}`;\n const announcedPage = `Page ${state.pageIndex + 1} of ${state.totalPages}`;\n const pageSizeAriaLabel = this.pageSizeAriaLabel ?? `${this.pageSizeLabel} per page`;\n const options: Array<{ label: string; value: string; isInactive?: boolean }> =\n state.pageSizeOptions.map(value => ({ label: String(value), value: String(value) }));\n if (this.fitToPage) {\n options.push({\n label: this.fitPageSizeLabel,\n value: 'fit',\n isInactive: this.resolvedFitPageSize === undefined,\n });\n }\n\n return (\n <Host>\n <nav\n class=\"pagination\"\n aria-label={this.label}\n aria-busy={this.loading ? 'true' : undefined}\n onKeyDown={event => this.handleKeyDown(event)}\n >\n <div class=\"pagination__page-size\">\n <ds-text\n class=\"pagination__label\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n {this.pageSizeLabel}:\n </ds-text>\n <span id={`${this.pageSizeControlId}-label`} class=\"ds-visually-hidden\">\n {pageSizeAriaLabel}\n </span>\n <ds-select\n size=\"md\"\n inputId={this.pageSizeControlId}\n ariaLabelledby={`${this.pageSizeControlId}-label`}\n options={options}\n value={state.pageSizeMode === 'fit' ? 'fit' : String(state.pageSize)}\n triggerLabel={state.pageSizeMode === 'fit' ? this.fitPageSizeTriggerLabel : undefined}\n indicator=\"up-down\"\n allowClear={false}\n activeFill={false}\n isInactive={this.loading}\n onDsChange={event => {\n event.stopPropagation();\n this.requestPageSize(event.detail);\n }}\n />\n <ds-text\n class=\"pagination__total\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n fontFeature=\"tabular-nums\"\n >\n {total}\n </ds-text>\n </div>\n <ds-divider class=\"pagination__divider\" orientation=\"vertical\" length=\"24px\" />\n <div class=\"pagination__navigation\">\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronLeftDouble\"\n ariaLabel=\"First page\"\n hasBorder={false}\n isInactive={this.loading || atStart}\n onDsClick={() => this.requestPage(0)}\n />\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronLeft\"\n ariaLabel=\"Previous page\"\n hasBorder={false}\n isInactive={this.loading || atStart}\n onDsClick={() => this.requestPage(state.pageIndex - 1)}\n />\n <ds-text\n class=\"pagination__page\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n fontFeature=\"tabular-nums\"\n >\n {page}\n </ds-text>\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronRight\"\n ariaLabel=\"Next page\"\n hasBorder={false}\n isInactive={this.loading || atEnd}\n onDsClick={() => this.requestPage(state.pageIndex + 1)}\n />\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronRightDouble\"\n ariaLabel=\"Last page\"\n hasBorder={false}\n isInactive={this.loading || atEnd}\n onDsClick={() => this.requestPage(state.totalPages - 1)}\n />\n </div>\n <span class=\"ds-visually-hidden\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n {announcedRange} {this.itemLabel}. {announcedPage}.\n </span>\n </nav>\n </Host>\n );\n }\n}\n",
406
+ "content": "import { Component, Event, EventEmitter, h, Host, Prop } from '@stencil/core';\nimport { resolvePaginationState } from './pagination-model';\nimport type {\n PaginationChangeDetail,\n PaginationPageSizeMode,\n} from './pagination-types';\n\nlet paginationId = 0;\n\n@Component({\n tag: 'ds-pagination',\n styleUrl: 'Pagination.css',\n scoped: true,\n})\nexport class Pagination {\n /** Controlled zero-based page index. */\n @Prop() pageIndex: number = 0;\n /** Controlled number of top-level items on a full page. */\n @Prop() pageSize: number = 25;\n /** Whether pageSize is a fixed choice or a measured Fit snapshot. */\n @Prop() pageSizeMode: PaginationPageSizeMode = 'fixed';\n /** Controlled total number of top-level items across every page. */\n @Prop() totalItems: number = 0;\n /** Available page sizes. Assign arrays through JavaScript. */\n @Prop() pageSizeOptions: number[] = [25, 50, 100, 200];\n /** Include the Fit to page choice. */\n @Prop() fitToPage: boolean = false;\n /** Keep the Fit to page choice visible but unavailable. */\n @Prop() fitToPageInactive: boolean = false;\n /** Effective whole-item capacity to request when Fit is selected. */\n @Prop() fitPageSize: number | undefined;\n /** Full choice-list label for Fit. */\n @Prop() fitPageSizeLabel: string = 'Fit to page';\n /** Compact closed-trigger label while Fit is selected. */\n @Prop() fitPageSizeTriggerLabel: string = 'Fit';\n /** Localized plural noun used by assistive range announcements. */\n @Prop() itemLabel: string = 'items';\n /** Visible page-size control label. */\n @Prop() pageSizeLabel: string = 'Items';\n /** Accessible page-size control label. Defaults to “{pageSizeLabel} per page”. */\n @Prop() pageSizeAriaLabel: string | undefined;\n /** Accessible name for the pagination navigation region. */\n @Prop() label: string = 'Pagination';\n /** Prevent interaction while the owner replaces the current data page. */\n @Prop() loading: boolean = false;\n\n /** Emits the complete next controlled state after a page or page-size request. */\n @Event() dsChange!: EventEmitter<PaginationChangeDetail>;\n\n private readonly pageSizeControlId = `ds-pagination-${++paginationId}-page-size`;\n\n private requestPage(pageIndex: number): void {\n const state = this.resolvedState;\n if (this.loading || pageIndex === state.pageIndex || pageIndex < 0 || pageIndex >= state.totalPages) {\n return;\n }\n this.dsChange.emit({\n pageIndex,\n pageSize: state.pageSize,\n pageSizeMode: state.pageSizeMode,\n totalItems: state.totalItems,\n pageSizeOptions: state.pageSizeOptions,\n fitToPage: this.fitToPage,\n fitToPageInactive: this.fitToPageInactive,\n fitPageSize: this.resolvedFitPageSize,\n fitPageSizeLabel: this.fitPageSizeLabel,\n fitPageSizeTriggerLabel: this.fitPageSizeTriggerLabel,\n itemLabel: this.itemLabel,\n pageSizeLabel: this.pageSizeLabel,\n ariaLabel: this.label,\n previousPageIndex: state.pageIndex,\n previousPageSize: state.pageSize,\n previousPageSizeMode: state.pageSizeMode,\n reason: 'page',\n });\n }\n\n private requestPageSize(value: string | string[]): void {\n if (\n this.loading ||\n typeof value !== 'string' ||\n (value === 'fit' && this.fitToPageInactive)\n ) return;\n const state = this.resolvedState;\n const nextMode: PaginationPageSizeMode = value === 'fit' ? 'fit' : 'fixed';\n const pageSize = nextMode === 'fit' ? this.resolvedFitPageSize : Number(value);\n if (!pageSize || (pageSize === state.pageSize && nextMode === state.pageSizeMode)) return;\n this.dsChange.emit({\n pageIndex: 0,\n pageSize: Math.trunc(pageSize),\n pageSizeMode: nextMode,\n totalItems: state.totalItems,\n pageSizeOptions: state.pageSizeOptions,\n fitToPage: this.fitToPage,\n fitToPageInactive: this.fitToPageInactive,\n fitPageSize: this.resolvedFitPageSize,\n fitPageSizeLabel: this.fitPageSizeLabel,\n fitPageSizeTriggerLabel: this.fitPageSizeTriggerLabel,\n itemLabel: this.itemLabel,\n pageSizeLabel: this.pageSizeLabel,\n ariaLabel: this.label,\n previousPageIndex: state.pageIndex,\n previousPageSize: state.pageSize,\n previousPageSizeMode: state.pageSizeMode,\n reason: 'page-size',\n });\n }\n\n private handleKeyDown(event: KeyboardEvent): void {\n if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n return;\n }\n const fromChoiceControl = event.composedPath().some(node =>\n node instanceof HTMLElement &&\n (node.tagName === 'DS-SELECT' || ['INPUT', 'SELECT', 'TEXTAREA'].includes(node.tagName)),\n );\n if (fromChoiceControl || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;\n const state = this.resolvedState;\n const pageIndex = event.key === 'ArrowLeft' ? state.pageIndex - 1 : state.pageIndex + 1;\n if (this.loading || pageIndex < 0 || pageIndex >= state.totalPages) return;\n event.preventDefault();\n this.requestPage(pageIndex);\n }\n\n private get resolvedFitPageSize(): number | undefined {\n return Number.isFinite(this.fitPageSize) && Number(this.fitPageSize) > 0\n ? Math.trunc(Number(this.fitPageSize))\n : undefined;\n }\n\n private get resolvedState() {\n return resolvePaginationState({\n pageIndex: this.pageIndex,\n pageSize: this.pageSize,\n pageSizeMode: this.pageSizeMode,\n totalItems: this.totalItems,\n pageSizeOptions: this.pageSizeOptions,\n });\n }\n\n render() {\n const state = this.resolvedState;\n const atStart = state.pageIndex === 0;\n const atEnd = state.pageIndex === state.totalPages - 1;\n const total = `of ${state.totalItems}`;\n const page = `${state.pageIndex + 1} of ${state.totalPages}`;\n const announcedRange = `${state.firstItem}–${state.lastItem} of ${state.totalItems}`;\n const announcedPage = `Page ${state.pageIndex + 1} of ${state.totalPages}`;\n const pageSizeAriaLabel = this.pageSizeAriaLabel ?? `${this.pageSizeLabel} per page`;\n const options: Array<{ label: string; value: string; isInactive?: boolean }> =\n state.pageSizeOptions.map(value => ({ label: String(value), value: String(value) }));\n if (this.fitToPage) {\n options.push({\n label: this.fitPageSizeLabel,\n value: 'fit',\n isInactive: this.fitToPageInactive || this.resolvedFitPageSize === undefined,\n });\n }\n\n return (\n <Host>\n <nav\n class=\"pagination\"\n aria-label={this.label}\n aria-busy={this.loading ? 'true' : undefined}\n onKeyDown={event => this.handleKeyDown(event)}\n >\n <div class=\"pagination__page-size\">\n <ds-text\n class=\"pagination__label\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n {this.pageSizeLabel}:\n </ds-text>\n <span id={`${this.pageSizeControlId}-label`} class=\"ds-visually-hidden\">\n {pageSizeAriaLabel}\n </span>\n <ds-select\n size=\"md\"\n inputId={this.pageSizeControlId}\n ariaLabelledby={`${this.pageSizeControlId}-label`}\n options={options}\n value={state.pageSizeMode === 'fit' ? 'fit' : String(state.pageSize)}\n triggerLabel={state.pageSizeMode === 'fit' ? this.fitPageSizeTriggerLabel : undefined}\n indicator=\"up-down\"\n allowClear={false}\n activeFill={false}\n isInactive={this.loading}\n onDsChange={event => {\n event.stopPropagation();\n this.requestPageSize(event.detail);\n }}\n />\n <ds-text\n class=\"pagination__total\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n fontFeature=\"tabular-nums\"\n >\n {total}\n </ds-text>\n </div>\n <ds-divider class=\"pagination__divider\" orientation=\"vertical\" length=\"24px\" />\n <div class=\"pagination__navigation\">\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronLeftDouble\"\n ariaLabel=\"First page\"\n hasBorder={false}\n isInactive={this.loading || atStart}\n onDsClick={() => this.requestPage(0)}\n />\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronLeft\"\n ariaLabel=\"Previous page\"\n hasBorder={false}\n isInactive={this.loading || atStart}\n onDsClick={() => this.requestPage(state.pageIndex - 1)}\n />\n <ds-text\n class=\"pagination__page\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n fontFeature=\"tabular-nums\"\n >\n {page}\n </ds-text>\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronRight\"\n ariaLabel=\"Next page\"\n hasBorder={false}\n isInactive={this.loading || atEnd}\n onDsClick={() => this.requestPage(state.pageIndex + 1)}\n />\n <ds-button-unfilled\n variant=\"icon\"\n size=\"md\"\n icon=\"ChevronRightDouble\"\n ariaLabel=\"Last page\"\n hasBorder={false}\n isInactive={this.loading || atEnd}\n onDsClick={() => this.requestPage(state.totalPages - 1)}\n />\n </div>\n <span class=\"ds-visually-hidden\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n {announcedRange} {this.itemLabel}. {announcedPage}.\n </span>\n </nav>\n </Host>\n );\n }\n}\n",
389
407
  "type": "registry:ui"
390
408
  },
391
409
  {
@@ -395,7 +413,7 @@
395
413
  },
396
414
  {
397
415
  "path": "src/wc/components/Pagination/pagination-types.ts",
398
- "content": "export type PaginationChangeReason = 'page' | 'page-size' | 'fit';\nexport type PaginationPageSizeMode = 'fixed' | 'fit';\n\n/** Controlled pagination state shared by Pagination and pagination-aware consumers. */\nexport interface PaginationState {\n /** Zero-based page index. */\n pageIndex: number;\n /** Number of top-level items on a full page. */\n pageSize: number;\n /** Whether pageSize is a chosen fixed value or a measured Fit snapshot. */\n pageSizeMode?: PaginationPageSizeMode;\n /** Total number of top-level items across every page. */\n totalItems: number;\n /** Available page sizes. Defaults to 25, 50, 100, and 200. */\n pageSizeOptions?: number[];\n /** Include a Fit to page option. The owner supplies the effective fitted size. */\n fitToPage?: boolean;\n /** Effective page size proposed when Fit to page is selected. */\n fitPageSize?: number;\n /** Full option label for Fit in the open choice list. */\n fitPageSizeLabel?: string;\n /** Compact trigger label while Fit is selected. */\n fitPageSizeTriggerLabel?: string;\n /** Localized plural noun used by assistive range announcements. */\n itemLabel?: string;\n /** Visible label for the page-size selector. */\n pageSizeLabel?: string;\n /** Accessible name for the pagination navigation region. */\n ariaLabel?: string;\n}\n\n/** Complete next controlled state emitted for a page or page-size request. */\nexport interface PaginationChangeDetail extends PaginationState {\n previousPageIndex: number;\n previousPageSize: number;\n previousPageSizeMode: PaginationPageSizeMode;\n reason: PaginationChangeReason;\n}\n",
416
+ "content": "export type PaginationChangeReason = 'page' | 'page-size' | 'fit';\nexport type PaginationPageSizeMode = 'fixed' | 'fit';\n\n/** Controlled pagination state shared by Pagination and pagination-aware consumers. */\nexport interface PaginationState {\n /** Zero-based page index. */\n pageIndex: number;\n /** Number of top-level items on a full page. */\n pageSize: number;\n /** Whether pageSize is a chosen fixed value or a measured Fit snapshot. */\n pageSizeMode?: PaginationPageSizeMode;\n /** Total number of top-level items across every page. */\n totalItems: number;\n /** Available page sizes. Defaults to 25, 50, 100, and 200. */\n pageSizeOptions?: number[];\n /** Include a Fit to page option. The owner supplies the effective fitted size. */\n fitToPage?: boolean;\n /** Keep the Fit to page option visible but unavailable for the current data shape. */\n fitToPageInactive?: boolean;\n /** Effective page size proposed when Fit to page is selected. */\n fitPageSize?: number;\n /** Full option label for Fit in the open choice list. */\n fitPageSizeLabel?: string;\n /** Compact trigger label while Fit is selected. */\n fitPageSizeTriggerLabel?: string;\n /** Localized plural noun used by assistive range announcements. */\n itemLabel?: string;\n /** Visible label for the page-size selector. */\n pageSizeLabel?: string;\n /** Accessible name for the pagination navigation region. */\n ariaLabel?: string;\n}\n\n/** Complete next controlled state emitted for a page or page-size request. */\nexport interface PaginationChangeDetail extends PaginationState {\n previousPageIndex: number;\n previousPageSize: number;\n previousPageSizeMode: PaginationPageSizeMode;\n reason: PaginationChangeReason;\n}\n",
399
417
  "type": "registry:ui"
400
418
  }
401
419
  ]
@@ -620,7 +620,7 @@
620
620
  "files": [
621
621
  {
622
622
  "path": "src/wc/components/PanelNav/PanelNav.css",
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
+ "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:host-context(ds-shell-app.shell-app--paper-texture) .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",
624
624
  "type": "registry:ui"
625
625
  },
626
626
  {
@@ -463,7 +463,7 @@
463
463
  },
464
464
  {
465
465
  "path": "src/wc/components/PanelTools/PanelTools.tsx",
466
- "content": "import {\n Component,\n Prop,\n Event,\n EventEmitter,\n Element,\n State,\n Watch,\n Method,\n Listen,\n h,\n Host,\n} from '@stencil/core';\nimport type { ChromeTransitionDetail } from '../../shell/chrome-transition';\nimport {\n PANEL_TOOLS_LABELS,\n PANEL_TOOLS_SHORTCUTS,\n type PanelToolsHeaderAction,\n type PanelToolsHeaderConfig,\n type PanelToolsHeaders,\n type PanelToolsItem,\n type PanelToolsToolId,\n} from './panel-tools-types';\nimport {\n isPanelToolsToolId,\n orderPanelToolsItems,\n panelToolsDrawerAtTerminal,\n panelToolsDrawerResting,\n panelToolsDrawerTransitionMs,\n panelToolsRailPlacement,\n reconcilePanelToolsAvailability,\n resolvePanelToolActivation,\n} from './panel-tools-utils';\n\n@Component({\n tag: 'ds-panel-tools',\n styleUrl: 'PanelTools.css',\n scoped: true,\n})\nexport class PanelTools {\n @Element() el!: HTMLElement;\n\n /** When false, only the icon rail is shown. */\n @Prop({ mutable: true, reflect: true }) open: boolean = false;\n\n /** Drawer presentation or viewport-covering presentation for the active tool. */\n @Prop({ mutable: true, reflect: true }) presentation: 'drawer' | 'fullscreen' = 'drawer';\n\n /** Let a fullscreen product supply independent master/detail headers inside its view. */\n @Prop({ attribute: 'fullscreen-header-mode', reflect: true })\n fullscreenHeaderMode: 'shared' | 'split' = 'shared';\n\n /** Active tool view — `search`, `agents`, `messages`, `stacks`, `activity`, or `help`. */\n @Prop({ mutable: true, attribute: 'active-tool', reflect: true })\n activeTool: PanelToolsToolId | '' = '';\n\n /**\n * Rail items rendered in the right column.\n * Set via JS property: `el.items = [...]`. Replace the array reference to update.\n */\n @Prop() items: PanelToolsItem[] = [];\n\n @Prop() toolsLabel: string = 'Tools';\n @Prop() toolShortcutsLabel: string = 'Tool shortcuts';\n\n /** Active header state per tool. Replace the object when title, depth, or actions change. */\n @Prop() headers: PanelToolsHeaders = {};\n\n /**\n * Optional localStorage key for the last active tool. The drawer always starts\n * closed; only the tool identity is restored for continuity within this browser.\n */\n @Prop({ attribute: 'storage-key' }) storageKey: string = '';\n\n /** Emitted when a rail button is toggled. Detail = { id, selected }. */\n @Event({ bubbles: true, composed: true }) dsToolChange!: EventEmitter<{\n id: PanelToolsToolId;\n selected: boolean;\n }>;\n\n /** Emitted when fullscreen changes, including Escape-initiated exits. */\n @Event({ bubbles: true, composed: true }) dsPresentationChange!: EventEmitter<{\n presentation: 'drawer' | 'fullscreen';\n }>;\n\n /** Requests navigation to the active tool's parent view. */\n @Event({ bubbles: true, composed: true }) dsHeaderBack!: EventEmitter<{\n tool: PanelToolsToolId;\n }>;\n\n /** Requests one application-owned action from the active tool header. */\n @Event({ bubbles: true, composed: true }) dsHeaderAction!: EventEmitter<{\n tool: PanelToolsToolId;\n id: string;\n }>;\n\n /** Bubbling lifecycle — `ds-bar-nav` defers overflow checks during drawer motion. */\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionStart!: EventEmitter<ChromeTransitionDetail>;\n\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionEnd!: EventEmitter<ChromeTransitionDetail>;\n\n /** Arms open vs close easing for the in-flight width transition. */\n @State() private motion: 'opening' | 'closing' | 'idle' = 'idle';\n\n /** Keeps header and slotted content painted until the closing clip transition completes. */\n @State() private drawerSurfaceRetained = false;\n\n /** Suppresses width transition until the host has painted its initial open state. */\n @State() private readyForMotion = false;\n\n /** Fullscreen presentation changes snap without borrowing drawer width motion. */\n @State() private presentationMotionSuppressed = false;\n\n @State() private rovingIndex = 0;\n @State() private fullViewToolIds: PanelToolsToolId[] = [];\n @State() private railBodyScrollable = false;\n\n private motionEnableGeneration = 0;\n private presentationMotionGeneration = 0;\n private transitionGeneration = 0;\n private transitionFallbackTimer: number | null = null;\n /** Ignores the old transition's terminal event when reversing direction. */\n private ignoreReplacementTerminal = false;\n private focusBeforeFullscreen: HTMLElement | null = null;\n private fullViewObserver: MutationObserver | null = null;\n private railBodyEl?: HTMLElement;\n private railBodyObserver?: ResizeObserver;\n\n private get railItems(): PanelToolsItem[] {\n return this.items ?? [];\n }\n\n private get orderedRailItems(): PanelToolsItem[] {\n return orderPanelToolsItems(this.railItems);\n }\n\n disconnectedCallback() {\n this.el.removeEventListener('transitionend', this.handleTransitionEnd);\n this.el.removeEventListener('transitioncancel', this.handleTransitionEnd);\n this.el.removeEventListener('dsAction', this.handleComposedHeaderAction);\n this.clearTransitionCompletion();\n this.motionEnableGeneration += 1;\n this.fullViewObserver?.disconnect();\n this.fullViewObserver = null;\n this.disconnectRailBodyObserver();\n }\n\n @Watch('presentation')\n presentationChanged(next: 'drawer' | 'fullscreen', previous?: 'drawer' | 'fullscreen') {\n if (next === previous) return;\n // Framework property bindings can update slotted master/detail content in\n // the same render that requests fullscreen. Reflect the host selector\n // immediately so that content cannot paint once in the drawer geometry\n // while Stencil waits to commit its next render.\n if (this.el.getAttribute('presentation') !== next) {\n this.el.setAttribute('presentation', next);\n }\n const presentationGeneration = ++this.presentationMotionGeneration;\n this.presentationMotionSuppressed = true;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n if (presentationGeneration !== this.presentationMotionGeneration) return;\n this.presentationMotionSuppressed = false;\n });\n });\n if (next === 'fullscreen') {\n this.focusBeforeFullscreen =\n document.activeElement instanceof HTMLElement ? document.activeElement : null;\n this.open = true;\n } else if (previous === 'fullscreen') {\n const focusBeforeFullscreen = this.focusBeforeFullscreen;\n const fallbackActionId = this.activeHeader().actions?.[0]?.id;\n requestAnimationFrame(async () => {\n if (focusBeforeFullscreen?.isConnected) {\n focusBeforeFullscreen.focus({ preventScroll: true });\n }\n const focusRestored = focusBeforeFullscreen\n ? document.activeElement === focusBeforeFullscreen ||\n focusBeforeFullscreen.contains(document.activeElement)\n : false;\n if (!focusRestored && fallbackActionId) await this.focusHeaderAction(fallbackActionId);\n this.focusBeforeFullscreen = null;\n });\n }\n this.dsPresentationChange.emit({ presentation: next });\n }\n\n @Listen('keydown', { target: 'window', capture: true })\n handleFullscreenEscape(event: KeyboardEvent) {\n if (this.presentation !== 'fullscreen' || event.key !== 'Escape') return;\n event.preventDefault();\n event.stopPropagation();\n this.presentation = 'drawer';\n }\n\n private deferMotionEnable() {\n if (this.readyForMotion) return;\n const generation = ++this.motionEnableGeneration;\n requestAnimationFrame(() => {\n if (generation !== this.motionEnableGeneration) return;\n requestAnimationFrame(() => {\n if (generation !== this.motionEnableGeneration) return;\n this.readyForMotion = true;\n });\n });\n }\n\n connectedCallback() {\n this.el.addEventListener('transitionend', this.handleTransitionEnd);\n this.el.addEventListener('transitioncancel', this.handleTransitionEnd);\n this.el.addEventListener('dsAction', this.handleComposedHeaderAction);\n }\n\n private handleComposedHeaderAction = (event: Event) => {\n const detail = (event as CustomEvent<{ id?: string }>).detail;\n if (!this.activeTool || !detail?.id) return;\n event.stopPropagation();\n this.dsHeaderAction.emit({ tool: this.activeTool, id: detail.id });\n };\n\n componentWillLoad() {\n this.restoreLastActiveTool();\n this.drawerSurfaceRetained = this.open;\n }\n\n @Watch('open')\n openChanged(isOpen: boolean, wasOpen?: boolean) {\n if (isOpen) this.drawerSurfaceRetained = true;\n if (this.readyForMotion && wasOpen !== undefined && wasOpen !== isOpen) {\n this.startDrawerTransition(isOpen ? 'opening' : 'closing');\n } else if (!isOpen) {\n this.drawerSurfaceRetained = false;\n }\n this.deferMotionEnable();\n }\n\n @Watch('activeTool')\n activeToolChanged() {\n this.persistLastActiveTool();\n this.deferMotionEnable();\n }\n\n @Watch('storageKey')\n storageKeyChanged() {\n this.restoreLastActiveTool();\n }\n\n componentDidLoad() {\n this.deferMotionEnable();\n this.connectRailBodyObserver();\n this.updateRailBodyScrollable();\n this.syncFullViewTools();\n this.fullViewObserver = new MutationObserver(() => this.syncFullViewTools());\n this.fullViewObserver.observe(this.el, {\n childList: true,\n attributes: true,\n attributeFilter: ['slot'],\n });\n }\n\n componentDidRender() {\n this.updateRailBodyScrollable();\n }\n\n private connectRailBodyObserver() {\n this.disconnectRailBodyObserver();\n if (!this.railBodyEl || typeof ResizeObserver === 'undefined') return;\n this.railBodyObserver = new ResizeObserver(() => this.updateRailBodyScrollable());\n this.railBodyObserver.observe(this.railBodyEl);\n }\n\n private disconnectRailBodyObserver() {\n this.railBodyObserver?.disconnect();\n this.railBodyObserver = undefined;\n }\n\n private updateRailBodyScrollable() {\n const next = Boolean(\n this.railBodyEl && this.railBodyEl.scrollHeight > this.railBodyEl.clientHeight + 1\n );\n if (next !== this.railBodyScrollable) this.railBodyScrollable = next;\n }\n\n @Watch('items')\n itemsChanged() {\n this.rovingIndex = 0;\n this.reconcileActiveTool();\n this.deferMotionEnable();\n }\n\n private restoreLastActiveTool() {\n if (!this.storageKey || this.activeTool) return;\n try {\n const stored = localStorage.getItem(this.storageKey);\n if (isPanelToolsToolId(stored)) this.activeTool = stored;\n } catch {\n /* localStorage unavailable */\n }\n }\n\n private persistLastActiveTool() {\n if (!this.storageKey || !this.activeTool) return;\n try {\n localStorage.setItem(this.storageKey, this.activeTool);\n } catch {\n /* unavailable */\n }\n }\n\n private clearPersistedTool() {\n if (!this.storageKey) return;\n try {\n localStorage.removeItem(this.storageKey);\n } catch {\n /* unavailable */\n }\n }\n\n private reconcileActiveTool() {\n const next = reconcilePanelToolsAvailability(this.railItems, this.open, this.activeTool);\n if (!next.removedTool) return;\n this.clearPersistedTool();\n this.open = next.open;\n this.activeTool = next.activeTool;\n this.dsToolChange.emit({ id: next.removedTool, selected: false });\n }\n\n private startDrawerTransition(phase: 'opening' | 'closing') {\n const drawer = this.el.querySelector('.panel-tools__drawer') as HTMLElement | null;\n if (this.motion !== 'idle') {\n this.finishDrawerTransition();\n this.ignoreReplacementTerminal = true;\n }\n this.motion = phase;\n this.dsChromeTransitionStart.emit({ source: 'panel-tools', phase });\n const generation = ++this.transitionGeneration;\n\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n if (generation !== this.transitionGeneration || this.motion !== phase) return;\n if (!drawer) {\n this.finishDrawerTransition();\n return;\n }\n\n if (this.drawerTransitionAtTerminal(drawer, phase)) {\n this.finishDrawerTransition();\n return;\n }\n\n const fallbackMs = panelToolsDrawerTransitionMs(getComputedStyle(drawer));\n if (fallbackMs <= 0) {\n this.finishDrawerTransition();\n return;\n }\n this.transitionFallbackTimer = window.setTimeout(() => {\n this.finishDrawerTransition();\n }, fallbackMs);\n });\n });\n }\n\n private handleTransitionEnd = (event: TransitionEvent) => {\n const drawer = this.el.querySelector('.panel-tools__drawer') as HTMLElement | null;\n if (event.target !== drawer) return;\n if (event.propertyName !== 'max-width') return;\n if (this.ignoreReplacementTerminal) {\n this.ignoreReplacementTerminal = false;\n return;\n }\n // Changing from the neutral transition recipe to the direction-specific\n // easing can cancel the transition that the reflected `open` class began.\n // The replacement transition and its watchdog still own completion; ending\n // motion here would hide the fixed-width tool surface while it is visible.\n if (event.type === 'transitioncancel') return;\n if (drawer && !this.drawerTransitionAtTerminal(drawer, this.motion)) return;\n this.finishDrawerTransition();\n };\n\n private drawerTransitionAtTerminal(\n drawer: HTMLElement,\n phase: 'opening' | 'closing' | 'idle'\n ): boolean {\n const width = drawer.getBoundingClientRect().width;\n const surface = drawer.querySelector('.panel-tools__drawer-surface') as HTMLElement | null;\n const targetWidth = surface?.getBoundingClientRect().width ?? 0;\n return panelToolsDrawerAtTerminal(width, targetWidth, phase);\n }\n\n private finishDrawerTransition() {\n const completedPhase = this.motion;\n const wasTransitioning = completedPhase !== 'idle';\n this.clearTransitionCompletion();\n if (!wasTransitioning) return;\n this.ignoreReplacementTerminal = false;\n this.motion = 'idle';\n if (completedPhase === 'closing' && !this.open) {\n this.drawerSurfaceRetained = false;\n }\n this.dsChromeTransitionEnd.emit({ source: 'panel-tools' });\n }\n\n private clearTransitionCompletion() {\n this.transitionGeneration += 1;\n if (this.transitionFallbackTimer !== null) {\n window.clearTimeout(this.transitionFallbackTimer);\n this.transitionFallbackTimer = null;\n }\n }\n\n /** Rail selection follows `open` immediately — independent of the slide animation. */\n private isRailSelected(id: PanelToolsToolId): boolean {\n return this.open && this.activeTool === id;\n }\n\n /** Drawer body stays mounted while the clip frame animates closed. */\n private isDrawerPresent(): boolean {\n return this.open || this.drawerSurfaceRetained;\n }\n\n private isViewActive(id: PanelToolsToolId): boolean {\n return this.isDrawerPresent() && this.activeTool === id;\n }\n\n private hasFullView(id: PanelToolsToolId): boolean {\n return this.fullViewToolIds.includes(id);\n }\n\n private syncFullViewTools() {\n const ids = this.orderedRailItems.map(item => item.id);\n const next = ids.filter(id => Boolean(this.el.querySelector(`[slot=\"${id}-view\"]`)));\n if (next.join('|') !== this.fullViewToolIds.join('|')) this.fullViewToolIds = next;\n }\n\n private headerLabel(): string {\n if (!this.isDrawerPresent() || !this.activeTool) return '';\n const item = this.railItems.find(candidate => candidate.id === this.activeTool);\n return (\n item?.label ??\n PANEL_TOOLS_LABELS[this.activeTool as keyof typeof PANEL_TOOLS_LABELS] ??\n this.activeTool\n );\n }\n\n private resolvedHeaders(): PanelToolsHeaders {\n return this.headers ?? {};\n }\n\n private activeHeader(): PanelToolsHeaderConfig {\n if (!this.activeTool) return {};\n return this.resolvedHeaders()[this.activeTool] ?? {};\n }\n\n private handleToolChange = (id: PanelToolsToolId) => {\n const next = resolvePanelToolActivation(this.open, this.activeTool, id);\n this.open = next.open;\n this.activeTool = next.activeTool;\n this.dsToolChange.emit({ id, selected: next.selected });\n };\n\n /** Toggle any rail tool open/closed — shell shortcuts K/A/S/M/N call this. */\n @Method()\n async activateTool(id: PanelToolsToolId) {\n const item = this.railItems.find(entry => entry.id === id);\n if (!item || item.isInactive) return;\n this.handleToolChange(id);\n }\n\n /** Close the tools drawer when open — used by shell keyboard shortcuts. */\n @Method()\n async closeDrawer() {\n if (!this.open) return;\n const id = this.activeTool;\n this.open = false;\n if (id) {\n this.dsToolChange.emit({ id, selected: false });\n }\n }\n\n /** Focus an active tool-header action by its application-owned id. */\n @Method()\n async focusHeaderAction(id: string) {\n const action = this.el.querySelector(`[data-header-action-id=\"${CSS.escape(id)}\"]`) as\n | (HTMLElement & { setFocus?: () => Promise<void> })\n | null;\n await action?.setFocus?.();\n }\n\n private focusRailAt(index: number) {\n const items = this.orderedRailItems;\n if (!items.length) return;\n const bounded = Math.max(0, Math.min(index, items.length - 1));\n if (bounded === this.rovingIndex) return;\n this.rovingIndex = bounded;\n const actions = Array.from(\n this.el.querySelectorAll<HTMLElement>('.panel-tools__rail-action .button-icon')\n );\n actions[bounded]?.focus({ preventScroll: true });\n }\n\n private handleRailKeyDown = (e: KeyboardEvent, index: number) => {\n const items = this.orderedRailItems;\n if (!items.length) return;\n\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.handleToolChange(items[index].id);\n return;\n }\n\n if (e.key === 'ArrowDown') {\n if (index >= items.length - 1) return;\n e.preventDefault();\n this.focusRailAt(index + 1);\n return;\n }\n\n if (e.key === 'ArrowUp') {\n if (index <= 0) return;\n e.preventDefault();\n this.focusRailAt(index - 1);\n }\n };\n\n private renderRailAction(item: PanelToolsItem, index: number) {\n const label =\n item.ariaLabel ??\n item.label ??\n PANEL_TOOLS_LABELS[item.id as keyof typeof PANEL_TOOLS_LABELS] ??\n item.id;\n return (\n <ds-tooltip\n key={item.id}\n label={label}\n shortcutKey={\n item.shortcutKey ??\n PANEL_TOOLS_SHORTCUTS[item.id as keyof typeof PANEL_TOOLS_SHORTCUTS]\n }\n side=\"left\"\n size=\"sm\"\n >\n <ds-button-unfilled\n variant=\"icon\"\n class=\"panel-tools__rail-action\"\n icon={item.icon}\n isActive={this.isRailSelected(item.id)}\n activeFill={false}\n hasBorder={false}\n pressScale={false}\n dot={item.dot ?? false}\n isInactive={item.isInactive}\n focusTabIndex={index === this.rovingIndex ? 0 : -1}\n aria-label={label}\n pressed={this.isRailSelected(item.id)}\n onFocusin={() => {\n this.rovingIndex = index;\n }}\n onKeyDown={(e: KeyboardEvent) => this.handleRailKeyDown(e, index)}\n onDsClick={() => this.handleToolChange(item.id)}\n />\n </ds-tooltip>\n );\n }\n\n render() {\n const headerLabel = this.headerLabel();\n const header = this.activeHeader();\n const headerTitle = header.title?.trim() || headerLabel;\n const headerActions = header.actions ?? [];\n const orderedRailItems = this.orderedRailItems;\n const headerItems = orderedRailItems.filter(\n item => panelToolsRailPlacement(item) === 'header'\n );\n const bodyItems = orderedRailItems.filter(\n item => panelToolsRailPlacement(item) === 'body'\n );\n const footerItems = orderedRailItems.filter(\n item => panelToolsRailPlacement(item) === 'footer'\n );\n const headerCount = headerItems.length;\n const footerStart = headerCount + bodyItems.length;\n const showDrawerChrome = this.isDrawerPresent();\n const drawerResting = panelToolsDrawerResting(this.open, this.motion);\n const activeFullView = Boolean(\n showDrawerChrome && this.activeTool && this.hasFullView(this.activeTool)\n );\n const toolIds = orderedRailItems.map(item => item.id);\n\n return (\n <Host\n class={{\n 'panel-tools': true,\n 'panel-tools--open': this.open,\n 'panel-tools--ready': this.readyForMotion,\n 'panel-tools--motion-opening': this.motion === 'opening',\n 'panel-tools--motion-closing': this.motion === 'closing',\n 'panel-tools--drawer-resting': drawerResting,\n 'panel-tools--fullscreen': this.presentation === 'fullscreen',\n 'panel-tools--presentation-snap': this.presentationMotionSuppressed,\n }}\n role={this.presentation === 'fullscreen' ? 'dialog' : 'complementary'}\n aria-modal={this.presentation === 'fullscreen' ? 'true' : undefined}\n aria-label={this.toolsLabel}\n >\n <div class=\"panel-tools__layout\">\n <nav class=\"panel-tools__rail\" aria-label={this.toolShortcutsLabel}>\n {headerItems.length ? (\n <div class=\"panel-tools__rail-header ds-chrome-row ds-chrome-space--md\">\n {headerItems.map((item, index) => this.renderRailAction(item, index))}\n </div>\n ) : null}\n <div\n class={{\n 'panel-tools__rail-body': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--md': true,\n 'ds-scrollbar-hidden': true,\n 'ds-focus-ring': this.railBodyScrollable,\n }}\n ref={element => {\n const next = (element as HTMLElement) ?? undefined;\n if (next === this.railBodyEl) return;\n this.railBodyEl = next;\n this.connectRailBodyObserver();\n }}\n role={this.railBodyScrollable ? 'region' : undefined}\n aria-label={this.railBodyScrollable ? this.toolShortcutsLabel : undefined}\n tabIndex={this.railBodyScrollable ? 0 : undefined}\n >\n <div class=\"panel-tools__rail-actions\">\n {bodyItems.map((item, bodyIdx) =>\n this.renderRailAction(item, headerCount + bodyIdx)\n )}\n </div>\n </div>\n {footerItems.length ? (\n <div class=\"panel-tools__rail-footer ds-chrome-row ds-chrome-space--md\">\n {footerItems.map((item, footerIdx) =>\n this.renderRailAction(item, footerStart + footerIdx)\n )}\n </div>\n ) : null}\n </nav>\n\n <div\n class={{\n 'panel-tools__drawer': true,\n 'panel-tools__drawer--visible': showDrawerChrome,\n }}\n aria-hidden={showDrawerChrome ? null : 'true'}\n inert={showDrawerChrome ? undefined : true}\n >\n <div class=\"panel-tools__drawer-surface\">\n {this.presentation !== 'fullscreen' || this.fullscreenHeaderMode === 'shared' ? (\n <ds-panel-tool-header\n class=\"panel-tools__header\"\n heading={headerTitle}\n showBack={header.showBack ?? false}\n backIcon={header.backIcon || 'ChevronLeft'}\n backAriaLabel={header.backAriaLabel || 'Back'}\n showMenu={false}\n ref={element => {\n const toolHeader = element as\n | (HTMLDsPanelToolHeaderElement & {\n actions: PanelToolsHeaderAction[];\n })\n | undefined;\n if (toolHeader) toolHeader.actions = headerActions;\n }}\n onDsBack={() => {\n if (this.activeTool) this.dsHeaderBack.emit({ tool: this.activeTool });\n }}\n />\n ) : null}\n <div class=\"panel-tools__body\" hidden={activeFullView}>\n {toolIds.map(id => {\n const active = this.isViewActive(id);\n return (\n <div\n class={{\n 'panel-tools__view': true,\n 'panel-tools__view--active': active,\n }}\n hidden={!active}\n >\n <slot name={id} />\n </div>\n );\n })}\n </div>\n <div class=\"panel-tools__full-views\" hidden={!activeFullView}>\n {toolIds.map(id => (\n <div\n class={{\n 'panel-tools__full-view': true,\n 'panel-tools__full-view--active':\n this.isViewActive(id) && this.hasFullView(id),\n }}\n hidden={!(this.isViewActive(id) && this.hasFullView(id))}\n >\n <slot name={`${id}-view`} />\n </div>\n ))}\n </div>\n </div>\n </div>\n </div>\n </Host>\n );\n }\n}\n",
466
+ "content": "import {\n Component,\n Prop,\n Event,\n EventEmitter,\n Element,\n State,\n Watch,\n Method,\n Listen,\n h,\n Host,\n} from '@stencil/core';\nimport type { ChromeTransitionDetail } from '../../shell/chrome-transition';\nimport {\n PANEL_TOOLS_LABELS,\n PANEL_TOOLS_SHORTCUTS,\n type PanelToolsHeaderAction,\n type PanelToolsHeaderConfig,\n type PanelToolsHeaders,\n type PanelToolsItem,\n type PanelToolsToolId,\n} from './panel-tools-types';\nimport {\n isPanelToolsToolId,\n orderPanelToolsItems,\n panelToolsDrawerAtTerminal,\n panelToolsDrawerResting,\n panelToolsDrawerTransitionMs,\n panelToolsRailPlacement,\n reconcilePanelToolsAvailability,\n resolvePanelToolActivation,\n} from './panel-tools-utils';\n\n@Component({\n tag: 'ds-panel-tools',\n styleUrl: 'PanelTools.css',\n shadow: true,\n})\nexport class PanelTools {\n @Element() el!: HTMLElement;\n\n /** When false, only the icon rail is shown. */\n @Prop({ mutable: true, reflect: true }) open: boolean = false;\n\n /** Drawer presentation or viewport-covering presentation for the active tool. */\n @Prop({ mutable: true, reflect: true }) presentation: 'drawer' | 'fullscreen' = 'drawer';\n\n /** Let a fullscreen product supply independent master/detail headers inside its view. */\n @Prop({ attribute: 'fullscreen-header-mode', reflect: true })\n fullscreenHeaderMode: 'shared' | 'split' = 'shared';\n\n /** Active tool view — `search`, `agents`, `messages`, `stacks`, `activity`, or `help`. */\n @Prop({ mutable: true, attribute: 'active-tool', reflect: true })\n activeTool: PanelToolsToolId | '' = '';\n\n /**\n * Rail items rendered in the right column.\n * Set via JS property: `el.items = [...]`. Replace the array reference to update.\n */\n @Prop() items: PanelToolsItem[] = [];\n\n @Prop() toolsLabel: string = 'Tools';\n @Prop() toolShortcutsLabel: string = 'Tool shortcuts';\n\n /** Active header state per tool. Replace the object when title, depth, or actions change. */\n @Prop() headers: PanelToolsHeaders = {};\n\n /**\n * Optional localStorage key for the last active tool. The drawer always starts\n * closed; only the tool identity is restored for continuity within this browser.\n */\n @Prop({ attribute: 'storage-key' }) storageKey: string = '';\n\n /** Emitted when a rail button is toggled. Detail = { id, selected }. */\n @Event({ bubbles: true, composed: true }) dsToolChange!: EventEmitter<{\n id: PanelToolsToolId;\n selected: boolean;\n }>;\n\n /** Emitted when fullscreen changes, including Escape-initiated exits. */\n @Event({ bubbles: true, composed: true }) dsPresentationChange!: EventEmitter<{\n presentation: 'drawer' | 'fullscreen';\n }>;\n\n /** Requests navigation to the active tool's parent view. */\n @Event({ bubbles: true, composed: true }) dsHeaderBack!: EventEmitter<{\n tool: PanelToolsToolId;\n }>;\n\n /** Requests one application-owned action from the active tool header. */\n @Event({ bubbles: true, composed: true }) dsHeaderAction!: EventEmitter<{\n tool: PanelToolsToolId;\n id: string;\n }>;\n\n /** Bubbling lifecycle — `ds-bar-nav` defers overflow checks during drawer motion. */\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionStart!: EventEmitter<ChromeTransitionDetail>;\n\n @Event({ bubbles: true, composed: true })\n dsChromeTransitionEnd!: EventEmitter<ChromeTransitionDetail>;\n\n /** Arms open vs close easing for the in-flight width transition. */\n @State() private motion: 'opening' | 'closing' | 'idle' = 'idle';\n\n /** Keeps header and slotted content painted until the closing clip transition completes. */\n @State() private drawerSurfaceRetained = false;\n\n /** Suppresses width transition until the host has painted its initial open state. */\n @State() private readyForMotion = false;\n\n /** Fullscreen presentation changes snap without borrowing drawer width motion. */\n @State() private presentationMotionSuppressed = false;\n\n @State() private rovingIndex = 0;\n @State() private fullViewToolIds: PanelToolsToolId[] = [];\n @State() private railBodyScrollable = false;\n\n private motionEnableGeneration = 0;\n private presentationMotionGeneration = 0;\n private transitionGeneration = 0;\n private transitionFallbackTimer: number | null = null;\n /** Ignores the old transition's terminal event when reversing direction. */\n private ignoreReplacementTerminal = false;\n private focusBeforeFullscreen: HTMLElement | null = null;\n private fullViewObserver: MutationObserver | null = null;\n private railBodyEl?: HTMLElement;\n private railBodyObserver?: ResizeObserver;\n\n private get railItems(): PanelToolsItem[] {\n return this.items ?? [];\n }\n\n private get orderedRailItems(): PanelToolsItem[] {\n return orderPanelToolsItems(this.railItems);\n }\n\n disconnectedCallback() {\n this.el.removeEventListener('transitionend', this.handleTransitionEnd);\n this.el.removeEventListener('transitioncancel', this.handleTransitionEnd);\n this.el.removeEventListener('dsAction', this.handleComposedHeaderAction);\n this.clearTransitionCompletion();\n this.motionEnableGeneration += 1;\n this.fullViewObserver?.disconnect();\n this.fullViewObserver = null;\n this.disconnectRailBodyObserver();\n }\n\n @Watch('presentation')\n presentationChanged(next: 'drawer' | 'fullscreen', previous?: 'drawer' | 'fullscreen') {\n if (next === previous) return;\n // Framework property bindings can update slotted master/detail content in\n // the same render that requests fullscreen. Reflect the host selector\n // immediately so that content cannot paint once in the drawer geometry\n // while Stencil waits to commit its next render.\n if (this.el.getAttribute('presentation') !== next) {\n this.el.setAttribute('presentation', next);\n }\n const presentationGeneration = ++this.presentationMotionGeneration;\n this.presentationMotionSuppressed = true;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n if (presentationGeneration !== this.presentationMotionGeneration) return;\n this.presentationMotionSuppressed = false;\n });\n });\n if (next === 'fullscreen') {\n this.focusBeforeFullscreen =\n document.activeElement instanceof HTMLElement ? document.activeElement : null;\n this.open = true;\n } else if (previous === 'fullscreen') {\n const focusBeforeFullscreen = this.focusBeforeFullscreen;\n const fallbackActionId = this.activeHeader().actions?.[0]?.id;\n requestAnimationFrame(async () => {\n if (focusBeforeFullscreen?.isConnected) {\n focusBeforeFullscreen.focus({ preventScroll: true });\n }\n const focusRestored = focusBeforeFullscreen\n ? document.activeElement === focusBeforeFullscreen ||\n focusBeforeFullscreen.contains(document.activeElement)\n : false;\n if (!focusRestored && fallbackActionId) await this.focusHeaderAction(fallbackActionId);\n this.focusBeforeFullscreen = null;\n });\n }\n this.dsPresentationChange.emit({ presentation: next });\n }\n\n @Listen('keydown', { target: 'window', capture: true })\n handleFullscreenEscape(event: KeyboardEvent) {\n if (this.presentation !== 'fullscreen' || event.key !== 'Escape') return;\n event.preventDefault();\n event.stopPropagation();\n this.presentation = 'drawer';\n }\n\n private deferMotionEnable() {\n if (this.readyForMotion) return;\n const generation = ++this.motionEnableGeneration;\n requestAnimationFrame(() => {\n if (generation !== this.motionEnableGeneration) return;\n requestAnimationFrame(() => {\n if (generation !== this.motionEnableGeneration) return;\n this.readyForMotion = true;\n });\n });\n }\n\n connectedCallback() {\n this.el.addEventListener('transitionend', this.handleTransitionEnd);\n this.el.addEventListener('transitioncancel', this.handleTransitionEnd);\n this.el.addEventListener('dsAction', this.handleComposedHeaderAction);\n }\n\n private handleComposedHeaderAction = (event: Event) => {\n const detail = (event as CustomEvent<{ id?: string }>).detail;\n if (!this.activeTool || !detail?.id) return;\n event.stopPropagation();\n this.dsHeaderAction.emit({ tool: this.activeTool, id: detail.id });\n };\n\n componentWillLoad() {\n this.restoreLastActiveTool();\n this.drawerSurfaceRetained = this.open;\n }\n\n @Watch('open')\n openChanged(isOpen: boolean, wasOpen?: boolean) {\n if (isOpen) this.drawerSurfaceRetained = true;\n if (this.readyForMotion && wasOpen !== undefined && wasOpen !== isOpen) {\n this.startDrawerTransition(isOpen ? 'opening' : 'closing');\n } else if (!isOpen) {\n this.drawerSurfaceRetained = false;\n }\n this.deferMotionEnable();\n }\n\n @Watch('activeTool')\n activeToolChanged() {\n this.persistLastActiveTool();\n this.deferMotionEnable();\n }\n\n @Watch('storageKey')\n storageKeyChanged() {\n this.restoreLastActiveTool();\n }\n\n componentDidLoad() {\n this.deferMotionEnable();\n this.connectRailBodyObserver();\n this.updateRailBodyScrollable();\n this.syncFullViewTools();\n this.fullViewObserver = new MutationObserver(() => this.syncFullViewTools());\n this.fullViewObserver.observe(this.el, {\n childList: true,\n attributes: true,\n attributeFilter: ['slot'],\n });\n }\n\n componentDidRender() {\n this.updateRailBodyScrollable();\n }\n\n private connectRailBodyObserver() {\n this.disconnectRailBodyObserver();\n if (!this.railBodyEl || typeof ResizeObserver === 'undefined') return;\n this.railBodyObserver = new ResizeObserver(() => this.updateRailBodyScrollable());\n this.railBodyObserver.observe(this.railBodyEl);\n }\n\n private disconnectRailBodyObserver() {\n this.railBodyObserver?.disconnect();\n this.railBodyObserver = undefined;\n }\n\n private updateRailBodyScrollable() {\n const next = Boolean(\n this.railBodyEl && this.railBodyEl.scrollHeight > this.railBodyEl.clientHeight + 1\n );\n if (next !== this.railBodyScrollable) this.railBodyScrollable = next;\n }\n\n @Watch('items')\n itemsChanged() {\n this.rovingIndex = 0;\n this.reconcileActiveTool();\n this.deferMotionEnable();\n }\n\n private restoreLastActiveTool() {\n if (!this.storageKey || this.activeTool) return;\n try {\n const stored = localStorage.getItem(this.storageKey);\n if (isPanelToolsToolId(stored)) this.activeTool = stored;\n } catch {\n /* localStorage unavailable */\n }\n }\n\n private persistLastActiveTool() {\n if (!this.storageKey || !this.activeTool) return;\n try {\n localStorage.setItem(this.storageKey, this.activeTool);\n } catch {\n /* unavailable */\n }\n }\n\n private clearPersistedTool() {\n if (!this.storageKey) return;\n try {\n localStorage.removeItem(this.storageKey);\n } catch {\n /* unavailable */\n }\n }\n\n private reconcileActiveTool() {\n const next = reconcilePanelToolsAvailability(this.railItems, this.open, this.activeTool);\n if (!next.removedTool) return;\n this.clearPersistedTool();\n this.open = next.open;\n this.activeTool = next.activeTool;\n this.dsToolChange.emit({ id: next.removedTool, selected: false });\n }\n\n private startDrawerTransition(phase: 'opening' | 'closing') {\n const drawer = this.renderRoot.querySelector('.panel-tools__drawer') as HTMLElement | null;\n if (this.motion !== 'idle') {\n this.finishDrawerTransition();\n this.ignoreReplacementTerminal = true;\n }\n this.motion = phase;\n this.dsChromeTransitionStart.emit({ source: 'panel-tools', phase });\n const generation = ++this.transitionGeneration;\n\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n if (generation !== this.transitionGeneration || this.motion !== phase) return;\n if (!drawer) {\n this.finishDrawerTransition();\n return;\n }\n\n if (this.drawerTransitionAtTerminal(drawer, phase)) {\n this.finishDrawerTransition();\n return;\n }\n\n const fallbackMs = panelToolsDrawerTransitionMs(getComputedStyle(drawer));\n if (fallbackMs <= 0) {\n this.finishDrawerTransition();\n return;\n }\n this.transitionFallbackTimer = window.setTimeout(() => {\n this.finishDrawerTransition();\n }, fallbackMs);\n });\n });\n }\n\n private handleTransitionEnd = (event: TransitionEvent) => {\n const drawer = this.renderRoot.querySelector('.panel-tools__drawer') as HTMLElement | null;\n if (event.target !== drawer) return;\n if (event.propertyName !== 'max-width') return;\n if (this.ignoreReplacementTerminal) {\n this.ignoreReplacementTerminal = false;\n return;\n }\n // Changing from the neutral transition recipe to the direction-specific\n // easing can cancel the transition that the reflected `open` class began.\n // The replacement transition and its watchdog still own completion; ending\n // motion here would hide the fixed-width tool surface while it is visible.\n if (event.type === 'transitioncancel') return;\n if (drawer && !this.drawerTransitionAtTerminal(drawer, this.motion)) return;\n this.finishDrawerTransition();\n };\n\n private drawerTransitionAtTerminal(\n drawer: HTMLElement,\n phase: 'opening' | 'closing' | 'idle'\n ): boolean {\n const width = drawer.getBoundingClientRect().width;\n const surface = drawer.querySelector('.panel-tools__drawer-surface') as HTMLElement | null;\n const targetWidth = surface?.getBoundingClientRect().width ?? 0;\n return panelToolsDrawerAtTerminal(width, targetWidth, phase);\n }\n\n private finishDrawerTransition() {\n const completedPhase = this.motion;\n const wasTransitioning = completedPhase !== 'idle';\n this.clearTransitionCompletion();\n if (!wasTransitioning) return;\n this.ignoreReplacementTerminal = false;\n this.motion = 'idle';\n if (completedPhase === 'closing' && !this.open) {\n this.drawerSurfaceRetained = false;\n }\n this.dsChromeTransitionEnd.emit({ source: 'panel-tools' });\n }\n\n private clearTransitionCompletion() {\n this.transitionGeneration += 1;\n if (this.transitionFallbackTimer !== null) {\n window.clearTimeout(this.transitionFallbackTimer);\n this.transitionFallbackTimer = null;\n }\n }\n\n /** Rail selection follows `open` immediately — independent of the slide animation. */\n private isRailSelected(id: PanelToolsToolId): boolean {\n return this.open && this.activeTool === id;\n }\n\n /** Drawer body stays mounted while the clip frame animates closed. */\n private isDrawerPresent(): boolean {\n return this.open || this.drawerSurfaceRetained;\n }\n\n private isViewActive(id: PanelToolsToolId): boolean {\n return this.isDrawerPresent() && this.activeTool === id;\n }\n\n private hasFullView(id: PanelToolsToolId): boolean {\n return this.fullViewToolIds.includes(id);\n }\n\n private syncFullViewTools() {\n const ids = this.orderedRailItems.map(item => item.id);\n const next = ids.filter(id => Boolean(this.el.querySelector(`[slot=\"${id}-view\"]`)));\n if (next.join('|') !== this.fullViewToolIds.join('|')) this.fullViewToolIds = next;\n }\n\n private get renderRoot(): ParentNode {\n return this.el.shadowRoot ?? this.el;\n }\n\n private headerLabel(): string {\n if (!this.isDrawerPresent() || !this.activeTool) return '';\n const item = this.railItems.find(candidate => candidate.id === this.activeTool);\n return (\n item?.label ??\n PANEL_TOOLS_LABELS[this.activeTool as keyof typeof PANEL_TOOLS_LABELS] ??\n this.activeTool\n );\n }\n\n private resolvedHeaders(): PanelToolsHeaders {\n return this.headers ?? {};\n }\n\n private activeHeader(): PanelToolsHeaderConfig {\n if (!this.activeTool) return {};\n return this.resolvedHeaders()[this.activeTool] ?? {};\n }\n\n private handleToolChange = (id: PanelToolsToolId) => {\n const next = resolvePanelToolActivation(this.open, this.activeTool, id);\n this.open = next.open;\n this.activeTool = next.activeTool;\n this.dsToolChange.emit({ id, selected: next.selected });\n };\n\n /** Toggle any rail tool open/closed — shell shortcuts K/A/S/M/N call this. */\n @Method()\n async activateTool(id: PanelToolsToolId) {\n const item = this.railItems.find(entry => entry.id === id);\n if (!item || item.isInactive) return;\n this.handleToolChange(id);\n }\n\n /** Close the tools drawer when open — used by shell keyboard shortcuts. */\n @Method()\n async closeDrawer() {\n if (!this.open) return;\n const id = this.activeTool;\n this.open = false;\n if (id) {\n this.dsToolChange.emit({ id, selected: false });\n }\n }\n\n /** Focus an active tool-header action by its application-owned id. */\n @Method()\n async focusHeaderAction(id: string) {\n const action = this.renderRoot.querySelector(`[data-header-action-id=\"${CSS.escape(id)}\"]`) as\n | (HTMLElement & { setFocus?: () => Promise<void> })\n | null;\n await action?.setFocus?.();\n }\n\n private focusRailAt(index: number) {\n const items = this.orderedRailItems;\n if (!items.length) return;\n const bounded = Math.max(0, Math.min(index, items.length - 1));\n if (bounded === this.rovingIndex) return;\n this.rovingIndex = bounded;\n const actions = Array.from(\n this.renderRoot.querySelectorAll<HTMLElement>('.panel-tools__rail-action .button-icon')\n );\n actions[bounded]?.focus({ preventScroll: true });\n }\n\n private handleRailKeyDown = (e: KeyboardEvent, index: number) => {\n const items = this.orderedRailItems;\n if (!items.length) return;\n\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.handleToolChange(items[index].id);\n return;\n }\n\n if (e.key === 'ArrowDown') {\n if (index >= items.length - 1) return;\n e.preventDefault();\n this.focusRailAt(index + 1);\n return;\n }\n\n if (e.key === 'ArrowUp') {\n if (index <= 0) return;\n e.preventDefault();\n this.focusRailAt(index - 1);\n }\n };\n\n private renderRailAction(item: PanelToolsItem, index: number) {\n const label =\n item.ariaLabel ??\n item.label ??\n PANEL_TOOLS_LABELS[item.id as keyof typeof PANEL_TOOLS_LABELS] ??\n item.id;\n return (\n <ds-tooltip\n key={item.id}\n label={label}\n shortcutKey={\n item.shortcutKey ??\n PANEL_TOOLS_SHORTCUTS[item.id as keyof typeof PANEL_TOOLS_SHORTCUTS]\n }\n side=\"left\"\n size=\"sm\"\n >\n <ds-button-unfilled\n variant=\"icon\"\n class=\"panel-tools__rail-action\"\n icon={item.icon}\n isActive={this.isRailSelected(item.id)}\n activeFill={false}\n hasBorder={false}\n pressScale={false}\n dot={item.dot ?? false}\n isInactive={item.isInactive}\n focusTabIndex={index === this.rovingIndex ? 0 : -1}\n aria-label={label}\n pressed={this.isRailSelected(item.id)}\n onFocusin={() => {\n this.rovingIndex = index;\n }}\n onKeyDown={(e: KeyboardEvent) => this.handleRailKeyDown(e, index)}\n onDsClick={() => this.handleToolChange(item.id)}\n />\n </ds-tooltip>\n );\n }\n\n render() {\n const headerLabel = this.headerLabel();\n const header = this.activeHeader();\n const headerTitle = header.title?.trim() || headerLabel;\n const headerActions = header.actions ?? [];\n const orderedRailItems = this.orderedRailItems;\n const headerItems = orderedRailItems.filter(\n item => panelToolsRailPlacement(item) === 'header'\n );\n const bodyItems = orderedRailItems.filter(\n item => panelToolsRailPlacement(item) === 'body'\n );\n const footerItems = orderedRailItems.filter(\n item => panelToolsRailPlacement(item) === 'footer'\n );\n const headerCount = headerItems.length;\n const footerStart = headerCount + bodyItems.length;\n const showDrawerChrome = this.isDrawerPresent();\n const drawerResting = panelToolsDrawerResting(this.open, this.motion);\n const activeFullView = Boolean(\n showDrawerChrome && this.activeTool && this.hasFullView(this.activeTool)\n );\n const toolIds = orderedRailItems.map(item => item.id);\n\n return (\n <Host\n class={{\n 'panel-tools': true,\n 'panel-tools--open': this.open,\n 'panel-tools--ready': this.readyForMotion,\n 'panel-tools--motion-opening': this.motion === 'opening',\n 'panel-tools--motion-closing': this.motion === 'closing',\n 'panel-tools--drawer-resting': drawerResting,\n 'panel-tools--fullscreen': this.presentation === 'fullscreen',\n 'panel-tools--presentation-snap': this.presentationMotionSuppressed,\n }}\n role={this.presentation === 'fullscreen' ? 'dialog' : 'complementary'}\n aria-modal={this.presentation === 'fullscreen' ? 'true' : undefined}\n aria-label={this.toolsLabel}\n >\n <div class=\"panel-tools__layout\">\n <nav class=\"panel-tools__rail\" aria-label={this.toolShortcutsLabel}>\n {headerItems.length ? (\n <div class=\"panel-tools__rail-header ds-chrome-row ds-chrome-space--md\">\n {headerItems.map((item, index) => this.renderRailAction(item, index))}\n </div>\n ) : null}\n <div\n class={{\n 'panel-tools__rail-body': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--md': true,\n 'ds-scrollbar-hidden': true,\n 'ds-focus-ring': this.railBodyScrollable,\n }}\n ref={element => {\n const next = (element as HTMLElement) ?? undefined;\n if (next === this.railBodyEl) return;\n this.railBodyEl = next;\n this.connectRailBodyObserver();\n }}\n role={this.railBodyScrollable ? 'region' : undefined}\n aria-label={this.railBodyScrollable ? this.toolShortcutsLabel : undefined}\n tabIndex={this.railBodyScrollable ? 0 : undefined}\n >\n <div class=\"panel-tools__rail-actions\">\n {bodyItems.map((item, bodyIdx) =>\n this.renderRailAction(item, headerCount + bodyIdx)\n )}\n </div>\n </div>\n {footerItems.length ? (\n <div class=\"panel-tools__rail-footer ds-chrome-row ds-chrome-space--md\">\n {footerItems.map((item, footerIdx) =>\n this.renderRailAction(item, footerStart + footerIdx)\n )}\n </div>\n ) : null}\n </nav>\n\n <div\n class={{\n 'panel-tools__drawer': true,\n 'panel-tools__drawer--visible': showDrawerChrome,\n }}\n aria-hidden={showDrawerChrome ? null : 'true'}\n inert={showDrawerChrome ? undefined : true}\n >\n <div class=\"panel-tools__drawer-surface\">\n {this.presentation !== 'fullscreen' || this.fullscreenHeaderMode === 'shared' ? (\n <ds-panel-tool-header\n class=\"panel-tools__header\"\n heading={headerTitle}\n showBack={header.showBack ?? false}\n backIcon={header.backIcon || 'ChevronLeft'}\n backAriaLabel={header.backAriaLabel || 'Back'}\n showMenu={false}\n ref={element => {\n const toolHeader = element as\n | (HTMLDsPanelToolHeaderElement & {\n actions: PanelToolsHeaderAction[];\n })\n | undefined;\n if (toolHeader) toolHeader.actions = headerActions;\n }}\n onDsBack={() => {\n if (this.activeTool) this.dsHeaderBack.emit({ tool: this.activeTool });\n }}\n />\n ) : null}\n <div class=\"panel-tools__body\" hidden={activeFullView}>\n {toolIds.map(id => {\n const active = this.isViewActive(id);\n return (\n <div\n class={{\n 'panel-tools__view': true,\n 'panel-tools__view--active': active,\n }}\n hidden={!active}\n >\n <slot name={id} />\n </div>\n );\n })}\n </div>\n <div class=\"panel-tools__full-views\" hidden={!activeFullView}>\n {toolIds.map(id => (\n <div\n class={{\n 'panel-tools__full-view': true,\n 'panel-tools__full-view--active':\n this.isViewActive(id) && this.hasFullView(id),\n }}\n hidden={!(this.isViewActive(id) && this.hasFullView(id))}\n >\n <slot name={`${id}-view`} />\n </div>\n ))}\n </div>\n </div>\n </div>\n </div>\n </Host>\n );\n }\n}\n",
467
467
  "type": "registry:ui"
468
468
  },
469
469
  {