@ds-mo/ui 13.17.0 → 13.18.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 (220) hide show
  1. package/dist/.package-ready.json +1 -1
  2. package/dist/agent-patterns.json +1 -1
  3. package/dist/agent.json +264 -13
  4. package/dist/angular/ds-filter-menu.d.ts +44 -0
  5. package/dist/angular/ds-filter-menu.js +57 -0
  6. package/dist/angular/ds-select.d.ts +12 -1
  7. package/dist/angular/ds-select.js +10 -4
  8. package/dist/angular/index.d.ts +1 -1
  9. package/dist/angular/index.js +1 -0
  10. package/dist/angular/proxies.d.ts +1 -0
  11. package/dist/angular/proxies.js +1 -0
  12. package/dist/components/ds-agent-activity.js +1 -1
  13. package/dist/components/ds-agent-questionnaire.js +1 -1
  14. package/dist/components/ds-agent-response.js +1 -1
  15. package/dist/components/ds-agent-source-list.js +1 -1
  16. package/dist/components/ds-agent-tool-call.js +1 -1
  17. package/dist/components/ds-attachment-list.js +1 -1
  18. package/dist/components/ds-badge.js +1 -1
  19. package/dist/components/ds-banner.js +1 -1
  20. package/dist/components/ds-bar-nav.js +1 -1
  21. package/dist/components/ds-bar-title.js +1 -1
  22. package/dist/components/ds-bar-workflow.js +1 -1
  23. package/dist/components/ds-bar-workflow.js.map +1 -1
  24. package/dist/components/ds-breadcrumb.js +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-overview.js +1 -1
  29. package/dist/components/ds-card-overview.js.map +1 -1
  30. package/dist/components/ds-card-setting.js +1 -1
  31. package/dist/components/ds-chart-legend.js +1 -1
  32. package/dist/components/ds-chart.js +1 -1
  33. package/dist/components/ds-checkbox.js +1 -1
  34. package/dist/components/ds-chip.js +1 -1
  35. package/dist/components/ds-code-block.js +1 -1
  36. package/dist/components/ds-conversation-list-item.js +1 -1
  37. package/dist/components/ds-conversation-list-item.js.map +1 -1
  38. package/dist/components/ds-conversation-list-section.js +1 -1
  39. package/dist/components/ds-empty-state.js +1 -1
  40. package/dist/components/ds-field.js +1 -1
  41. package/dist/components/ds-filter-menu.d.ts +11 -0
  42. package/dist/components/ds-filter-menu.js +2 -0
  43. package/dist/components/ds-filter-menu.js.map +1 -0
  44. package/dist/components/ds-input.js +1 -1
  45. package/dist/components/ds-input.js.map +1 -1
  46. package/dist/components/ds-markdown.js +1 -1
  47. package/dist/components/ds-menu.js +1 -1
  48. package/dist/components/ds-message-actions.js +1 -1
  49. package/dist/components/ds-message-bubble.js +1 -1
  50. package/dist/components/ds-message-composer.js +1 -1
  51. package/dist/components/ds-message-scroller.js +1 -1
  52. package/dist/components/ds-message.js +1 -1
  53. package/dist/components/ds-mobile-bar-nav.js +1 -1
  54. package/dist/components/ds-mobile-header.js +1 -1
  55. package/dist/components/ds-mobile-section-switcher.js +1 -1
  56. package/dist/components/ds-mobile-sheet-nav.js +1 -1
  57. package/dist/components/ds-modal.js +1 -1
  58. package/dist/components/ds-modal.js.map +1 -1
  59. package/dist/components/ds-panel-nav.js +1 -1
  60. package/dist/components/ds-panel-sub-nav.js +1 -1
  61. package/dist/components/ds-panel-tool-header.js +1 -1
  62. package/dist/components/ds-panel-tool-search.js +1 -1
  63. package/dist/components/ds-panel-tools.js +1 -1
  64. package/dist/components/ds-radio.js +1 -1
  65. package/dist/components/ds-scroll-overlay.js +1 -1
  66. package/dist/components/ds-select.js +1 -1
  67. package/dist/components/ds-select.js.map +1 -1
  68. package/dist/components/ds-shell-app.js +1 -1
  69. package/dist/components/ds-shell-app.js.map +1 -1
  70. package/dist/components/ds-shell-page.js +1 -1
  71. package/dist/components/ds-shell-tools.js +1 -1
  72. package/dist/components/ds-skeleton.js +1 -1
  73. package/dist/components/ds-slider.js +1 -1
  74. package/dist/components/ds-swatch-picker.js +1 -1
  75. package/dist/components/ds-switch.js +1 -1
  76. package/dist/components/ds-tab-group.js +1 -1
  77. package/dist/components/ds-table.js +1 -1
  78. package/dist/components/ds-table.js.map +1 -1
  79. package/dist/components/ds-tag.js +1 -1
  80. package/dist/components/ds-text.js +1 -1
  81. package/dist/components/ds-toast.js +1 -1
  82. package/dist/components/ds-tooltip-chart.js +1 -1
  83. package/dist/components/ds-tooltip.js +1 -1
  84. package/dist/components/ds-typing-indicator.js +1 -1
  85. package/dist/components/p-5uI3i2vg.js +2 -0
  86. package/dist/components/p-5uI3i2vg.js.map +1 -0
  87. package/dist/components/p-BAgjMUt3.js +2 -0
  88. package/dist/components/p-BAgjMUt3.js.map +1 -0
  89. package/dist/components/{p-CdKz-QT2.js → p-BCfgU8Fh.js} +2 -2
  90. package/dist/components/{p-CsXC1cHI.js → p-BDsAON4I.js} +2 -2
  91. package/dist/components/p-BMq4DJWh.js +2 -0
  92. package/dist/components/{p-D9FdOR_m.js.map → p-BMq4DJWh.js.map} +1 -1
  93. package/dist/components/{p-GWUTrIlk.js → p-BOCrvs-v.js} +2 -2
  94. package/dist/components/{p-B69Re7HW.js → p-BOjGzd5N.js} +2 -2
  95. package/dist/components/{p-BAAjiAIF.js → p-BQkZ8FIl.js} +2 -2
  96. package/dist/components/p-BaiGt_o6.js +2 -0
  97. package/dist/components/{p-D4jQy3Ps.js.map → p-BaiGt_o6.js.map} +1 -1
  98. package/dist/components/p-BqFKxWVl.js +2 -0
  99. package/dist/components/p-BqFKxWVl.js.map +1 -0
  100. package/dist/components/p-BtY2nJW4.js +2 -0
  101. package/dist/components/{p-m5CSrx_s.js.map → p-BtY2nJW4.js.map} +1 -1
  102. package/dist/components/{p-DStd98dM.js → p-BxQlH4gl.js} +2 -2
  103. package/dist/components/{p-DStd98dM.js.map → p-BxQlH4gl.js.map} +1 -1
  104. package/dist/components/{p-00or0yGw.js → p-C7ywYAzN.js} +2 -2
  105. package/dist/components/p-C8keZ4k6.js +2 -0
  106. package/dist/components/{p-BMAZWEDa.js.map → p-C8keZ4k6.js.map} +1 -1
  107. package/dist/components/p-CB8iBV_M.js +2 -0
  108. package/dist/components/p-CB8iBV_M.js.map +1 -0
  109. package/dist/components/p-CP2W8qxB.js +2 -0
  110. package/dist/components/{p-D5sIwLjA.js.map → p-CP2W8qxB.js.map} +1 -1
  111. package/dist/components/p-CUQTEeLp.js +2 -0
  112. package/dist/components/{p-B94M62Tx.js.map → p-CUQTEeLp.js.map} +1 -1
  113. package/dist/components/{p-D4j0deyd.js → p-C_tqXl5P.js} +2 -2
  114. package/dist/components/p-Csq6XDgm.js +2 -0
  115. package/dist/components/{p-D9CDoQBx.js.map → p-Csq6XDgm.js.map} +1 -1
  116. package/dist/components/p-CswBI711.js +2 -0
  117. package/dist/components/{p-CnUq7E8S.js.map → p-CswBI711.js.map} +1 -1
  118. package/dist/components/p-CtSxm6PI.js +2 -0
  119. package/dist/components/{p-DyqLVdJp.js.map → p-CtSxm6PI.js.map} +1 -1
  120. package/dist/components/{p-dEouiXJ8.js → p-D8nnhQNJ.js} +2 -2
  121. package/dist/components/p-DTMxwYQj.js +2 -0
  122. package/dist/components/{p-B2SoinWf.js.map → p-DTMxwYQj.js.map} +1 -1
  123. package/dist/components/{p-C891ueIx.js → p-DYoCb_sj.js} +2 -2
  124. package/dist/components/{p-Dhx_3_tf.js → p-DZFilC--.js} +2 -2
  125. package/dist/components/p-DZFilC--.js.map +1 -0
  126. package/dist/components/{p-GbTasczF.js → p-Dcnd5ZX5.js} +2 -2
  127. package/dist/components/{p-BRze4tRe.js → p-DeBto13S.js} +2 -2
  128. package/dist/components/p-DtvJTPds.js +2 -0
  129. package/dist/components/{p-B53Vpuqq.js.map → p-DtvJTPds.js.map} +1 -1
  130. package/dist/components/{p-DrvEhJhO.js → p-N4m0evkj.js} +2 -2
  131. package/dist/components/p-NdahPypA.js +2 -0
  132. package/dist/components/p-NdahPypA.js.map +1 -0
  133. package/dist/components/{p-D4AfoIu6.js → p-NuRg_IoA.js} +2 -2
  134. package/dist/components/{p-CqDJRVT8.js → p-NzOcZg8r.js} +2 -2
  135. package/dist/components/p-PzI11fs8.js +2 -0
  136. package/dist/components/p-PzI11fs8.js.map +1 -0
  137. package/dist/components/{p-Cohb8T7G.js → p-QdI17bl-.js} +2 -2
  138. package/dist/components/{p-chA2WA5x.js → p-Qzh-wyTg.js} +2 -2
  139. package/dist/components/{p-CnIvWEVm.js → p-Xr9OO-Ao.js} +2 -2
  140. package/dist/components/{p-Qf-os3lG.js → p-oyZjjoYV.js} +2 -2
  141. package/dist/components/{p-Dbobj7DI.js → p-smzwm_EW.js} +2 -2
  142. package/dist/components/p-tR8INOIN.js +2 -0
  143. package/dist/components/p-tR8INOIN.js.map +1 -0
  144. package/dist/docs/components.json +1299 -93
  145. package/dist/lib/utils/choice-list.d.ts +10 -0
  146. package/dist/lib/utils/index.js +4 -1
  147. package/dist/lib/utils/index.js.map +2 -2
  148. package/dist/lib/utils/token-defaults.d.ts +2 -0
  149. package/dist/mcp-data/registry/agent-source-list.json +2 -2
  150. package/dist/mcp-data/registry/bar-nav.json +2 -2
  151. package/dist/mcp-data/registry/breadcrumb.json +2 -2
  152. package/dist/mcp-data/registry/filter-menu.json +643 -0
  153. package/dist/mcp-data/registry/menu.json +11 -8
  154. package/dist/mcp-data/registry/modal.json +3 -3
  155. package/dist/mcp-data/registry/registry.json +266 -12
  156. package/dist/mcp-data/registry/select.json +131 -7
  157. package/dist/mcp-data/registry/table.json +2 -2
  158. package/dist/mcp-data/registry/text.json +2 -2
  159. package/dist/react/components.d.ts +1 -0
  160. package/dist/react/components.js +1 -0
  161. package/dist/react/ds-filter-menu.d.ts +17 -0
  162. package/dist/react/ds-filter-menu.js +19 -0
  163. package/dist/react/ds-select.d.ts +3 -1
  164. package/dist/react/ds-select.js +3 -1
  165. package/dist/styles/prose.css +19 -3
  166. package/dist/styles/table.css +1 -3
  167. package/dist/types/components/FilterMenu/FilterMenu.d.ts +137 -0
  168. package/dist/types/components/Menu/Menu.d.ts +1 -1
  169. package/dist/types/components/Menu/menu-types.d.ts +9 -0
  170. package/dist/types/components/Modal/Modal.d.ts +1 -0
  171. package/dist/types/components/Select/Select.d.ts +34 -0
  172. package/dist/types/components.d.ts +368 -2
  173. package/dist/types/utils/choice-list-parts.d.ts +17 -0
  174. package/dist/types/utils/choice-list.d.ts +10 -0
  175. package/dist/types/utils/select-controller.d.ts +2 -0
  176. package/dist/types/utils/token-defaults.d.ts +2 -0
  177. package/package.json +2 -1
  178. package/dist/components/p-B2SoinWf.js +0 -2
  179. package/dist/components/p-B53Vpuqq.js +0 -2
  180. package/dist/components/p-B94M62Tx.js +0 -2
  181. package/dist/components/p-BMAZWEDa.js +0 -2
  182. package/dist/components/p-BtUgncSJ.js +0 -2
  183. package/dist/components/p-BtUgncSJ.js.map +0 -1
  184. package/dist/components/p-CMbcRN92.js +0 -2
  185. package/dist/components/p-CMbcRN92.js.map +0 -1
  186. package/dist/components/p-CXduurfd.js +0 -2
  187. package/dist/components/p-CXduurfd.js.map +0 -1
  188. package/dist/components/p-CnUq7E8S.js +0 -2
  189. package/dist/components/p-D4jQy3Ps.js +0 -2
  190. package/dist/components/p-D5sIwLjA.js +0 -2
  191. package/dist/components/p-D9CDoQBx.js +0 -2
  192. package/dist/components/p-D9FdOR_m.js +0 -2
  193. package/dist/components/p-DO5-fplZ.js +0 -2
  194. package/dist/components/p-DO5-fplZ.js.map +0 -1
  195. package/dist/components/p-Dhx_3_tf.js.map +0 -1
  196. package/dist/components/p-DyqLVdJp.js +0 -2
  197. package/dist/components/p-NQOI5gN1.js +0 -2
  198. package/dist/components/p-NQOI5gN1.js.map +0 -1
  199. package/dist/components/p-m5CSrx_s.js +0 -2
  200. package/dist/components/p-ymtdhOU5.js +0 -2
  201. package/dist/components/p-ymtdhOU5.js.map +0 -1
  202. /package/dist/components/{p-CdKz-QT2.js.map → p-BCfgU8Fh.js.map} +0 -0
  203. /package/dist/components/{p-CsXC1cHI.js.map → p-BDsAON4I.js.map} +0 -0
  204. /package/dist/components/{p-GWUTrIlk.js.map → p-BOCrvs-v.js.map} +0 -0
  205. /package/dist/components/{p-B69Re7HW.js.map → p-BOjGzd5N.js.map} +0 -0
  206. /package/dist/components/{p-BAAjiAIF.js.map → p-BQkZ8FIl.js.map} +0 -0
  207. /package/dist/components/{p-00or0yGw.js.map → p-C7ywYAzN.js.map} +0 -0
  208. /package/dist/components/{p-D4j0deyd.js.map → p-C_tqXl5P.js.map} +0 -0
  209. /package/dist/components/{p-dEouiXJ8.js.map → p-D8nnhQNJ.js.map} +0 -0
  210. /package/dist/components/{p-C891ueIx.js.map → p-DYoCb_sj.js.map} +0 -0
  211. /package/dist/components/{p-GbTasczF.js.map → p-Dcnd5ZX5.js.map} +0 -0
  212. /package/dist/components/{p-BRze4tRe.js.map → p-DeBto13S.js.map} +0 -0
  213. /package/dist/components/{p-DrvEhJhO.js.map → p-N4m0evkj.js.map} +0 -0
  214. /package/dist/components/{p-D4AfoIu6.js.map → p-NuRg_IoA.js.map} +0 -0
  215. /package/dist/components/{p-CqDJRVT8.js.map → p-NzOcZg8r.js.map} +0 -0
  216. /package/dist/components/{p-Cohb8T7G.js.map → p-QdI17bl-.js.map} +0 -0
  217. /package/dist/components/{p-chA2WA5x.js.map → p-Qzh-wyTg.js.map} +0 -0
  218. /package/dist/components/{p-CnIvWEVm.js.map → p-Xr9OO-Ao.js.map} +0 -0
  219. /package/dist/components/{p-Qf-os3lG.js.map → p-oyZjjoYV.js.map} +0 -0
  220. /package/dist/components/{p-Dbobj7DI.js.map → p-smzwm_EW.js.map} +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "menu",
4
4
  "title": "Menu",
5
- "description": "Anchored action menu with flat commands, grouped sections, single-selection choices, supplemental notification dots, switches, and destructive actions.",
5
+ "description": "Anchored action menu with flat commands, grouped sections, single-selection choices, supplemental notification dots or trailing tags, switches, and destructive actions.",
6
6
  "type": "registry:ui",
7
7
  "meta": {
8
8
  "library": "@ds-mo/ui",
@@ -13,10 +13,10 @@
13
13
  "intent": {
14
14
  "audience": "general",
15
15
  "status": "stable",
16
- "summary": "Anchored action menu with flat commands, grouped sections, single-selection choices, supplemental notification dots, switches, and destructive actions.",
16
+ "summary": "Anchored action menu with flat commands, grouped sections, single-selection choices, supplemental notification dots or trailing tags, switches, and destructive actions.",
17
17
  "useWhen": [
18
18
  "A trigger reveals a short contextual list of commands or state-changing actions.",
19
- "Actions need grouping, mutually exclusive selected state, supplemental notification dots, switch rows, or destructive treatment."
19
+ "Actions need grouping, mutually exclusive selected state, supplemental notification dots or trailing tags, switch rows, or destructive treatment."
20
20
  ],
21
21
  "avoidWhen": [
22
22
  "The user is selecting a value for a form field.",
@@ -56,7 +56,8 @@
56
56
  "Switch rows use menuitemcheckbox semantics on the row; the nested ds-switch is an aria-hidden presentation indicator at the same density as the menu.",
57
57
  "Single-selection menus use menuitemradio semantics and aria-checked on each row, but render no nested radio control; selected interaction fill and primary foreground provide the visual state.",
58
58
  "A swatch-picker section changes the popup to a non-modal dialog so its radio group and ordinary preference buttons keep valid nested semantics; Tab moves between its controls, then leaves and closes at the popup boundary.",
59
- "Use notification dots only as supplemental status and not as the sole indication of important information."
59
+ "Use notification dots only as supplemental status and not as the sole indication of important information.",
60
+ "Trailing tags are non-interactive supplemental content; their visible label remains part of the menu item's accessible name."
60
61
  ],
61
62
  "states": [
62
63
  "The host owns open state and responds to selection and close events.",
@@ -65,7 +66,8 @@
65
66
  "For selectionMode single, keep exactly one item isSelected and replace the items or sections array after dsSelect; Menu never mutates consumer-owned selection data.",
66
67
  "Arrays, objects, and element anchors must be assigned as JavaScript properties.",
67
68
  "Use a swatch-picker section for a small curated visual preference and apply dsSwatchSelect immediately without closing the popup.",
68
- "Ordinary rows, section headings, and supporting text consume the Menu size. At xs, both the primary label and supporting text use caption metrics."
69
+ "Ordinary rows, section headings, and supporting text consume the Menu size. At xs, both the primary label and supporting text use caption metrics.",
70
+ "A trailing tag resolves one density smaller than its menu row so it nests without changing row height. Do not combine tag, dot, and switch suffixes on one item."
69
71
  ],
70
72
  "responsiveBehavior": [
71
73
  "Keep the default choice-cell anchor alignment so the first or last interactive row edge aligns with its trigger, matching Select; use popup-frame only for deliberate custom frame geometry.",
@@ -468,17 +470,18 @@
468
470
  "badge",
469
471
  "swatch-picker",
470
472
  "switch",
473
+ "tag",
471
474
  "text"
472
475
  ],
473
476
  "files": [
474
477
  {
475
478
  "path": "src/wc/components/Menu/Menu.css",
476
- "content": "@import '../../utils/choice-list.css';\n\n/* Inactive: `.ds-control-inactive` (50% opacity). */\n\n.menu-item--destructive .menu-item__label {\n /* Beat ds-text color-* host classes for destructive rows. */\n color: var(--color-foreground-bold-negative) !important;\n}\n\n/* Standard md icon zone keeps the dot aligned with other control suffixes. */\n.menu-item__dot-box {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--ds-control-icon, var(--dimension-iconography-md));\n height: var(--ds-control-icon, var(--dimension-iconography-md));\n pointer-events: none;\n}\n\n/* ── Switch suffix ───────────────────────────────────────────────────────── */\n\n.menu-item__switch {\n flex-shrink: 0;\n}\n",
479
+ "content": "@import '../../utils/choice-list.css';\n\n/* Inactive: `.ds-control-inactive` (50% opacity). */\n\n.menu-item--destructive .menu-item__label {\n /* Beat ds-text color-* host classes for destructive rows. */\n color: var(--color-foreground-bold-negative) !important;\n}\n\n/* Standard md icon zone keeps the dot aligned with other control suffixes. */\n.menu-item__dot-box {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--ds-control-icon, var(--dimension-iconography-md));\n height: var(--ds-control-icon, var(--dimension-iconography-md));\n pointer-events: none;\n}\n\n/* ── Switch suffix ───────────────────────────────────────────────────────── */\n\n.menu-item--switch {\n gap: var(--dimension-space-100);\n}\n\n.menu-item__switch {\n flex-shrink: 0;\n}\n",
477
480
  "type": "registry:ui"
478
481
  },
479
482
  {
480
483
  "path": "src/wc/components/Menu/Menu.tsx",
481
- "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 type { MenuItemData, MenuSection } from './menu-types';\nimport { snapshotMenuSections } from './menu-sections';\n\nexport type MenuSelectionMode = 'none' | 'single';\nexport type MenuSize = ControlSize;\nimport {\n isMenuPickerSection,\n isMenuSwatchPickerSection,\n} from './menu-types';\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\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\n private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | 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.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.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.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 this.position.schedule(onReady);\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);\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 const flat = this.flatItems;\n const enabled = flat\n .map((it, i) => ({ it, i }))\n .filter(({ it }) => !it.isInactive)\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 (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 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\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 }}\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 {section.header}\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 => {\n const idx = flatIdx++;\n const isFocused = this.focusedIndex === idx;\n const isSingleSelectionItem =\n !hasCompositeSections && this.selectionMode === 'single' && !item.showSwitch;\n return (\n <button\n key={idx}\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': !item.isInactive,\n 'ds-interaction-fill--selected': !!item.isSelected && !item.isInactive,\n 'menu-item--selected': !!item.isSelected,\n 'ds-control-inactive': !!item.isInactive,\n 'menu-item--destructive': !!item.isDestructive,\n 'menu-item--focused': isFocused,\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 disabled={item.isInactive}\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 <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.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 presentation\n />\n )}\n </button>\n );\n })\n )}\n </div>\n ))}\n </div>\n </div>\n </Host>\n );\n }\n}\n",
484
+ "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 MenuSection,\n} from './menu-types';\nimport { snapshotMenuSections } from './menu-sections';\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\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\n private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | 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.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.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.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 this.position.schedule(onReady);\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);\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 const flat = this.flatItems;\n const enabled = flat\n .map((it, i) => ({ it, i }))\n .filter(({ it }) => !it.isInactive)\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 (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 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\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 }}\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 => {\n const idx = flatIdx++;\n const isFocused = this.focusedIndex === idx;\n const isSingleSelectionItem =\n !hasCompositeSections && this.selectionMode === 'single' && !item.showSwitch;\n return (\n <button\n key={idx}\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': !item.isInactive,\n 'ds-interaction-fill--selected': !!item.isSelected && !item.isInactive,\n 'menu-item--selected': !!item.isSelected,\n 'menu-item--switch': !!item.showSwitch,\n 'ds-control-inactive': !!item.isInactive,\n 'menu-item--destructive': !!item.isDestructive,\n 'menu-item--focused': isFocused,\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 disabled={item.isInactive}\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 <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 />\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 presentation\n />\n )}\n </button>\n );\n })\n )}\n </div>\n ))}\n </div>\n </div>\n </Host>\n );\n }\n}\n",
482
485
  "type": "registry:ui"
483
486
  },
484
487
  {
@@ -498,7 +501,7 @@
498
501
  },
499
502
  {
500
503
  "path": "src/wc/components/Menu/menu-types.ts",
501
- "content": "import type { SwatchPickerOption, SwatchPickerSection } from '../SwatchPicker/swatch-picker-types';\n\nexport type { MenuAlign, MenuSide } from './menu-position';\nexport {\n PANEL_NAV_USER_MENU_PLACEMENT,\n PANEL_TOOLS_HEADER_MENU_PLACEMENT,\n type MenuPlacement,\n} from './menu-placement';\n\nexport interface MenuItemData {\n label: string;\n value?: string;\n subtext?: string;\n /** Show a supplemental brand notification dot. */\n dot?: boolean;\n isSelected?: boolean;\n isInactive?: boolean;\n isDestructive?: boolean;\n /** Render a compact switch indicator; the menu row owns interaction. */\n showSwitch?: boolean;\n switchValue?: boolean;\n}\n\nexport interface MenuItemsSection {\n header?: string;\n items: MenuItemData[];\n}\n\nexport interface MenuSwatchPickerSection {\n header?: string;\n variant: 'swatch-picker';\n value: string;\n options?: SwatchPickerOption[];\n sections?: SwatchPickerSection[];\n groupLabel?: string;\n}\n\nexport type MenuSection =\n | MenuItemsSection\n | MenuSwatchPickerSection;\n\nexport function isMenuSwatchPickerSection(\n section: MenuSection,\n): section is MenuSwatchPickerSection {\n return 'variant' in section && section.variant === 'swatch-picker';\n}\n\nexport function isMenuPickerSection(\n section: MenuSection,\n): section is MenuSwatchPickerSection {\n return isMenuSwatchPickerSection(section);\n}\n\nexport type { SwatchPickerOption, SwatchPickerSection };\n",
504
+ "content": "import type { SwatchPickerOption, SwatchPickerSection } from '../SwatchPicker/swatch-picker-types';\nimport type { TagContrast, TagIntent } from '../Tag/Tag';\n\nexport type { MenuAlign, MenuSide } from './menu-position';\nexport {\n PANEL_NAV_USER_MENU_PLACEMENT,\n PANEL_TOOLS_HEADER_MENU_PLACEMENT,\n type MenuPlacement,\n} from './menu-placement';\n\nexport interface MenuItemTagData {\n label: string;\n intent?: TagIntent;\n contrast?: TagContrast;\n rounded?: boolean;\n}\n\nexport interface MenuItemData {\n label: string;\n value?: string;\n subtext?: string;\n /** Show a supplemental brand notification dot. */\n dot?: boolean;\n /** Show a non-interactive tag at the trailing edge of the row. */\n tag?: MenuItemTagData;\n isSelected?: boolean;\n isInactive?: boolean;\n isDestructive?: boolean;\n /** Render a compact switch indicator; the menu row owns interaction. */\n showSwitch?: boolean;\n switchValue?: boolean;\n}\n\nexport interface MenuItemsSection {\n header?: string;\n items: MenuItemData[];\n}\n\nexport interface MenuSwatchPickerSection {\n header?: string;\n variant: 'swatch-picker';\n value: string;\n options?: SwatchPickerOption[];\n sections?: SwatchPickerSection[];\n groupLabel?: string;\n}\n\nexport type MenuSection =\n | MenuItemsSection\n | MenuSwatchPickerSection;\n\nexport function isMenuSwatchPickerSection(\n section: MenuSection,\n): section is MenuSwatchPickerSection {\n return 'variant' in section && section.variant === 'swatch-picker';\n}\n\nexport function isMenuPickerSection(\n section: MenuSection,\n): section is MenuSwatchPickerSection {\n return isMenuSwatchPickerSection(section);\n}\n\nexport type { SwatchPickerOption, SwatchPickerSection };\n",
502
505
  "type": "registry:ui"
503
506
  }
504
507
  ]
@@ -62,7 +62,7 @@
62
62
  ],
63
63
  "states": [
64
64
  "The owner opens the modal through the open property and responds to the typed dsClose detail for application state synchronization or dismissal analytics.",
65
- "The component mutates open to false for Close, Escape, and backdrop dismissal while retaining its rendered surface through exit motion.",
65
+ "The component mutates open to false for Close, Escape, and backdrop dismissal while retaining its rendered surface through exit motion. The close timer begins only after the closing class has rendered, so the dialog surface and backdrop enter together and exit together before the native top layer closes across browsers.",
66
66
  "The default slot owns task content; the named footer slot is optional and is hidden when no actions are assigned.",
67
67
  "The optional header description remains stacked beneath the title with the shared compact-header rhythm; longer task content belongs in the default slot.",
68
68
  "Primary actions use a bold filled button with semantic intent. Secondary and cancel actions use bordered label-only unfilled buttons.",
@@ -289,12 +289,12 @@
289
289
  "files": [
290
290
  {
291
291
  "path": "src/wc/components/Modal/Modal.css",
292
- "content": "@import '../../utils/chrome-header.css';\n@import '../../utils/chrome-layout.css';\n@import '../../utils/forced-colors.css';\n\n:host {\n display: contents;\n}\n\n.modal-dialog {\n display: none;\n flex-direction: column;\n max-width: none;\n max-height: calc(100vh - 2 * var(--dimension-space-200));\n margin: auto;\n padding: 0;\n overflow: hidden;\n color: var(--color-foreground-primary);\n background-color: var(--color-background-primary);\n border: 0;\n border-radius: var(--dimension-radius-050);\n box-shadow: var(--effect-elevation-elevated-floating);\n outline: none;\n box-sizing: border-box;\n}\n\n.modal-dialog[open] {\n display: flex;\n animation: modalDialogIn var(--effect-motion-short-2) forwards;\n}\n\n.modal-dialog::backdrop {\n background-color: var(--color-background-shade);\n animation: modalBackdropIn var(--effect-motion-short-2) forwards;\n}\n\n.modal-dialog--closing {\n animation: modalDialogOut var(--effect-motion-short-3) forwards;\n}\n\n.modal-dialog--closing::backdrop {\n animation: modalBackdropOut var(--effect-motion-short-3) forwards;\n}\n\n@keyframes modalBackdropIn {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@keyframes modalBackdropOut {\n from { opacity: 1; }\n to { opacity: 0; }\n}\n\n@keyframes modalDialogIn {\n from { opacity: 0; transform: scale(0.97) translateY(4px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n}\n\n@keyframes modalDialogOut {\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.97) translateY(4px); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .modal-dialog[open],\n .modal-dialog--closing {\n animation: none;\n transform: none;\n }\n\n .modal-dialog::backdrop {\n animation: none;\n opacity: 1;\n }\n\n .modal-dialog--closing,\n .modal-dialog--closing::backdrop {\n opacity: 0;\n }\n}\n\n.modal-header {\n align-items: flex-start;\n flex: 0 0 auto;\n color: var(--color-foreground-primary);\n}\n\n.modal-copy,\nds-text.modal-heading,\nds-text.modal-description {\n width: auto;\n text-align: left;\n}\n\nds-text.modal-heading {\n -webkit-user-select: none;\n user-select: none;\n}\n\n.modal-close-tooltip {\n flex: 0 0 auto;\n}\n\n.modal-content {\n min-height: 0;\n padding: var(--dimension-space-200);\n overflow-y: auto;\n flex: 1 1 auto;\n box-sizing: border-box;\n}\n\n.modal-footer {\n justify-content: flex-start;\n height: var(--dimension-size-800);\n flex: 0 0 auto;\n border-top: var(--dimension-stroke-width-012) solid var(--color-border-tertiary);\n}\n\n.modal-footer__actions {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n min-width: 0;\n}\n\n.modal-footer--empty {\n display: none;\n}\n\n.modal-footer__actions slot {\n display: contents;\n}\n\n@media (forced-colors: active) {\n .modal-dialog {\n box-shadow: none;\n outline: var(--dimension-stroke-width-012) solid var(--ds-forced-color-content);\n outline-offset: calc(-1 * var(--dimension-stroke-width-012));\n }\n}\n",
292
+ "content": "@import '../../utils/chrome-header.css';\n@import '../../utils/chrome-layout.css';\n@import '../../utils/forced-colors.css';\n\n:host {\n display: contents;\n}\n\n.modal-dialog {\n display: none;\n flex-direction: column;\n max-width: none;\n max-height: calc(100vh - 2 * var(--dimension-space-200));\n margin: auto;\n padding: 0;\n overflow: hidden;\n color: var(--color-foreground-primary);\n background-color: var(--color-background-primary);\n border: 0;\n border-radius: var(--dimension-radius-050);\n box-shadow: var(--effect-elevation-elevated-floating);\n outline: none;\n box-sizing: border-box;\n animation: modalDialogIn var(--effect-motion-short-2) forwards;\n}\n\n.modal-dialog[open] {\n display: flex;\n}\n\n.modal-dialog::backdrop {\n background-color: var(--color-background-shade);\n animation: modalBackdropIn var(--effect-motion-short-2) forwards;\n}\n\n.modal-dialog--closing {\n animation: modalDialogOut var(--effect-motion-short-2) forwards;\n}\n\n.modal-dialog--closing::backdrop {\n animation: modalBackdropOut var(--effect-motion-short-2) forwards;\n}\n\n@keyframes modalBackdropIn {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@keyframes modalBackdropOut {\n from { opacity: 1; }\n to { opacity: 0; }\n}\n\n@keyframes modalDialogIn {\n from { opacity: 0; transform: scale(0.97) translateY(4px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n}\n\n@keyframes modalDialogOut {\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.97) translateY(4px); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .modal-dialog[open],\n .modal-dialog--closing {\n animation: none;\n transform: none;\n }\n\n .modal-dialog::backdrop {\n animation: none;\n opacity: 1;\n }\n\n .modal-dialog--closing,\n .modal-dialog--closing::backdrop {\n opacity: 0;\n }\n}\n\n.modal-header {\n align-items: flex-start;\n flex: 0 0 auto;\n color: var(--color-foreground-primary);\n}\n\n.modal-copy,\nds-text.modal-heading,\nds-text.modal-description {\n width: auto;\n text-align: left;\n}\n\nds-text.modal-heading {\n -webkit-user-select: none;\n user-select: none;\n}\n\n.modal-close-tooltip {\n flex: 0 0 auto;\n}\n\n.modal-content {\n min-height: 0;\n padding: var(--dimension-space-200);\n overflow-y: auto;\n flex: 1 1 auto;\n box-sizing: border-box;\n}\n\n.modal-footer {\n justify-content: flex-start;\n height: var(--dimension-size-800);\n flex: 0 0 auto;\n border-top: var(--dimension-stroke-width-012) solid var(--color-border-tertiary);\n}\n\n.modal-footer__actions {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n min-width: 0;\n}\n\n.modal-footer--empty {\n display: none;\n}\n\n.modal-footer__actions slot {\n display: contents;\n}\n\n@media (forced-colors: active) {\n .modal-dialog {\n box-shadow: none;\n outline: var(--dimension-stroke-width-012) solid var(--ds-forced-color-content);\n outline-offset: calc(-1 * var(--dimension-stroke-width-012));\n }\n}\n",
293
293
  "type": "registry:ui"
294
294
  },
295
295
  {
296
296
  "path": "src/wc/components/Modal/Modal.tsx",
297
- "content": "import { Component, Prop, State, Event, EventEmitter, Element, Watch, h, Host } from '@stencil/core';\nimport { resolveMotionTimeMs, TOKEN_DEFAULTS } from '../../utils';\n\nexport type ModalWidth = 'sm' | 'md' | 'lg';\nexport type ModalCloseReason = 'close-button' | 'escape' | 'backdrop';\n\nexport interface ModalCloseDetail {\n reason: ModalCloseReason;\n originalEvent: Event;\n}\n\nconst WIDTH_MAP: Record<ModalWidth, string> = {\n sm: 'var(--dimension-modal-width-sm)',\n md: 'var(--dimension-modal-width-md)',\n lg: 'var(--dimension-modal-width-lg)',\n};\n\nconst FOCUSABLE_SEL = [\n 'a[href]',\n 'area[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n 'iframe',\n '[contenteditable=\"true\"]',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\nlet modalIdCounter = 0;\n\n@Component({\n tag: 'ds-modal',\n styleUrl: 'Modal.css',\n scoped: true,\n})\nexport class Modal {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) open: boolean = false;\n @Prop() heading!: string;\n /** Optional supporting copy shown below the heading. */\n @Prop() description: string = '';\n @Prop() closeAriaLabel: string = 'Close';\n @Prop() modalWidth: ModalWidth | string = 'md';\n /** Optional id reference for explanatory content in the default slot. */\n @Prop({ attribute: 'aria-describedby' }) ariaDescribedby: string | undefined;\n\n @State() private closing: boolean = false;\n @State() private hasFooter: boolean = false;\n\n /** Emitted when an internal dismissal control requests that the modal close. */\n @Event() dsClose!: EventEmitter<ModalCloseDetail>;\n /** Emitted after exit motion completes, the top layer closes, and focus is restored. */\n @Event() dsAfterClose!: EventEmitter<void>;\n\n private titleId = `ds-modal-title-${++modalIdCounter}`;\n private descriptionId = `${this.titleId}-description`;\n private dialogEl: HTMLDialogElement | null = null;\n private previousFocus: HTMLElement | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n\n componentDidLoad() {\n this.updateFooterPresence();\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.clearCloseTimer();\n if (this.dialogEl?.open) this.dialogEl.close();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.clearCloseTimer();\n const alreadyOpen = !!this.dialogEl?.open;\n this.closing = false;\n if (!this.dialogEl || alreadyOpen) return;\n this.previousFocus = document.activeElement as HTMLElement | null;\n this.dialogEl.showModal();\n requestAnimationFrame(() => {\n if (this.open && this.dialogEl?.open) this.focusClose();\n });\n } else if (this.dialogEl?.open) {\n this.closing = true;\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 private focusClose() {\n const close = this.el.querySelector('ds-button-unfilled.modal-close') as\n | (HTMLElement & { setFocus?: () => Promise<void> })\n | null;\n void close?.setFocus?.();\n }\n\n private clearCloseTimer() {\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private requestClose(reason: ModalCloseReason, originalEvent: Event) {\n if (!this.open || this.closing || !this.dialogEl?.open) return;\n this.dsClose.emit({ reason, originalEvent });\n this.open = false;\n }\n\n private updateFooterPresence(slot?: HTMLSlotElement) {\n const assignedNodes = slot?.assignedNodes() ?? [];\n this.hasFooter =\n assignedNodes.some(node => node.nodeType !== Node.TEXT_NODE || !!node.textContent?.trim()) ||\n this.el.querySelector('[slot=\"footer\"]') !== null;\n }\n\n private handleCancel(event: Event) {\n event.preventDefault();\n this.requestClose('escape', event);\n }\n\n private handleBackdropPointerDown(event: PointerEvent) {\n if (event.target === this.dialogEl) this.requestClose('backdrop', event);\n }\n\n private handleKeyDown(event: KeyboardEvent) {\n if (event.key !== 'Tab' || !this.dialogEl) return;\n const focusables = Array.from(\n this.dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE_SEL)\n ).filter(element =>\n !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true'\n );\n if (!focusables.length) {\n event.preventDefault();\n this.dialogEl.focus();\n return;\n }\n const first = focusables[0];\n const last = focusables[focusables.length - 1];\n const active = document.activeElement;\n if (event.shiftKey && (active === first || active === this.dialogEl)) {\n event.preventDefault();\n last.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first.focus();\n }\n }\n\n private get resolvedWidth(): string {\n if (this.modalWidth === 'sm' || this.modalWidth === 'md' || this.modalWidth === 'lg') {\n return WIDTH_MAP[this.modalWidth];\n }\n return this.modalWidth as string;\n }\n\n private get describedBy(): string | undefined {\n const ids = [this.description ? this.descriptionId : '', this.ariaDescribedby ?? ''].filter(\n Boolean,\n );\n return ids.length > 0 ? ids.join(' ') : undefined;\n }\n\n private get closeAnimationMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort3, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private finishClose() {\n if (!this.dialogEl?.open) return;\n this.dialogEl.close();\n this.closing = false;\n this.clearCloseTimer();\n this.previousFocus?.focus?.();\n this.previousFocus = null;\n this.dsAfterClose.emit();\n }\n\n render() {\n return (\n <Host>\n <dialog\n ref={(element?: HTMLDialogElement) => {\n this.dialogEl = element ?? null;\n }}\n class={{ 'modal-dialog': true, 'modal-dialog--closing': this.closing }}\n aria-labelledby={this.titleId}\n aria-describedby={this.describedBy}\n onCancel={(event: Event) => this.handleCancel(event)}\n onPointerDown={(event: PointerEvent) => this.handleBackdropPointerDown(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleKeyDown(event)}\n style={{\n width: `min(${this.resolvedWidth}, calc(100vw - 2 * var(--dimension-space-200)))`,\n }}\n >\n <div class=\"modal-header ds-chrome-header ds-chrome-header--bounded\">\n <div class=\"modal-copy ds-chrome-header__copy ds-chrome-header__copy--stacked ds-control--md\">\n <ds-text\n class=\"modal-heading ds-chrome-header__heading\"\n as=\"h2\"\n variant=\"text-title-small\"\n emphasis\n color=\"primary\"\n lineTruncation={1}\n textId={this.titleId}\n >\n {this.heading}\n </ds-text>\n {this.description ? (\n <ds-text\n class=\"modal-description ds-chrome-header__description\"\n as=\"p\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n wrap=\"wrap\"\n textId={this.descriptionId}\n >\n {this.description}\n </ds-text>\n ) : null}\n </div>\n <ds-tooltip class=\"modal-close-tooltip ds-chrome-header__trailing\" label={this.closeAriaLabel} side=\"bottom\" size=\"sm\">\n <ds-button-unfilled\n class=\"modal-close\"\n variant=\"icon\"\n icon=\"Cross\"\n size=\"md\"\n aria-label={this.closeAriaLabel}\n activeFill={false}\n hasBorder={false}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.requestClose('close-button', event.detail)\n }\n />\n </ds-tooltip>\n </div>\n <div class=\"modal-content\">\n <slot />\n </div>\n <div\n class={{\n 'modal-footer': true,\n 'ds-chrome-row': true,\n 'ds-chrome-space--lg': true,\n 'modal-footer--empty': !this.hasFooter,\n }}\n >\n <div class=\"modal-footer__actions\">\n <slot\n name=\"footer\"\n onSlotchange={(event: Event) =>\n this.updateFooterPresence(event.currentTarget as HTMLSlotElement)\n }\n />\n </div>\n </div>\n </dialog>\n </Host>\n );\n }\n}\n",
297
+ "content": "import { Component, Prop, State, Event, EventEmitter, Element, Watch, h, Host } from '@stencil/core';\nimport { resolveMotionTimeMs, TOKEN_DEFAULTS } from '../../utils';\n\nexport type ModalWidth = 'sm' | 'md' | 'lg';\nexport type ModalCloseReason = 'close-button' | 'escape' | 'backdrop';\n\nexport interface ModalCloseDetail {\n reason: ModalCloseReason;\n originalEvent: Event;\n}\n\nconst WIDTH_MAP: Record<ModalWidth, string> = {\n sm: 'var(--dimension-modal-width-sm)',\n md: 'var(--dimension-modal-width-md)',\n lg: 'var(--dimension-modal-width-lg)',\n};\n\nconst FOCUSABLE_SEL = [\n 'a[href]',\n 'area[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n 'iframe',\n '[contenteditable=\"true\"]',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\nlet modalIdCounter = 0;\n\n@Component({\n tag: 'ds-modal',\n styleUrl: 'Modal.css',\n scoped: true,\n})\nexport class Modal {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) open: boolean = false;\n @Prop() heading!: string;\n /** Optional supporting copy shown below the heading. */\n @Prop() description: string = '';\n @Prop() closeAriaLabel: string = 'Close';\n @Prop() modalWidth: ModalWidth | string = 'md';\n /** Optional id reference for explanatory content in the default slot. */\n @Prop({ attribute: 'aria-describedby' }) ariaDescribedby: string | undefined;\n\n @State() private closing: boolean = false;\n @State() private hasFooter: boolean = false;\n\n /** Emitted when an internal dismissal control requests that the modal close. */\n @Event() dsClose!: EventEmitter<ModalCloseDetail>;\n /** Emitted after exit motion completes, the top layer closes, and focus is restored. */\n @Event() dsAfterClose!: EventEmitter<void>;\n\n private titleId = `ds-modal-title-${++modalIdCounter}`;\n private descriptionId = `${this.titleId}-description`;\n private dialogEl: HTMLDialogElement | null = null;\n private previousFocus: HTMLElement | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n\n componentDidLoad() {\n this.updateFooterPresence();\n if (this.open) this.onOpenChange(true);\n }\n\n componentDidRender() {\n if (!this.closing || !this.dialogEl?.open || this.closeTimer) return;\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 disconnectedCallback() {\n this.clearCloseTimer();\n if (this.dialogEl?.open) this.dialogEl.close();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.clearCloseTimer();\n const alreadyOpen = !!this.dialogEl?.open;\n this.closing = false;\n if (!this.dialogEl || alreadyOpen) return;\n this.previousFocus = document.activeElement as HTMLElement | null;\n this.dialogEl.showModal();\n requestAnimationFrame(() => {\n if (this.open && this.dialogEl?.open) this.focusClose();\n });\n } else if (this.dialogEl?.open) {\n this.closing = true;\n }\n }\n\n private focusClose() {\n const close = this.el.querySelector('ds-button-unfilled.modal-close') as\n | (HTMLElement & { setFocus?: () => Promise<void> })\n | null;\n void close?.setFocus?.();\n }\n\n private clearCloseTimer() {\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private requestClose(reason: ModalCloseReason, originalEvent: Event) {\n if (!this.open || this.closing || !this.dialogEl?.open) return;\n this.dsClose.emit({ reason, originalEvent });\n this.open = false;\n }\n\n private updateFooterPresence(slot?: HTMLSlotElement) {\n const assignedNodes = slot?.assignedNodes() ?? [];\n this.hasFooter =\n assignedNodes.some(node => node.nodeType !== Node.TEXT_NODE || !!node.textContent?.trim()) ||\n this.el.querySelector('[slot=\"footer\"]') !== null;\n }\n\n private handleCancel(event: Event) {\n event.preventDefault();\n this.requestClose('escape', event);\n }\n\n private handleBackdropPointerDown(event: PointerEvent) {\n if (event.target === this.dialogEl) this.requestClose('backdrop', event);\n }\n\n private handleKeyDown(event: KeyboardEvent) {\n if (event.key !== 'Tab' || !this.dialogEl) return;\n const focusables = Array.from(\n this.dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE_SEL)\n ).filter(element =>\n !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true'\n );\n if (!focusables.length) {\n event.preventDefault();\n this.dialogEl.focus();\n return;\n }\n const first = focusables[0];\n const last = focusables[focusables.length - 1];\n const active = document.activeElement;\n if (event.shiftKey && (active === first || active === this.dialogEl)) {\n event.preventDefault();\n last.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first.focus();\n }\n }\n\n private get resolvedWidth(): string {\n if (this.modalWidth === 'sm' || this.modalWidth === 'md' || this.modalWidth === 'lg') {\n return WIDTH_MAP[this.modalWidth];\n }\n return this.modalWidth as string;\n }\n\n private get describedBy(): string | undefined {\n const ids = [this.description ? this.descriptionId : '', this.ariaDescribedby ?? ''].filter(\n Boolean,\n );\n return ids.length > 0 ? ids.join(' ') : undefined;\n }\n\n private get closeAnimationMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort2, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private finishClose() {\n if (!this.dialogEl?.open) return;\n this.dialogEl.close();\n this.closing = false;\n this.clearCloseTimer();\n this.previousFocus?.focus?.();\n this.previousFocus = null;\n this.dsAfterClose.emit();\n }\n\n render() {\n return (\n <Host>\n <dialog\n ref={(element?: HTMLDialogElement) => {\n this.dialogEl = element ?? null;\n }}\n class={{ 'modal-dialog': true, 'modal-dialog--closing': this.closing }}\n aria-labelledby={this.titleId}\n aria-describedby={this.describedBy}\n onCancel={(event: Event) => this.handleCancel(event)}\n onPointerDown={(event: PointerEvent) => this.handleBackdropPointerDown(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleKeyDown(event)}\n style={{\n width: `min(${this.resolvedWidth}, calc(100vw - 2 * var(--dimension-space-200)))`,\n }}\n >\n <div class=\"modal-header ds-chrome-header ds-chrome-header--bounded\">\n <div class=\"modal-copy ds-chrome-header__copy ds-chrome-header__copy--stacked ds-control--md\">\n <ds-text\n class=\"modal-heading ds-chrome-header__heading\"\n as=\"h2\"\n variant=\"text-title-small\"\n emphasis\n color=\"primary\"\n lineTruncation={1}\n textId={this.titleId}\n >\n {this.heading}\n </ds-text>\n {this.description ? (\n <ds-text\n class=\"modal-description ds-chrome-header__description\"\n as=\"p\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n wrap=\"wrap\"\n textId={this.descriptionId}\n >\n {this.description}\n </ds-text>\n ) : null}\n </div>\n <ds-tooltip class=\"modal-close-tooltip ds-chrome-header__trailing\" label={this.closeAriaLabel} side=\"bottom\" size=\"sm\">\n <ds-button-unfilled\n class=\"modal-close\"\n variant=\"icon\"\n icon=\"Cross\"\n size=\"md\"\n aria-label={this.closeAriaLabel}\n activeFill={false}\n hasBorder={false}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.requestClose('close-button', event.detail)\n }\n />\n </ds-tooltip>\n </div>\n <div class=\"modal-content\">\n <slot />\n </div>\n <div\n class={{\n 'modal-footer': true,\n 'ds-chrome-row': true,\n 'ds-chrome-space--lg': true,\n 'modal-footer--empty': !this.hasFooter,\n }}\n >\n <div class=\"modal-footer__actions\">\n <slot\n name=\"footer\"\n onSlotchange={(event: Event) =>\n this.updateFooterPresence(event.currentTarget as HTMLSlotElement)\n }\n />\n </div>\n </div>\n </dialog>\n </Host>\n );\n }\n}\n",
298
298
  "type": "registry:ui"
299
299
  }
300
300
  ]